qodalis-cli-server-plugin-admin 1.0.0b8__tar.gz → 1.0.0b24__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/PKG-INFO +2 -1
  2. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/pyproject.toml +2 -1
  3. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/__init__.py +3 -0
  4. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/auth/auth_controller.py +34 -2
  5. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/auth/auth_middleware.py +12 -5
  6. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/cli_admin_builder.py +82 -8
  7. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/controllers/logs_controller.py +102 -0
  8. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/controllers/plugins_controller.py +12 -1
  9. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/controllers/status_controller.py +100 -0
  10. qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/dashboard/assets/index-SAFC7f6i.css → qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/dashboard/assets/index-C5vgichu.css +1 -1
  11. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/dashboard/assets/index-CCGF7PHx.js +257 -0
  12. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/dashboard/index.html +3 -3
  13. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/dashboard/qodalis-icon.svg +50 -0
  14. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/admin_branding.py +75 -0
  15. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/services/admin_config.py +35 -0
  16. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/log_ring_buffer.py +323 -0
  17. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/status_history.py +143 -0
  18. qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/time_range.py +58 -0
  19. qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/controllers/logs_controller.py +0 -40
  20. qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/controllers/status_controller.py +0 -56
  21. qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/dashboard/assets/index-CThBrI8x.js +0 -248
  22. qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/services/log_ring_buffer.py +0 -144
  23. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/.gitignore +0 -0
  24. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/auth/__init__.py +0 -0
  25. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/auth/jwt_service.py +0 -0
  26. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/controllers/__init__.py +0 -0
  27. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/controllers/config_controller.py +0 -0
  28. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
  29. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/dashboard/assets/wasm/qodalis_cli_wasm_bg.wasm +0 -0
  30. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/dashboard_resolver.py +0 -0
  31. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/services/__init__.py +0 -0
  32. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/services/admin_theme.py +0 -0
  33. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/services/module_registry.py +0 -0
  34. {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/spa_static_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qodalis-cli-server-plugin-admin
3
- Version: 1.0.0b8
3
+ Version: 1.0.0b24
4
4
  Summary: Admin dashboard plugin for Qodalis CLI servers
5
5
  Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
6
6
  Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
@@ -20,3 +20,4 @@ Requires-Python: >=3.10
20
20
  Requires-Dist: fastapi>=0.100.0
21
21
  Requires-Dist: psutil>=5.9.0
22
22
  Requires-Dist: pyjwt>=2.0.0
23
+ Requires-Dist: qodalis-cli-server-abstractions>=1.0.0b23
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qodalis-cli-server-plugin-admin"
7
- version = "1.0.0b8"
7
+ version = "1.0.0b24"
8
8
  description = "Admin dashboard plugin for Qodalis CLI servers"
9
9
  readme = { text = "", content-type = "text/plain" }
10
10
  license = "MIT"
@@ -27,6 +27,7 @@ dependencies = [
27
27
  "PyJWT>=2.0.0",
28
28
  "fastapi>=0.100.0",
29
29
  "psutil>=5.9.0",
30
+ "qodalis-cli-server-abstractions>=1.0.0b23",
30
31
  ]
31
32
 
32
33
  [project.urls]
@@ -6,6 +6,7 @@ from .auth.auth_middleware import create_auth_dependency
6
6
  from .auth.auth_controller import create_auth_router
7
7
  from .dashboard_resolver import resolve_dashboard_dir
8
8
  from .services.admin_config import AdminConfig
9
+ from .services.admin_branding import is_branding_icon, validate_admin_branding
9
10
  from .services.admin_theme import (
10
11
  ADMIN_THEME_MODES,
11
12
  ADMIN_THEME_TOKENS,
@@ -30,7 +31,9 @@ __all__ = [
30
31
  "admin_theme_json",
31
32
  "create_auth_dependency",
32
33
  "create_auth_router",
34
+ "is_branding_icon",
33
35
  "is_theme_color",
34
36
  "resolve_dashboard_dir",
37
+ "validate_admin_branding",
35
38
  "validate_admin_theme",
36
39
  ]
@@ -4,9 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import time
6
6
  from collections import defaultdict
7
- from typing import Any
7
+ from typing import Any, Callable
8
8
 
9
9
  from fastapi import APIRouter, Depends, HTTPException, Request
10
+ from fastapi.responses import JSONResponse
10
11
  from pydantic import BaseModel
11
12
 
12
13
  from .jwt_service import JwtService
@@ -52,6 +53,11 @@ class _RateLimiter:
52
53
 
53
54
 
54
55
  ADMIN_ACCESS = "admin:access"
56
+ #: ``GET /auth/mode``: the dashboard's own username/password form.
57
+ MODE_PASSWORD = "password"
58
+ #: ``GET /auth/mode``: sign-in through the host application.
59
+ MODE_EXTERNAL = "external"
60
+ EXTERNAL_AUTH_CODE = "EXTERNAL_AUTH"
55
61
 
56
62
 
57
63
  def trusted_hops(trust_proxy: bool | int | None) -> int:
@@ -107,18 +113,44 @@ def create_auth_router(
107
113
  *,
108
114
  trust_proxy: bool | int = False,
109
115
  users_plugin: Any = None,
116
+ is_external: Callable[[], bool] | None = None,
110
117
  ) -> APIRouter:
111
- """Create a FastAPI router with ``POST /login`` and ``GET /me``.
118
+ """Create a FastAPI router with ``GET /mode``, ``POST /login`` and
119
+ ``GET /me``.
112
120
 
113
121
  With ``users_plugin`` the login goes through the users auth service and
114
122
  requires the ``admin:access`` permission; the returned token is the
115
123
  users session token. Otherwise the static admin credentials are used.
124
+ In external sign-in mode (``is_external()``: the server's
125
+ ``external_authentication``) the login answers 501.
116
126
  """
117
127
  router = APIRouter()
118
128
  limiter = _RateLimiter()
119
129
 
130
+ def external() -> bool:
131
+ return is_external is not None and is_external()
132
+
133
+ @router.get("/mode")
134
+ async def mode() -> JSONResponse:
135
+ """Which sign-in the dashboard shows (public). ``external`` when the
136
+ host application signs users in (it hands its token to the dashboard
137
+ through ``/qcli/admin/#token=…``), otherwise ``password``."""
138
+ return JSONResponse(
139
+ {"mode": MODE_EXTERNAL if external() else MODE_PASSWORD, "signInUrl": admin_config.sign_in_url},
140
+ headers={"Cache-Control": "no-store"},
141
+ )
142
+
120
143
  @router.post("/login", response_model=LoginResponse)
121
144
  async def login(body: LoginRequest, request: Request) -> Any:
145
+ if external():
146
+ # The static admin credentials sign no one in here.
147
+ url = admin_config.sign_in_url
148
+ where = f" at {url}" if url is not None else " through the host application"
149
+ return JSONResponse(status_code=501, content={
150
+ "error": f"Sign-in is managed by the host application. Sign in{where}.",
151
+ "code": EXTERNAL_AUTH_CODE,
152
+ "signInUrl": url,
153
+ })
122
154
  ip = client_ip(request, trust_proxy)
123
155
  limiter.check(ip)
124
156
 
@@ -15,11 +15,18 @@ def create_users_auth_dependency(users_plugin: Any):
15
15
  ``admin:access`` (admin ``*`` has it)."""
16
16
 
17
17
  async def require_auth(request: Request) -> dict[str, Any]:
18
- auth_header = request.headers.get("Authorization", "")
19
- if not auth_header.startswith("Bearer "):
20
- raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
21
- await users_plugin.ensure_seeded()
22
- ctx = await users_plugin.auth_service.authenticate_token(auth_header[7:].strip())
18
+ auth_service = users_plugin.auth_service
19
+ if auth_service.external_auth:
20
+ # The host application signs users in: its external auth adapter
21
+ # authenticates the request (e.g. the bearer token handed to the
22
+ # dashboard through /qcli/admin/#token=…).
23
+ ctx = await users_plugin.require_auth(request)
24
+ else:
25
+ auth_header = request.headers.get("Authorization", "")
26
+ if not auth_header.startswith("Bearer "):
27
+ raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
28
+ await users_plugin.ensure_seeded()
29
+ ctx = await auth_service.authenticate_token(auth_header[7:].strip())
23
30
  if ctx.must_change_password:
24
31
  raise HTTPException(status_code=403, detail="password change required")
25
32
  if not ctx.can("admin:access"):
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  import time
7
- from dataclasses import dataclass
8
+ from dataclasses import dataclass, field
8
9
  from collections.abc import Mapping
9
10
  from typing import Any, Callable, Awaitable
10
11
 
@@ -20,9 +21,11 @@ from .controllers.config_controller import create_config_router
20
21
  from .controllers.logs_controller import create_logs_router
21
22
  from .controllers.ws_clients_controller import create_ws_clients_router
22
23
  from .services.admin_config import AdminConfig
23
- from .services.admin_theme import admin_theme_json
24
+ from .services.admin_branding import validate_admin_branding
25
+ from .services.admin_theme import validate_admin_theme
24
26
  from .services.log_ring_buffer import LogRingBuffer
25
27
  from .services.module_registry import ModuleRegistry
28
+ from .services.status_history import StatusHistory
26
29
  from .dashboard_resolver import resolve_dashboard_dir
27
30
 
28
31
 
@@ -36,11 +39,20 @@ class AdminBuildDeps:
36
39
  registry: Any # CliCommandRegistry
37
40
  event_socket_manager: Any # CliEventSocketManager
38
41
  builder: Any # CliBuilder
42
+ #: Where the wire message of every log entry (``log:entry``) goes; by
43
+ #: default they are published on ``event_bus`` with the audience ``logs:read``.
39
44
  broadcast_fn: BroadcastFn | None = None
45
+ #: The server's event bus (``result.event_bus``); taken from the server
46
+ #: by ``mount_plugin`` when neither it nor ``broadcast_fn`` is given.
47
+ event_bus: Any = None
40
48
  enabled_features: list[str] | None = None
41
49
  #: When set, the dashboard authenticates through the users plugin
42
50
  #: (``admin:access`` permission); static credentials are not used.
43
51
  users_plugin: Any = None
52
+ #: The server's ``CliServerOptions`` (``result.options``): its
53
+ #: ``external_authentication`` selects the dashboard's sign-in mode.
54
+ #: Taken from the server by ``mount_plugin`` when not given.
55
+ server_options: Any = None
44
56
 
45
57
 
46
58
  @dataclass
@@ -54,6 +66,28 @@ class CliAdminPlugin:
54
66
  log_buffer: LogRingBuffer
55
67
  dashboard_app: Any # ASGI app (SPA fallback) or None
56
68
  module_registry: ModuleRegistry | None = None
69
+ #: Status samples of ``GET /status/history``; sampling runs with the
70
+ #: app's lifespan once the plugin is mounted.
71
+ status_history: StatusHistory | None = None
72
+ #: Holds the server options read by ``GET /auth/mode`` (see on_mount).
73
+ _server: dict = field(default_factory=dict, repr=False)
74
+
75
+ def on_mount(self, server: Any) -> None:
76
+ """Hook called by ``CliServerResult.mount_plugin``: the sign-in mode
77
+ follows the server's ``options.external_authentication`` and the log
78
+ entries go to the server's event bus."""
79
+ if self._server.get("options") is None:
80
+ self._server["options"] = getattr(server, "options", None)
81
+ # Log entries are published on the server's bus (``log:entry``, audience ``logs:read``)
82
+ # unless the plugin was built with a bus or a broadcast function
83
+ if self.log_buffer.event_bus is None and self.log_buffer.broadcast_fn is None:
84
+ self.log_buffer.event_bus = getattr(server, "event_bus", None)
85
+ app = getattr(server, "app", None)
86
+ router = getattr(app, "router", None)
87
+ if self.status_history is not None and router is not None and not self._server.get("sampling"):
88
+ # Sample while the app runs; cancelled on shutdown.
89
+ router.lifespan_context = self.status_history.wrap_lifespan(router.lifespan_context)
90
+ self._server["sampling"] = True
57
91
 
58
92
 
59
93
  class CliAdminBuilder:
@@ -66,7 +100,9 @@ class CliAdminBuilder:
66
100
  self._dashboard_path: str | None = None
67
101
  self._trust_proxy = False
68
102
  self._users_plugin: Any = None
69
- self._theme_json: bytes = admin_theme_json(None)
103
+ self._theme: dict[str, Any] = validate_admin_theme(None)
104
+ self._branding: dict[str, Any] | None = None
105
+ self._sign_in_url: str | None = None
70
106
 
71
107
  def set_credentials(self, username: str, password: str) -> CliAdminBuilder:
72
108
  """Set admin login credentials (overrides env vars)."""
@@ -112,7 +148,34 @@ class CliAdminBuilder:
112
148
  "dark": {"primary": "#4f9be0", "background": "#0d1823"},
113
149
  })
114
150
  """
115
- self._theme_json = admin_theme_json(theme)
151
+ self._theme = validate_admin_theme(theme)
152
+ return self
153
+
154
+ def set_branding(self, branding: Mapping[str, Any]) -> CliAdminBuilder:
155
+ """Ship the dashboard under your own name and icon, and name its terminal.
156
+
157
+ ``{"name": "RateBell", "icon_url": "/favicon.svg", "cli_name": "RateBell CLI"}``:
158
+ the name and icon of the browser tab, sidebar and sign-in page, and the
159
+ terminal's name in its welcome banner. The icon is an absolute http(s)
160
+ URL, a root-relative path or a base64 ``data:image`` URL. Unset values
161
+ keep the Qodalis defaults (Qodalis Cli Admin, the Qodalis Solutions
162
+ logo, Web CLI); a bad value raises :class:`ValueError` here.
163
+ """
164
+ self._branding = validate_admin_branding(branding)
165
+ return self
166
+
167
+ def _theme_json(self) -> bytes:
168
+ """The ``theme.json`` body: the color scheme, plus ``branding`` when set."""
169
+ doc = {**self._theme, "branding": self._branding} if self._branding else self._theme
170
+ return json.dumps(doc, separators=(",", ":")).encode()
171
+
172
+ def set_sign_in_url(self, url: str | None) -> CliAdminBuilder:
173
+ """Where the host application signs users in, shown by the dashboard
174
+ in external sign-in mode (users plugin with an external auth adapter)
175
+ and returned by ``GET /api/v1/qcli/auth/mode``. An absolute
176
+ ``http(s)`` URL or a root-relative path (``/sign-in``); anything else
177
+ raises :class:`ValueError` in :meth:`build`."""
178
+ self._sign_in_url = url
116
179
  return self
117
180
 
118
181
  def set_users_plugin(self, users_plugin: Any) -> CliAdminBuilder:
@@ -130,9 +193,15 @@ class CliAdminBuilder:
130
193
  password=self._password,
131
194
  jwt_secret=self._jwt_secret,
132
195
  warn_default=users_plugin is None,
196
+ sign_in_url=self._sign_in_url,
133
197
  )
198
+ config.validate()
199
+ server: dict = {"options": deps.server_options}
200
+
201
+ def is_external() -> bool:
202
+ return bool(getattr(server["options"], "external_authentication", False))
134
203
  jwt_service = JwtService(secret=config.jwt_secret)
135
- log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn)
204
+ log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn, event_bus=deps.event_bus)
136
205
  log_buffer.install_handler()
137
206
  module_registry = ModuleRegistry(deps.builder)
138
207
  if users_plugin is not None:
@@ -148,6 +217,7 @@ class CliAdminBuilder:
148
217
  auth_router = create_auth_router(
149
218
  config, jwt_service, auth_dependency=auth_dep,
150
219
  trust_proxy=self._trust_proxy, users_plugin=users_plugin,
220
+ is_external=is_external,
151
221
  )
152
222
  router.include_router(auth_router, prefix="/auth")
153
223
 
@@ -166,13 +236,15 @@ class CliAdminBuilder:
166
236
  enabled_features.append("jobs")
167
237
  break
168
238
 
239
+ status_history = StatusHistory(deps.event_socket_manager)
169
240
  status_router = create_status_router(
170
241
  start_time, deps.event_socket_manager, auth_dep, enabled_features,
171
- registry=deps.registry,
242
+ registry=deps.registry, builder=deps.builder,
243
+ status_history=status_history,
172
244
  )
173
245
  router.include_router(status_router)
174
246
 
175
- plugins_router = create_plugins_router(module_registry, auth_dep)
247
+ plugins_router = create_plugins_router(module_registry, auth_dep, deps.builder)
176
248
  router.include_router(plugins_router)
177
249
 
178
250
  config_router = create_config_router(config, auth_dep)
@@ -189,7 +261,7 @@ class CliAdminBuilder:
189
261
 
190
262
  if dashboard_dir:
191
263
  dashboard_app = SPAStaticFiles(
192
- directory=dashboard_dir, html=True, theme_json=self._theme_json,
264
+ directory=dashboard_dir, html=True, theme_json=self._theme_json(),
193
265
  )
194
266
 
195
267
  return CliAdminPlugin(
@@ -200,4 +272,6 @@ class CliAdminBuilder:
200
272
  log_buffer=log_buffer,
201
273
  dashboard_app=dashboard_app,
202
274
  module_registry=module_registry,
275
+ status_history=status_history,
276
+ _server=server,
203
277
  )
@@ -0,0 +1,102 @@
1
+ """Logs controller — query the log ring buffer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from ..services.log_ring_buffer import HISTOGRAM_LEVELS, LogRingBuffer, histogram_level
11
+ from ..services.time_range import RangeError, parse_buckets, parse_time, resolve_range
12
+
13
+
14
+ def _levels(level: str | None) -> list[str]:
15
+ """The histogram levels named in ``level`` (comma-separated);
16
+ :class:`RangeError` for an unknown one."""
17
+ levels: list[str] = []
18
+ for name in (level or "").split(","):
19
+ if not name.strip():
20
+ continue
21
+ normalized = histogram_level(name)
22
+ if normalized is None:
23
+ raise RangeError(f"Unknown level '{name.strip()}': use {', '.join(HISTOGRAM_LEVELS)}.")
24
+ if normalized not in levels:
25
+ levels.append(normalized)
26
+ return levels
27
+
28
+
29
+ def create_logs_router(
30
+ log_buffer: LogRingBuffer,
31
+ auth_dependency: Any,
32
+ ) -> APIRouter:
33
+ """Create a router with ``GET /logs`` and ``GET /logs/histogram``."""
34
+ router = APIRouter()
35
+
36
+ @router.get("/logs")
37
+ async def get_logs(
38
+ _user: dict[str, Any] = Depends(auth_dependency),
39
+ level: str | None = Query(None),
40
+ search: str | None = Query(None),
41
+ service: str | None = Query(None),
42
+ from_: str | None = Query(None, alias="from"),
43
+ to: str | None = Query(None),
44
+ limit: int = Query(100, le=1000),
45
+ offset: int = Query(0, ge=0),
46
+ ) -> Any:
47
+ """Buffered entries filtered by level, search, service and the time
48
+ range ``[from, to)`` (ISO 8601, no offset = UTC)."""
49
+ try:
50
+ since = parse_time(from_, "from")
51
+ until = parse_time(to, "to")
52
+ except RangeError as exc:
53
+ return JSONResponse(status_code=400, content={"error": str(exc)})
54
+ items, total = log_buffer.query(
55
+ level=level,
56
+ search=search,
57
+ service=service.strip() if service and service.strip() else None,
58
+ since=since,
59
+ until=until,
60
+ limit=limit,
61
+ offset=offset,
62
+ )
63
+ return {
64
+ "entries": items,
65
+ "total": total,
66
+ "limit": limit,
67
+ "offset": offset,
68
+ "hasMore": offset + len(items) < total,
69
+ # The dashboard filters on the server when it names its store.
70
+ "store": "memory",
71
+ "persistent": False,
72
+ "services": [],
73
+ }
74
+
75
+ @router.get("/logs/histogram")
76
+ async def get_logs_histogram(
77
+ _user: dict[str, Any] = Depends(auth_dependency),
78
+ from_: str | None = Query(None, alias="from"),
79
+ to: str | None = Query(None),
80
+ buckets: str | None = Query(None),
81
+ level: str | None = Query(None),
82
+ search: str | None = Query(None),
83
+ service: str | None = Query(None),
84
+ ) -> Any:
85
+ """Log entries per level in equal time columns of ``[from, to)``
86
+ (default: the last 24 hours), with the filters of ``GET /logs``."""
87
+ try:
88
+ since, until = resolve_range(from_, to)
89
+ count = parse_buckets(buckets)
90
+ levels = _levels(level)
91
+ except RangeError as exc:
92
+ return JSONResponse(status_code=400, content={"error": str(exc)})
93
+ return log_buffer.histogram(
94
+ since,
95
+ until,
96
+ count,
97
+ levels=levels or None,
98
+ search=search or None,
99
+ service=service.strip() if service and service.strip() else None,
100
+ )
101
+
102
+ return router
@@ -13,10 +13,21 @@ from ..services.module_registry import ModuleRegistry
13
13
  def create_plugins_router(
14
14
  module_registry: ModuleRegistry,
15
15
  auth_dependency: Any,
16
+ builder: Any = None,
16
17
  ) -> APIRouter:
17
- """Create a router with ``GET /plugins`` and ``POST /plugins/{id}/toggle``."""
18
+ """Create a router with ``GET /plugins``, ``GET /plugins/installed`` and
19
+ ``POST /plugins/{id}/toggle``."""
18
20
  router = APIRouter()
19
21
 
22
+ @router.get("/plugins/installed")
23
+ async def list_installed_plugins(
24
+ _user: dict[str, Any] = Depends(auth_dependency),
25
+ ) -> list[dict[str, Any]]:
26
+ """The installed server plugin packages (core, users, admin, jobs,
27
+ storage providers, ...) with their versions, in the order added."""
28
+ plugins = getattr(builder, "installed_plugins", None) or []
29
+ return [p.to_dict() for p in plugins]
30
+
20
31
  @router.get("/plugins")
21
32
  async def list_plugins(
22
33
  _user: dict[str, Any] = Depends(auth_dependency),
@@ -0,0 +1,100 @@
1
+ """Status controller — server health and runtime information."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from fastapi import APIRouter, Depends, Query
11
+ from fastapi.responses import JSONResponse
12
+
13
+ from ..services.status_history import StatusHistory, memory_usage_mb
14
+
15
+ #: ``GET /status/history``: default and longest window in minutes.
16
+ DEFAULT_HISTORY_MINUTES = 60
17
+ MAX_HISTORY_MINUTES = 1440
18
+
19
+
20
+ def create_status_router(
21
+ start_time: float,
22
+ event_socket_manager: Any,
23
+ auth_dependency: Any,
24
+ enabled_features: list[str] | None = None,
25
+ registry: Any = None,
26
+ builder: Any = None,
27
+ status_history: StatusHistory | None = None,
28
+ ) -> APIRouter:
29
+ """Create a router that exposes ``GET /status`` and ``GET /status/history``."""
30
+ router = APIRouter()
31
+
32
+ @router.get("/status")
33
+ async def get_status(
34
+ _user: dict[str, Any] = Depends(auth_dependency),
35
+ ) -> dict[str, Any]:
36
+ uptime_seconds = time.time() - start_time
37
+
38
+ started_at = datetime.fromtimestamp(
39
+ start_time, tz=timezone.utc
40
+ ).isoformat()
41
+
42
+ registered_commands = len(registry.processors) if registry is not None else 0
43
+
44
+ return {
45
+ "uptimeSeconds": uptime_seconds,
46
+ "memoryUsageMb": memory_usage_mb(),
47
+ "startedAt": started_at,
48
+ "platform": "python",
49
+ "platformVersion": platform.python_version(),
50
+ "os": f"{platform.system()} {platform.release()}",
51
+ "activeWsConnections": len(event_socket_manager.get_clients()),
52
+ "activeShellSessions": 0,
53
+ "registeredCommands": registered_commands,
54
+ "registeredJobs": 0,
55
+ "enabledFeatures": _features(enabled_features, builder),
56
+ }
57
+
58
+ @router.get("/status/history")
59
+ async def get_status_history(
60
+ _user: dict[str, Any] = Depends(auth_dependency),
61
+ minutes: str | None = Query(None),
62
+ ) -> Any:
63
+ """The status samples (every 15 s, kept 24 h) of the last ``minutes``
64
+ (default 60, clamped to 1..1440), oldest first."""
65
+ window = DEFAULT_HISTORY_MINUTES
66
+ if minutes is not None and minutes.strip():
67
+ try:
68
+ window = int(minutes.strip())
69
+ except ValueError:
70
+ return JSONResponse(
71
+ status_code=400,
72
+ content={"error": f"'minutes' must be an integer, got '{minutes}'"},
73
+ )
74
+ window = min(max(window, 1), MAX_HISTORY_MINUTES)
75
+ history = status_history or StatusHistory(event_socket_manager)
76
+ return {
77
+ "intervalSeconds": history.interval,
78
+ "samples": history.history(window),
79
+ }
80
+
81
+ return router
82
+
83
+
84
+ JOBS_DISTRIBUTION = "qodalis-cli-server-plugin-jobs"
85
+ #: The audit plugin (the admin plugin does not depend on it).
86
+ AUDIT_DISTRIBUTION = "qodalis-cli-server-plugin-audit"
87
+
88
+
89
+ def _features(enabled_features: list[str] | None, builder: Any) -> list[str]:
90
+ """The enabled features (filesystem, jobs, audit, in that order): ``jobs``
91
+ when the jobs plugin is mounted, ``audit`` when the audit plugin (which
92
+ serves the audit log, ``GET /api/v1/qcli/audit``; the users plugin mounts
93
+ it) is."""
94
+ features = list(enabled_features or [])
95
+ installed = {p.id for p in (getattr(builder, "installed_plugins", None) or [])}
96
+ if "jobs" not in features and JOBS_DISTRIBUTION in installed:
97
+ features.append("jobs")
98
+ if "audit" not in features and AUDIT_DISTRIBUTION in installed:
99
+ features.append("audit")
100
+ return features