qodalis-cli-server-plugin-admin 1.0.0b1__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.0b1/.gitignore +2 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/PKG-INFO +22 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/pyproject.toml +44 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/__init__.py +24 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/__init__.py +12 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/auth_controller.py +103 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/auth_middleware.py +43 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/auth/jwt_service.py +55 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/cli_admin_builder.py +146 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/__init__.py +15 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/config_controller.py +38 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/logs_controller.py +40 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/plugins_controller.py +39 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/status_controller.py +56 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/ws_clients_controller.py +23 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/dashboard_resolver.py +45 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/__init__.py +11 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/admin_config.py +114 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/log_ring_buffer.py +127 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/services/module_registry.py +64 -0
- qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/spa_static_files.py +36 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qodalis-cli-server-plugin-admin
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: Admin dashboard plugin for Qodalis CLI servers
|
|
5
|
+
Project-URL: Homepage, https://github.com/qodalis-solutions/cli-server-python
|
|
6
|
+
Project-URL: Repository, https://github.com/qodalis-solutions/cli-server-python
|
|
7
|
+
Project-URL: Issues, https://github.com/qodalis-solutions/cli-server-python/issues
|
|
8
|
+
Author-email: Nicolae Lupei <nicolae.lupei@qodalis.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: admin,cli,dashboard,plugin,qodalis
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: fastapi>=0.100.0
|
|
21
|
+
Requires-Dist: psutil>=5.9.0
|
|
22
|
+
Requires-Dist: pyjwt>=2.0.0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qodalis-cli-server-plugin-admin"
|
|
7
|
+
version = "1.0.0b1"
|
|
8
|
+
description = "Admin dashboard plugin for Qodalis CLI servers"
|
|
9
|
+
readme = { text = "", content-type = "text/plain" }
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Nicolae Lupei", email = "nicolae.lupei@qodalis.com" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["cli", "qodalis", "admin", "dashboard", "plugin"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"PyJWT>=2.0.0",
|
|
28
|
+
"fastapi>=0.100.0",
|
|
29
|
+
"psutil>=5.9.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/qodalis-solutions/cli-server-python"
|
|
34
|
+
Repository = "https://github.com/qodalis-solutions/cli-server-python"
|
|
35
|
+
Issues = "https://github.com/qodalis-solutions/cli-server-python/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["qodalis_cli_admin"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["qodalis_cli_admin/**"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build]
|
|
44
|
+
artifacts = ["qodalis_cli_admin/dashboard/**"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Qodalis CLI admin plugin — admin dashboard for CLI servers."""
|
|
2
|
+
|
|
3
|
+
from .cli_admin_builder import CliAdminBuilder, CliAdminPlugin, AdminBuildDeps, BroadcastFn
|
|
4
|
+
from .auth.jwt_service import JwtService
|
|
5
|
+
from .auth.auth_middleware import create_auth_dependency
|
|
6
|
+
from .auth.auth_controller import create_auth_router
|
|
7
|
+
from .dashboard_resolver import resolve_dashboard_dir
|
|
8
|
+
from .services.admin_config import AdminConfig
|
|
9
|
+
from .services.log_ring_buffer import LogRingBuffer
|
|
10
|
+
from .services.module_registry import ModuleRegistry
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AdminBuildDeps",
|
|
14
|
+
"AdminConfig",
|
|
15
|
+
"BroadcastFn",
|
|
16
|
+
"CliAdminBuilder",
|
|
17
|
+
"CliAdminPlugin",
|
|
18
|
+
"JwtService",
|
|
19
|
+
"LogRingBuffer",
|
|
20
|
+
"ModuleRegistry",
|
|
21
|
+
"create_auth_dependency",
|
|
22
|
+
"create_auth_router",
|
|
23
|
+
"resolve_dashboard_dir",
|
|
24
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Admin authentication — JWT service, middleware, and auth controller."""
|
|
2
|
+
|
|
3
|
+
from .jwt_service import JwtService
|
|
4
|
+
from .auth_middleware import create_auth_dependency, require_auth
|
|
5
|
+
from .auth_controller import create_auth_router
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"JwtService",
|
|
9
|
+
"create_auth_dependency",
|
|
10
|
+
"create_auth_router",
|
|
11
|
+
"require_auth",
|
|
12
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Authentication controller — login and ``/me`` endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .jwt_service import JwtService
|
|
13
|
+
from ..services.admin_config import AdminConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Request / Response models
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LoginRequest(BaseModel):
|
|
22
|
+
username: str
|
|
23
|
+
password: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LoginResponse(BaseModel):
|
|
27
|
+
token: str
|
|
28
|
+
expiresIn: int
|
|
29
|
+
username: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Rate limiting helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_MAX_ATTEMPTS = 5
|
|
37
|
+
_WINDOW_SECONDS = 60
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _RateLimiter:
|
|
41
|
+
"""Simple in-memory per-IP rate limiter for failed login attempts."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._attempts: dict[str, list[float]] = defaultdict(list)
|
|
45
|
+
|
|
46
|
+
def check(self, ip: str) -> None:
|
|
47
|
+
"""Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
|
|
48
|
+
now = time.time()
|
|
49
|
+
cutoff = now - _WINDOW_SECONDS
|
|
50
|
+
# Clean old entries
|
|
51
|
+
self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
|
|
52
|
+
if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
|
|
53
|
+
raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
|
|
54
|
+
|
|
55
|
+
def record_failure(self, ip: str) -> None:
|
|
56
|
+
self._attempts[ip].append(time.time())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Router factory
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_auth_router(
|
|
65
|
+
admin_config: AdminConfig,
|
|
66
|
+
jwt_service: JwtService,
|
|
67
|
+
auth_dependency: Any = None,
|
|
68
|
+
) -> APIRouter:
|
|
69
|
+
"""Create a FastAPI router with ``POST /login`` and ``GET /me``."""
|
|
70
|
+
router = APIRouter()
|
|
71
|
+
limiter = _RateLimiter()
|
|
72
|
+
|
|
73
|
+
@router.post("/login", response_model=LoginResponse)
|
|
74
|
+
async def login(body: LoginRequest, request: Request) -> Any:
|
|
75
|
+
# Check X-Forwarded-For header for real client IP behind proxy
|
|
76
|
+
forwarded_for = request.headers.get("x-forwarded-for")
|
|
77
|
+
if forwarded_for:
|
|
78
|
+
client_ip = forwarded_for.split(",")[0].strip()
|
|
79
|
+
else:
|
|
80
|
+
client_ip = request.client.host if request.client else "127.0.0.1"
|
|
81
|
+
limiter.check(client_ip)
|
|
82
|
+
|
|
83
|
+
if not admin_config.validate_credentials(body.username, body.password):
|
|
84
|
+
limiter.record_failure(client_ip)
|
|
85
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
86
|
+
|
|
87
|
+
expires_in = JwtService.DEFAULT_EXPIRY_SECONDS
|
|
88
|
+
token = jwt_service.sign_token(
|
|
89
|
+
{"username": body.username, "authenticatedAt": int(time.time())},
|
|
90
|
+
expires_in=expires_in,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return LoginResponse(token=token, expiresIn=expires_in, username=body.username)
|
|
94
|
+
|
|
95
|
+
if auth_dependency is not None:
|
|
96
|
+
@router.get("/me")
|
|
97
|
+
async def me(payload: dict[str, Any] = Depends(auth_dependency)) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"username": payload.get("username", ""),
|
|
100
|
+
"authenticatedAt": payload.get("authenticatedAt"),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return router
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""FastAPI dependency for JWT authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
from fastapi import Request, HTTPException
|
|
9
|
+
|
|
10
|
+
from .jwt_service import JwtService
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_auth_dependency(jwt_service: JwtService):
|
|
14
|
+
"""Return a FastAPI ``Depends`` callable that validates Bearer tokens."""
|
|
15
|
+
|
|
16
|
+
async def require_auth(request: Request) -> dict[str, Any]:
|
|
17
|
+
"""Extract and verify the JWT Bearer token from the Authorization header.
|
|
18
|
+
|
|
19
|
+
Returns the decoded payload on success; raises ``HTTPException(401)``
|
|
20
|
+
otherwise.
|
|
21
|
+
"""
|
|
22
|
+
auth_header = request.headers.get("Authorization", "")
|
|
23
|
+
|
|
24
|
+
if not auth_header.startswith("Bearer "):
|
|
25
|
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
|
26
|
+
|
|
27
|
+
token = auth_header[7:]
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
payload = jwt_service.verify_token(token)
|
|
31
|
+
except jwt.InvalidTokenError:
|
|
32
|
+
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
|
33
|
+
|
|
34
|
+
return payload
|
|
35
|
+
|
|
36
|
+
return require_auth
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Convenience alias — requires a JwtService instance to be set externally.
|
|
40
|
+
# Prefer ``create_auth_dependency(jwt_service)`` for production use.
|
|
41
|
+
async def require_auth(request: Request) -> dict[str, Any]: # noqa: F811
|
|
42
|
+
"""Placeholder — use :func:`create_auth_dependency` to create a real dependency."""
|
|
43
|
+
raise HTTPException(status_code=401, detail="Auth not configured")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""JWT token generation and validation using PyJWT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JwtService:
|
|
14
|
+
"""Thin wrapper around PyJWT for signing and verifying tokens."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_EXPIRY_SECONDS = 24 * 60 * 60 # 24 hours
|
|
17
|
+
|
|
18
|
+
def __init__(self, secret: str = "") -> None:
|
|
19
|
+
self._secret = (
|
|
20
|
+
secret
|
|
21
|
+
or os.environ.get("QCLI_ADMIN_JWT_SECRET", "")
|
|
22
|
+
or secrets.token_hex(32)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def secret(self) -> str:
|
|
27
|
+
return self._secret
|
|
28
|
+
|
|
29
|
+
def sign_token(
|
|
30
|
+
self,
|
|
31
|
+
payload: dict[str, Any],
|
|
32
|
+
expires_in: int | None = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Create a signed JWT token.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
payload:
|
|
39
|
+
Claims to embed in the token.
|
|
40
|
+
expires_in:
|
|
41
|
+
Lifetime in seconds. Defaults to 24 hours.
|
|
42
|
+
"""
|
|
43
|
+
exp = int(time.time()) + (self.DEFAULT_EXPIRY_SECONDS if expires_in is None else expires_in)
|
|
44
|
+
data = {**payload, "exp": exp, "iat": int(time.time())}
|
|
45
|
+
return jwt.encode(data, self._secret, algorithm="HS256")
|
|
46
|
+
|
|
47
|
+
def verify_token(self, token: str) -> dict[str, Any]:
|
|
48
|
+
"""Decode and verify a JWT token.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
jwt.InvalidTokenError
|
|
53
|
+
If the token is invalid or expired.
|
|
54
|
+
"""
|
|
55
|
+
return jwt.decode(token, self._secret, algorithms=["HS256"])
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Fluent builder for configuring and creating the admin plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Awaitable
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter
|
|
11
|
+
from .spa_static_files import SPAStaticFiles
|
|
12
|
+
|
|
13
|
+
from .auth.jwt_service import JwtService
|
|
14
|
+
from .auth.auth_middleware import create_auth_dependency
|
|
15
|
+
from .auth.auth_controller import create_auth_router
|
|
16
|
+
from .controllers.status_controller import create_status_router
|
|
17
|
+
from .controllers.plugins_controller import create_plugins_router
|
|
18
|
+
from .controllers.config_controller import create_config_router
|
|
19
|
+
from .controllers.logs_controller import create_logs_router
|
|
20
|
+
from .controllers.ws_clients_controller import create_ws_clients_router
|
|
21
|
+
from .services.admin_config import AdminConfig
|
|
22
|
+
from .services.log_ring_buffer import LogRingBuffer
|
|
23
|
+
from .services.module_registry import ModuleRegistry
|
|
24
|
+
from .dashboard_resolver import resolve_dashboard_dir
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
BroadcastFn = Callable[[str], Awaitable[None]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AdminBuildDeps:
|
|
32
|
+
"""Dependencies required to build the admin plugin."""
|
|
33
|
+
|
|
34
|
+
registry: Any # CliCommandRegistry
|
|
35
|
+
event_socket_manager: Any # CliEventSocketManager
|
|
36
|
+
builder: Any # CliBuilder
|
|
37
|
+
broadcast_fn: BroadcastFn | None = None
|
|
38
|
+
enabled_features: list[str] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CliAdminPlugin:
|
|
43
|
+
"""Result of building the admin plugin."""
|
|
44
|
+
|
|
45
|
+
router: APIRouter
|
|
46
|
+
auth_dependency: Any
|
|
47
|
+
log_buffer: LogRingBuffer
|
|
48
|
+
dashboard_app: Any # ASGI app (SPA fallback) or None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CliAdminBuilder:
|
|
52
|
+
"""Fluent builder for the admin dashboard plugin."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._username: str = ""
|
|
56
|
+
self._password: str = ""
|
|
57
|
+
self._jwt_secret: str = ""
|
|
58
|
+
self._dashboard_path: str | None = None
|
|
59
|
+
|
|
60
|
+
def set_credentials(self, username: str, password: str) -> CliAdminBuilder:
|
|
61
|
+
"""Set admin login credentials (overrides env vars)."""
|
|
62
|
+
self._username = username
|
|
63
|
+
self._password = password
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def set_jwt_secret(self, secret: str) -> CliAdminBuilder:
|
|
67
|
+
"""Set the JWT signing secret (overrides env var)."""
|
|
68
|
+
self._jwt_secret = secret
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def set_dashboard_path(self, path: str) -> CliAdminBuilder:
|
|
72
|
+
"""Set an explicit path to the dashboard dist directory."""
|
|
73
|
+
self._dashboard_path = path
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def build(self, deps: AdminBuildDeps) -> CliAdminPlugin:
|
|
77
|
+
"""Build the plugin, returning the router, auth dependency, and log buffer."""
|
|
78
|
+
start_time = time.time()
|
|
79
|
+
|
|
80
|
+
# Services
|
|
81
|
+
config = AdminConfig(
|
|
82
|
+
username=self._username,
|
|
83
|
+
password=self._password,
|
|
84
|
+
jwt_secret=self._jwt_secret,
|
|
85
|
+
)
|
|
86
|
+
jwt_service = JwtService(secret=config.jwt_secret)
|
|
87
|
+
log_buffer = LogRingBuffer(broadcast_fn=deps.broadcast_fn)
|
|
88
|
+
log_buffer.install_handler()
|
|
89
|
+
module_registry = ModuleRegistry(deps.builder)
|
|
90
|
+
auth_dep = create_auth_dependency(jwt_service)
|
|
91
|
+
|
|
92
|
+
# Build the top-level router
|
|
93
|
+
router = APIRouter()
|
|
94
|
+
|
|
95
|
+
# Auth routes (login is public, /me is protected)
|
|
96
|
+
auth_router = create_auth_router(config, jwt_service, auth_dependency=auth_dep)
|
|
97
|
+
router.include_router(auth_router, prefix="/auth")
|
|
98
|
+
|
|
99
|
+
# Detect enabled features
|
|
100
|
+
enabled_features: list[str] = list(deps.enabled_features or [])
|
|
101
|
+
|
|
102
|
+
if "filesystem" not in enabled_features and (
|
|
103
|
+
getattr(deps.builder, "file_storage_provider", None) is not None
|
|
104
|
+
or getattr(deps.builder, "filesystem_options", None) is not None
|
|
105
|
+
):
|
|
106
|
+
enabled_features.append("filesystem")
|
|
107
|
+
|
|
108
|
+
if "jobs" not in enabled_features:
|
|
109
|
+
for mod in getattr(deps.builder, "modules", []):
|
|
110
|
+
mod_name = type(mod).__name__.lower()
|
|
111
|
+
if "job" in mod_name:
|
|
112
|
+
enabled_features.append("jobs")
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
# Protected routes
|
|
116
|
+
status_router = create_status_router(
|
|
117
|
+
start_time, deps.event_socket_manager, auth_dep, enabled_features,
|
|
118
|
+
registry=deps.registry,
|
|
119
|
+
)
|
|
120
|
+
router.include_router(status_router)
|
|
121
|
+
|
|
122
|
+
plugins_router = create_plugins_router(module_registry, auth_dep)
|
|
123
|
+
router.include_router(plugins_router)
|
|
124
|
+
|
|
125
|
+
config_router = create_config_router(config, auth_dep)
|
|
126
|
+
router.include_router(config_router)
|
|
127
|
+
|
|
128
|
+
logs_router = create_logs_router(log_buffer, auth_dep)
|
|
129
|
+
router.include_router(logs_router)
|
|
130
|
+
|
|
131
|
+
ws_clients_router = create_ws_clients_router(deps.event_socket_manager, auth_dep)
|
|
132
|
+
router.include_router(ws_clients_router)
|
|
133
|
+
|
|
134
|
+
# Resolve dashboard SPA
|
|
135
|
+
dashboard_dir = resolve_dashboard_dir(self._dashboard_path)
|
|
136
|
+
dashboard_app = None
|
|
137
|
+
|
|
138
|
+
if dashboard_dir:
|
|
139
|
+
dashboard_app = SPAStaticFiles(directory=dashboard_dir, html=True)
|
|
140
|
+
|
|
141
|
+
return CliAdminPlugin(
|
|
142
|
+
router=router,
|
|
143
|
+
auth_dependency=auth_dep,
|
|
144
|
+
log_buffer=log_buffer,
|
|
145
|
+
dashboard_app=dashboard_app,
|
|
146
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Admin dashboard controllers."""
|
|
2
|
+
|
|
3
|
+
from .status_controller import create_status_router
|
|
4
|
+
from .plugins_controller import create_plugins_router
|
|
5
|
+
from .config_controller import create_config_router
|
|
6
|
+
from .logs_controller import create_logs_router
|
|
7
|
+
from .ws_clients_controller import create_ws_clients_router
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"create_config_router",
|
|
11
|
+
"create_logs_router",
|
|
12
|
+
"create_plugins_router",
|
|
13
|
+
"create_status_router",
|
|
14
|
+
"create_ws_clients_router",
|
|
15
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Configuration controller — read and update admin settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ..services.admin_config import AdminConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UpdateConfigRequest(BaseModel):
|
|
14
|
+
settings: dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_config_router(
|
|
18
|
+
admin_config: AdminConfig,
|
|
19
|
+
auth_dependency: Any,
|
|
20
|
+
) -> APIRouter:
|
|
21
|
+
"""Create a router with ``GET /config`` and ``PUT /config``."""
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
@router.get("/config")
|
|
25
|
+
async def get_config(
|
|
26
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
return {"sections": admin_config.get_config_sections()}
|
|
29
|
+
|
|
30
|
+
@router.put("/config")
|
|
31
|
+
async def update_config(
|
|
32
|
+
body: UpdateConfigRequest,
|
|
33
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
admin_config.update_settings(body.settings)
|
|
36
|
+
return {"message": "Configuration updated", "sections": admin_config.get_config_sections()}
|
|
37
|
+
|
|
38
|
+
return router
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
|
|
9
|
+
from ..services.log_ring_buffer import LogRingBuffer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_logs_router(
|
|
13
|
+
log_buffer: LogRingBuffer,
|
|
14
|
+
auth_dependency: Any,
|
|
15
|
+
) -> APIRouter:
|
|
16
|
+
"""Create a router with ``GET /logs``."""
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
@router.get("/logs")
|
|
20
|
+
async def get_logs(
|
|
21
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
22
|
+
level: str | None = Query(None),
|
|
23
|
+
search: str | None = Query(None),
|
|
24
|
+
limit: int = Query(100, le=1000),
|
|
25
|
+
offset: int = Query(0, ge=0),
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
items, total = log_buffer.query(
|
|
28
|
+
level=level,
|
|
29
|
+
search=search,
|
|
30
|
+
limit=limit,
|
|
31
|
+
offset=offset,
|
|
32
|
+
)
|
|
33
|
+
return {
|
|
34
|
+
"entries": items,
|
|
35
|
+
"total": total,
|
|
36
|
+
"limit": limit,
|
|
37
|
+
"offset": offset,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return router
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Plugins controller — list and toggle registered modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from ..services.module_registry import ModuleRegistry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_plugins_router(
|
|
14
|
+
module_registry: ModuleRegistry,
|
|
15
|
+
auth_dependency: Any,
|
|
16
|
+
) -> APIRouter:
|
|
17
|
+
"""Create a router with ``GET /plugins`` and ``POST /plugins/{id}/toggle``."""
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
@router.get("/plugins")
|
|
21
|
+
async def list_plugins(
|
|
22
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
23
|
+
) -> list[dict[str, Any]]:
|
|
24
|
+
return module_registry.list()
|
|
25
|
+
|
|
26
|
+
@router.post("/plugins/{plugin_id}/toggle")
|
|
27
|
+
async def toggle_plugin(
|
|
28
|
+
plugin_id: str,
|
|
29
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
30
|
+
) -> Any:
|
|
31
|
+
try:
|
|
32
|
+
return module_registry.toggle(plugin_id)
|
|
33
|
+
except (KeyError, ValueError):
|
|
34
|
+
return JSONResponse(
|
|
35
|
+
status_code=404,
|
|
36
|
+
content={"error": "Plugin not found", "code": "PLUGIN_NOT_FOUND"},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return router
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Status controller — server health and runtime information."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import psutil
|
|
13
|
+
from fastapi import APIRouter, Depends
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_status_router(
|
|
17
|
+
start_time: float,
|
|
18
|
+
event_socket_manager: Any,
|
|
19
|
+
auth_dependency: Any,
|
|
20
|
+
enabled_features: list[str] | None = None,
|
|
21
|
+
registry: Any = None,
|
|
22
|
+
) -> APIRouter:
|
|
23
|
+
"""Create a router that exposes ``GET /status``."""
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.get("/status")
|
|
27
|
+
async def get_status(
|
|
28
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
uptime_seconds = time.time() - start_time
|
|
31
|
+
|
|
32
|
+
proc = psutil.Process(os.getpid())
|
|
33
|
+
mem_info = proc.memory_info()
|
|
34
|
+
memory_usage_mb = mem_info.rss / (1024 * 1024)
|
|
35
|
+
|
|
36
|
+
started_at = datetime.fromtimestamp(
|
|
37
|
+
start_time, tz=timezone.utc
|
|
38
|
+
).isoformat()
|
|
39
|
+
|
|
40
|
+
registered_commands = len(registry.processors) if registry is not None else 0
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"uptimeSeconds": uptime_seconds,
|
|
44
|
+
"memoryUsageMb": round(memory_usage_mb, 2),
|
|
45
|
+
"startedAt": started_at,
|
|
46
|
+
"platform": "python",
|
|
47
|
+
"platformVersion": platform.python_version(),
|
|
48
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
49
|
+
"activeWsConnections": len(event_socket_manager.get_clients()),
|
|
50
|
+
"activeShellSessions": 0,
|
|
51
|
+
"registeredCommands": registered_commands,
|
|
52
|
+
"registeredJobs": 0,
|
|
53
|
+
"enabledFeatures": enabled_features or [],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return router
|
qodalis_cli_server_plugin_admin-1.0.0b1/qodalis_cli_admin/controllers/ws_clients_controller.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""WebSocket clients controller — list connected WS clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_ws_clients_router(
|
|
11
|
+
event_socket_manager: Any,
|
|
12
|
+
auth_dependency: Any,
|
|
13
|
+
) -> APIRouter:
|
|
14
|
+
"""Create a router with ``GET /ws/clients``."""
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
@router.get("/ws/clients")
|
|
18
|
+
async def get_ws_clients(
|
|
19
|
+
_user: dict[str, Any] = Depends(auth_dependency),
|
|
20
|
+
) -> list[dict[str, Any]]:
|
|
21
|
+
return event_socket_manager.get_clients()
|
|
22
|
+
|
|
23
|
+
return router
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Resolves the dashboard dist directory at runtime."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
|
|
8
|
+
"""
|
|
9
|
+
Resolve the dashboard dist directory. Looks for:
|
|
10
|
+
1. Explicitly configured path
|
|
11
|
+
2. Bundled dashboard directory (embedded in this package)
|
|
12
|
+
3. node_modules/@qodalis/cli-server-dashboard/dist (npm package)
|
|
13
|
+
4. Relative development paths (sibling repo)
|
|
14
|
+
"""
|
|
15
|
+
# 1. Explicit override
|
|
16
|
+
if explicit_path and os.path.isdir(explicit_path):
|
|
17
|
+
return os.path.abspath(explicit_path)
|
|
18
|
+
|
|
19
|
+
# 2. Bundled dashboard directory (embedded in this package)
|
|
20
|
+
bundled = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard')
|
|
21
|
+
if os.path.isdir(bundled):
|
|
22
|
+
return bundled
|
|
23
|
+
|
|
24
|
+
# 3. npm package — search upwards from cwd for node_modules
|
|
25
|
+
current = os.getcwd()
|
|
26
|
+
while current != os.path.dirname(current):
|
|
27
|
+
candidate = os.path.join(
|
|
28
|
+
current, "node_modules", "@qodalis", "cli-server-dashboard", "dist"
|
|
29
|
+
)
|
|
30
|
+
if os.path.isdir(candidate):
|
|
31
|
+
return os.path.abspath(candidate)
|
|
32
|
+
current = os.path.dirname(current)
|
|
33
|
+
|
|
34
|
+
# 4. Relative development paths
|
|
35
|
+
this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
36
|
+
dev_paths = [
|
|
37
|
+
os.path.join(this_dir, "..", "..", "..", "..", "cli-server-dashboard", "dist"),
|
|
38
|
+
os.path.join(os.getcwd(), "..", "cli-server-dashboard", "dist"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
for path in dev_paths:
|
|
42
|
+
if os.path.isdir(path):
|
|
43
|
+
return os.path.abspath(path)
|
|
44
|
+
|
|
45
|
+
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Admin configuration — reads credentials from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("qcli-admin")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AdminConfig:
|
|
16
|
+
"""Holds admin dashboard configuration.
|
|
17
|
+
|
|
18
|
+
Credentials default to ``QCLI_ADMIN_USERNAME`` / ``QCLI_ADMIN_PASSWORD``
|
|
19
|
+
environment variables, falling back to ``admin`` / ``admin``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
username: str = ""
|
|
23
|
+
password: str = ""
|
|
24
|
+
jwt_secret: str = ""
|
|
25
|
+
|
|
26
|
+
# Mutable runtime settings
|
|
27
|
+
_mutable_settings: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
FORBIDDEN_KEYS: frozenset[str] = frozenset(
|
|
30
|
+
{"username", "password", "jwt_secret", "jwtSecret"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not self.username:
|
|
35
|
+
self.username = os.environ.get("QCLI_ADMIN_USERNAME", "admin")
|
|
36
|
+
if not self.password:
|
|
37
|
+
self.password = os.environ.get("QCLI_ADMIN_PASSWORD", "admin")
|
|
38
|
+
if not self.jwt_secret:
|
|
39
|
+
self.jwt_secret = os.environ.get("QCLI_ADMIN_JWT_SECRET", "")
|
|
40
|
+
|
|
41
|
+
if self.username == "admin" and self.password == "admin":
|
|
42
|
+
logger.warning(
|
|
43
|
+
"Using default admin credentials. Set QCLI_ADMIN_USERNAME"
|
|
44
|
+
" and QCLI_ADMIN_PASSWORD environment variables."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def validate_credentials(self, username: str, password: str) -> bool:
|
|
48
|
+
"""Return *True* if the supplied credentials match."""
|
|
49
|
+
username_ok = hmac.compare_digest(username, self.username)
|
|
50
|
+
password_ok = hmac.compare_digest(password, self.password)
|
|
51
|
+
return username_ok and password_ok
|
|
52
|
+
|
|
53
|
+
def get_config_sections(self) -> list[dict[str, Any]]:
|
|
54
|
+
"""Return structured configuration sections for the admin API."""
|
|
55
|
+
import platform as _platform
|
|
56
|
+
import sys as _sys
|
|
57
|
+
|
|
58
|
+
custom_entries = [
|
|
59
|
+
{
|
|
60
|
+
"key": k,
|
|
61
|
+
"value": v,
|
|
62
|
+
"type": _infer_type(v),
|
|
63
|
+
"description": "",
|
|
64
|
+
"mutable": True,
|
|
65
|
+
}
|
|
66
|
+
for k, v in self._mutable_settings.items()
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
"name": "server",
|
|
72
|
+
"mutable": False,
|
|
73
|
+
"settings": [
|
|
74
|
+
{"key": "platform", "value": "python", "type": "string", "description": "Server platform", "mutable": False},
|
|
75
|
+
{"key": "platformVersion", "value": _sys.version.split()[0], "type": "string", "description": "Python version", "mutable": False},
|
|
76
|
+
{"key": "os", "value": _platform.platform(), "type": "string", "description": "Operating system", "mutable": False},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"name": "auth",
|
|
81
|
+
"mutable": False,
|
|
82
|
+
"settings": [
|
|
83
|
+
{"key": "username", "value": self.username, "type": "string", "description": "Admin username", "mutable": False},
|
|
84
|
+
{"key": "jwtSecretConfigured", "value": bool(self.jwt_secret), "type": "boolean", "description": "Whether JWT secret is explicitly set", "mutable": False},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "custom",
|
|
89
|
+
"mutable": True,
|
|
90
|
+
"settings": custom_entries,
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
def update_settings(self, settings: dict[str, Any]) -> None:
|
|
95
|
+
"""Update mutable runtime settings.
|
|
96
|
+
|
|
97
|
+
Keys that overlap with sensitive configuration are silently rejected.
|
|
98
|
+
"""
|
|
99
|
+
sanitized = {k: v for k, v in settings.items() if k not in self.FORBIDDEN_KEYS}
|
|
100
|
+
self._mutable_settings.update(sanitized)
|
|
101
|
+
|
|
102
|
+
def get_settings(self) -> dict[str, Any]:
|
|
103
|
+
"""Return a copy of the current mutable runtime settings."""
|
|
104
|
+
return dict(self._mutable_settings)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _infer_type(val: Any) -> str:
|
|
108
|
+
if isinstance(val, bool):
|
|
109
|
+
return "boolean"
|
|
110
|
+
if isinstance(val, (int, float)):
|
|
111
|
+
return "number"
|
|
112
|
+
if isinstance(val, list):
|
|
113
|
+
return "string[]"
|
|
114
|
+
return "string"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Ring buffer for log entries with optional broadcast support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections import deque
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Callable, Awaitable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LogEntry:
|
|
14
|
+
"""A single log entry."""
|
|
15
|
+
|
|
16
|
+
timestamp: str
|
|
17
|
+
level: str
|
|
18
|
+
message: str
|
|
19
|
+
logger_name: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
BroadcastFn = Callable[[str], Awaitable[None]]
|
|
23
|
+
|
|
24
|
+
_LEVEL_MAP = {
|
|
25
|
+
"WARNING": "WARN",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_level(level: str) -> str:
|
|
30
|
+
"""Normalize Python log level names to SPA-expected short forms."""
|
|
31
|
+
return _LEVEL_MAP.get(level.upper(), level.upper())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogRingBuffer:
|
|
35
|
+
"""Fixed-size ring buffer that stores log entries.
|
|
36
|
+
|
|
37
|
+
Optionally captures records from Python's :mod:`logging` module via
|
|
38
|
+
:meth:`install_handler`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
max_size: int = 1000,
|
|
44
|
+
broadcast_fn: BroadcastFn | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._buffer: deque[LogEntry] = deque(maxlen=max_size)
|
|
47
|
+
self._broadcast_fn = broadcast_fn
|
|
48
|
+
self._handler: _RingBufferHandler | None = None
|
|
49
|
+
|
|
50
|
+
# -- public API -----------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def add(self, entry: LogEntry) -> None:
|
|
53
|
+
"""Append a log entry to the buffer."""
|
|
54
|
+
entry.level = (entry.level or "INFO").upper()
|
|
55
|
+
self._buffer.append(entry)
|
|
56
|
+
|
|
57
|
+
def query(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
level: str | None = None,
|
|
61
|
+
search: str | None = None,
|
|
62
|
+
limit: int = 100,
|
|
63
|
+
offset: int = 0,
|
|
64
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
65
|
+
"""Return matching entries and the total count (before pagination)."""
|
|
66
|
+
entries = list(self._buffer)
|
|
67
|
+
|
|
68
|
+
if level:
|
|
69
|
+
level_upper = level.upper()
|
|
70
|
+
# Support filtering by SPA-form "WARN" matching Python's "WARNING"
|
|
71
|
+
match_levels = {level_upper}
|
|
72
|
+
if level_upper == "WARN":
|
|
73
|
+
match_levels.add("WARNING")
|
|
74
|
+
entries = [e for e in entries if e.level in match_levels]
|
|
75
|
+
|
|
76
|
+
if search:
|
|
77
|
+
search_lower = search.lower()
|
|
78
|
+
entries = [e for e in entries if search_lower in e.message.lower()]
|
|
79
|
+
|
|
80
|
+
total = len(entries)
|
|
81
|
+
page = entries[offset: offset + limit]
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
[
|
|
85
|
+
{
|
|
86
|
+
"timestamp": e.timestamp,
|
|
87
|
+
"level": _normalize_level(e.level),
|
|
88
|
+
"message": e.message,
|
|
89
|
+
"source": e.logger_name,
|
|
90
|
+
}
|
|
91
|
+
for e in page
|
|
92
|
+
],
|
|
93
|
+
total,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# -- logging integration --------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def install_handler(self, logger_name: str = "") -> None:
|
|
99
|
+
"""Install a :class:`logging.Handler` that feeds into this buffer."""
|
|
100
|
+
if self._handler is not None:
|
|
101
|
+
return
|
|
102
|
+
self._handler = _RingBufferHandler(self)
|
|
103
|
+
logging.getLogger(logger_name).addHandler(self._handler)
|
|
104
|
+
|
|
105
|
+
def uninstall_handler(self, logger_name: str = "") -> None:
|
|
106
|
+
"""Remove the previously installed handler."""
|
|
107
|
+
if self._handler is None:
|
|
108
|
+
return
|
|
109
|
+
logging.getLogger(logger_name).removeHandler(self._handler)
|
|
110
|
+
self._handler = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _RingBufferHandler(logging.Handler):
|
|
114
|
+
"""Logging handler that pushes records into a :class:`LogRingBuffer`."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, ring: LogRingBuffer) -> None:
|
|
117
|
+
super().__init__()
|
|
118
|
+
self._ring = ring
|
|
119
|
+
|
|
120
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
121
|
+
entry = LogEntry(
|
|
122
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
123
|
+
level=record.levelname,
|
|
124
|
+
message=self.format(record),
|
|
125
|
+
logger_name=record.name,
|
|
126
|
+
)
|
|
127
|
+
self._ring.add(entry)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Module registry — reads modules from CliBuilder and tracks enabled state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from qodalis_cli.extensions.cli_builder import CliBuilder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModuleRegistry:
|
|
12
|
+
"""Provides a read/toggle view over modules registered in :class:`CliBuilder`."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, builder: CliBuilder) -> None:
|
|
15
|
+
self._builder = builder
|
|
16
|
+
self._disabled: set[str] = set()
|
|
17
|
+
|
|
18
|
+
def list(self) -> list[dict[str, Any]]:
|
|
19
|
+
"""Return a list of all modules with their enabled/disabled state."""
|
|
20
|
+
result: list[dict[str, Any]] = []
|
|
21
|
+
for idx, module in enumerate(self._builder.modules):
|
|
22
|
+
module_id = str(idx)
|
|
23
|
+
name = getattr(module, "name", type(module).__name__)
|
|
24
|
+
result.append(
|
|
25
|
+
{
|
|
26
|
+
"id": module_id,
|
|
27
|
+
"name": name,
|
|
28
|
+
"processorCount": len(module.processors),
|
|
29
|
+
"processors": [p.command for p in module.processors],
|
|
30
|
+
"enabled": module_id not in self._disabled,
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
def toggle(self, module_id: str) -> dict[str, Any]:
|
|
36
|
+
"""Toggle a module's enabled state and return the updated record."""
|
|
37
|
+
modules = self._builder.modules
|
|
38
|
+
idx = int(module_id)
|
|
39
|
+
if idx < 0 or idx >= len(modules):
|
|
40
|
+
raise KeyError(f"Module not found: {module_id}")
|
|
41
|
+
|
|
42
|
+
if module_id in self._disabled:
|
|
43
|
+
self._disabled.discard(module_id)
|
|
44
|
+
enabled = True
|
|
45
|
+
else:
|
|
46
|
+
self._disabled.add(module_id)
|
|
47
|
+
enabled = False
|
|
48
|
+
|
|
49
|
+
module = modules[idx]
|
|
50
|
+
result: dict[str, Any] = {
|
|
51
|
+
"id": module_id,
|
|
52
|
+
"name": getattr(module, "name", type(module).__name__),
|
|
53
|
+
"processorCount": len(module.processors),
|
|
54
|
+
"processors": [p.command for p in module.processors],
|
|
55
|
+
"enabled": enabled,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if not enabled:
|
|
59
|
+
result["warning"] = (
|
|
60
|
+
"Module state tracked but command unregistration is not yet"
|
|
61
|
+
" supported. Processors remain active."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return result
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""SPA-aware static file serving.
|
|
2
|
+
|
|
3
|
+
Starlette's ``StaticFiles(html=True)`` only maps ``/`` to ``index.html``.
|
|
4
|
+
For a single-page application we need *all* non-file routes to fall back to
|
|
5
|
+
``index.html`` so that the client-side router can handle them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
13
|
+
from starlette.responses import FileResponse, Response
|
|
14
|
+
from starlette.staticfiles import StaticFiles
|
|
15
|
+
from starlette.types import Scope
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SPAStaticFiles(StaticFiles):
|
|
19
|
+
"""StaticFiles subclass that falls back to ``index.html`` for missing paths.
|
|
20
|
+
|
|
21
|
+
This enables client-side routing: any request that doesn't match a real
|
|
22
|
+
file (e.g. ``/login``, ``/dashboard/settings``) is served the SPA shell
|
|
23
|
+
so the JS router can take over.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
27
|
+
try:
|
|
28
|
+
return await super().get_response(path, scope)
|
|
29
|
+
except StarletteHTTPException as exc:
|
|
30
|
+
if exc.status_code != 404:
|
|
31
|
+
raise
|
|
32
|
+
# File not found — serve index.html for SPA client-side routing
|
|
33
|
+
index = os.path.join(self.directory, "index.html") # type: ignore[arg-type]
|
|
34
|
+
if os.path.isfile(index):
|
|
35
|
+
return FileResponse(index, media_type="text/html")
|
|
36
|
+
raise
|