qodalis-cli-server-plugin-admin 1.0.0b1__py3-none-any.whl

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.
@@ -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
@@ -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,11 @@
1
+ """Admin plugin services."""
2
+
3
+ from .admin_config import AdminConfig
4
+ from .log_ring_buffer import LogRingBuffer
5
+ from .module_registry import ModuleRegistry
6
+
7
+ __all__ = [
8
+ "AdminConfig",
9
+ "LogRingBuffer",
10
+ "ModuleRegistry",
11
+ ]
@@ -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
@@ -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,21 @@
1
+ qodalis_cli_admin/__init__.py,sha256=ps-YVWIDYENFb1u_aUootcPQ7BBKiKF3mPevwpcWWvk,784
2
+ qodalis_cli_admin/cli_admin_builder.py,sha256=qMbC0qk-kztBg7N2SdIvb6TQfT88OHz307iTZtKMS5M,5103
3
+ qodalis_cli_admin/dashboard_resolver.py,sha256=YtMCzXW-9CDLZVJkay30sokXrQ7PYXcjXDouqyKrtGY,1571
4
+ qodalis_cli_admin/spa_static_files.py,sha256=2rqkNv9k2-u6ZWCjTLW4fJP1j9ZdZ_vnIr3LF423Iag,1364
5
+ qodalis_cli_admin/auth/__init__.py,sha256=vPNd5f8E391iUO3D8WGxpDohkBGbQlkYIl2kJI_I8OI,337
6
+ qodalis_cli_admin/auth/auth_controller.py,sha256=od7qlSEkARoFKAU7dFV73PWqtN5Zlv2mVu4v-0RqjZY,3406
7
+ qodalis_cli_admin/auth/auth_middleware.py,sha256=-Uhv7QumIp6I5-a7-Jefu0BLPwgnbiODr3DNUUz0cCw,1459
8
+ qodalis_cli_admin/auth/jwt_service.py,sha256=Gs1ods8hBRhiNOPcK1zu9WbM23RxS3pTQe8fKl2aqvg,1448
9
+ qodalis_cli_admin/controllers/__init__.py,sha256=R3Qw-xMUt4v05HxTtZ8_-vV65QGtQFKb6UduwAuqXJY,460
10
+ qodalis_cli_admin/controllers/config_controller.py,sha256=UWRIHFjZRbqP2atz5ApjhMH5J9vF0ABDU524GO651m4,1057
11
+ qodalis_cli_admin/controllers/logs_controller.py,sha256=AiTR1Z1zlqmSK7wtZcXFU-tS5HUuX7Dv6uqFR4zbTqE,997
12
+ qodalis_cli_admin/controllers/plugins_controller.py,sha256=_uhwpfZY-LUOVBajWsuFo3g5qR0Za5M5oavlCAS0o-E,1124
13
+ qodalis_cli_admin/controllers/status_controller.py,sha256=E8CoLjx7Ay_mbMVhsTEDicwqBRvNzbzbMqOQPlNcvtw,1664
14
+ qodalis_cli_admin/controllers/ws_clients_controller.py,sha256=AQYkw3Xg9ei5EMkLBeLYWVL2LsKTIj4Og5Ila7w_WlA,568
15
+ qodalis_cli_admin/services/__init__.py,sha256=RtZ1jNIvJU-I4qbsAuY9rnjMr_1cdFQd0wyU18BnTUE,232
16
+ qodalis_cli_admin/services/admin_config.py,sha256=22gRx7MicoX64niJVYFBAPwpsKic3HNMKxyyZ_thp6I,4084
17
+ qodalis_cli_admin/services/log_ring_buffer.py,sha256=i6UMHCDWXjw2ZtzD_6Z7a1IucfF1I5EYGmSHbC3cnuQ,3802
18
+ qodalis_cli_admin/services/module_registry.py,sha256=c-RbyCSlgJpyE9-edHwU_YP-5-8wUyrwTxMypXNVpx4,2229
19
+ qodalis_cli_server_plugin_admin-1.0.0b1.dist-info/METADATA,sha256=TmwT-pUlnGLgGaEZppKkq4bRyj1rpJXxYkzy_wzibfo,1002
20
+ qodalis_cli_server_plugin_admin-1.0.0b1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
21
+ qodalis_cli_server_plugin_admin-1.0.0b1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any