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.
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/PKG-INFO +2 -1
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/pyproject.toml +2 -1
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/__init__.py +3 -0
- {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
- {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
- {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
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/controllers/logs_controller.py +102 -0
- {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
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/controllers/status_controller.py +100 -0
- 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
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/dashboard/assets/index-CCGF7PHx.js +257 -0
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/dashboard/index.html +3 -3
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/dashboard/qodalis-icon.svg +50 -0
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/admin_branding.py +75 -0
- {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
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/log_ring_buffer.py +323 -0
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/status_history.py +143 -0
- qodalis_cli_server_plugin_admin-1.0.0b24/qodalis_cli_admin/services/time_range.py +58 -0
- qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/controllers/logs_controller.py +0 -40
- qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/controllers/status_controller.py +0 -56
- qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/dashboard/assets/index-CThBrI8x.js +0 -248
- qodalis_cli_server_plugin_admin-1.0.0b8/qodalis_cli_admin/services/log_ring_buffer.py +0 -144
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/.gitignore +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/auth/__init__.py +0 -0
- {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
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/controllers/__init__.py +0 -0
- {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
- {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
- {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
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/dashboard_resolver.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/qodalis_cli_admin/services/__init__.py +0 -0
- {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
- {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
- {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
{qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: qodalis-cli-server-plugin-admin
|
|
3
|
-
Version: 1.0.
|
|
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
|
{qodalis_cli_server_plugin_admin-1.0.0b8 → qodalis_cli_server_plugin_admin-1.0.0b24}/pyproject.toml
RENAMED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
19
|
-
if
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|