qodalis-cli-server-plugin-admin 1.0.0b1__tar.gz → 1.0.0b5__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 → qodalis_cli_server_plugin_admin-1.0.0b5}/PKG-INFO +1 -1
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/pyproject.toml +1 -1
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/auth_controller.py +5 -16
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/auth_middleware.py +1 -3
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/jwt_service.py +5 -10
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/cli_admin_builder.py +6 -6
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/config_controller.py +2 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/dashboard_resolver.py +8 -10
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/admin_config.py +1 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/log_ring_buffer.py +1 -4
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/module_registry.py +38 -9
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/spa_static_files.py +1 -1
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/.gitignore +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/auth/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/__init__.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/logs_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/plugins_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/status_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/controllers/ws_clients_controller.py +0 -0
- {qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/qodalis_cli_admin/services/__init__.py +0 -0
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qodalis-cli-server-plugin-admin
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0b5
|
|
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
|
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/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.0b5"
|
|
8
8
|
description = "Admin dashboard plugin for Qodalis CLI servers"
|
|
9
9
|
readme = { text = "", content-type = "text/plain" }
|
|
10
10
|
license = "MIT"
|
|
@@ -13,26 +13,21 @@ from .jwt_service import JwtService
|
|
|
13
13
|
from ..services.admin_config import AdminConfig
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
# ---------------------------------------------------------------------------
|
|
17
|
-
# Request / Response models
|
|
18
|
-
# ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
|
|
21
16
|
class LoginRequest(BaseModel):
|
|
17
|
+
"""Request body for the login endpoint."""
|
|
18
|
+
|
|
22
19
|
username: str
|
|
23
20
|
password: str
|
|
24
21
|
|
|
25
22
|
|
|
26
23
|
class LoginResponse(BaseModel):
|
|
24
|
+
"""Response body returned on successful login."""
|
|
25
|
+
|
|
27
26
|
token: str
|
|
28
27
|
expiresIn: int
|
|
29
28
|
username: str
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
# ---------------------------------------------------------------------------
|
|
33
|
-
# Rate limiting helpers
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
31
|
_MAX_ATTEMPTS = 5
|
|
37
32
|
_WINDOW_SECONDS = 60
|
|
38
33
|
|
|
@@ -47,20 +42,15 @@ class _RateLimiter:
|
|
|
47
42
|
"""Raise ``HTTPException(429)`` if the IP has exceeded the limit."""
|
|
48
43
|
now = time.time()
|
|
49
44
|
cutoff = now - _WINDOW_SECONDS
|
|
50
|
-
# Clean old entries
|
|
51
45
|
self._attempts[ip] = [t for t in self._attempts[ip] if t > cutoff]
|
|
52
46
|
if len(self._attempts[ip]) >= _MAX_ATTEMPTS:
|
|
53
47
|
raise HTTPException(status_code=429, detail="Too many failed login attempts. Try again later.")
|
|
54
48
|
|
|
55
49
|
def record_failure(self, ip: str) -> None:
|
|
50
|
+
"""Record a failed login attempt for the given IP."""
|
|
56
51
|
self._attempts[ip].append(time.time())
|
|
57
52
|
|
|
58
53
|
|
|
59
|
-
# ---------------------------------------------------------------------------
|
|
60
|
-
# Router factory
|
|
61
|
-
# ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
|
|
64
54
|
def create_auth_router(
|
|
65
55
|
admin_config: AdminConfig,
|
|
66
56
|
jwt_service: JwtService,
|
|
@@ -72,7 +62,6 @@ def create_auth_router(
|
|
|
72
62
|
|
|
73
63
|
@router.post("/login", response_model=LoginResponse)
|
|
74
64
|
async def login(body: LoginRequest, request: Request) -> Any:
|
|
75
|
-
# Check X-Forwarded-For header for real client IP behind proxy
|
|
76
65
|
forwarded_for = request.headers.get("x-forwarded-for")
|
|
77
66
|
if forwarded_for:
|
|
78
67
|
client_ip = forwarded_for.split(",")[0].strip()
|
|
@@ -36,8 +36,6 @@ def create_auth_dependency(jwt_service: JwtService):
|
|
|
36
36
|
return require_auth
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
# Convenience alias — requires a JwtService instance to be set externally.
|
|
40
|
-
# Prefer ``create_auth_dependency(jwt_service)`` for production use.
|
|
41
39
|
async def require_auth(request: Request) -> dict[str, Any]: # noqa: F811
|
|
42
|
-
"""
|
|
40
|
+
"""No-op fallback; use ``create_auth_dependency`` to create a real dependency."""
|
|
43
41
|
raise HTTPException(status_code=401, detail="Auth not configured")
|
|
@@ -33,12 +33,9 @@ class JwtService:
|
|
|
33
33
|
) -> str:
|
|
34
34
|
"""Create a signed JWT token.
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Claims to embed in the token.
|
|
40
|
-
expires_in:
|
|
41
|
-
Lifetime in seconds. Defaults to 24 hours.
|
|
36
|
+
Args:
|
|
37
|
+
payload: Claims to embed in the token.
|
|
38
|
+
expires_in: Lifetime in seconds. Defaults to 24 hours.
|
|
42
39
|
"""
|
|
43
40
|
exp = int(time.time()) + (self.DEFAULT_EXPIRY_SECONDS if expires_in is None else expires_in)
|
|
44
41
|
data = {**payload, "exp": exp, "iat": int(time.time())}
|
|
@@ -47,9 +44,7 @@ class JwtService:
|
|
|
47
44
|
def verify_token(self, token: str) -> dict[str, Any]:
|
|
48
45
|
"""Decode and verify a JWT token.
|
|
49
46
|
|
|
50
|
-
Raises
|
|
51
|
-
|
|
52
|
-
jwt.InvalidTokenError
|
|
53
|
-
If the token is invalid or expired.
|
|
47
|
+
Raises:
|
|
48
|
+
jwt.InvalidTokenError: If the token is invalid or expired.
|
|
54
49
|
"""
|
|
55
50
|
return jwt.decode(token, self._secret, algorithms=["HS256"])
|
|
@@ -42,10 +42,13 @@ class AdminBuildDeps:
|
|
|
42
42
|
class CliAdminPlugin:
|
|
43
43
|
"""Result of building the admin plugin."""
|
|
44
44
|
|
|
45
|
+
prefix: str
|
|
46
|
+
dashboard_prefix: str
|
|
45
47
|
router: APIRouter
|
|
46
48
|
auth_dependency: Any
|
|
47
49
|
log_buffer: LogRingBuffer
|
|
48
50
|
dashboard_app: Any # ASGI app (SPA fallback) or None
|
|
51
|
+
module_registry: ModuleRegistry | None = None
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
class CliAdminBuilder:
|
|
@@ -77,7 +80,6 @@ class CliAdminBuilder:
|
|
|
77
80
|
"""Build the plugin, returning the router, auth dependency, and log buffer."""
|
|
78
81
|
start_time = time.time()
|
|
79
82
|
|
|
80
|
-
# Services
|
|
81
83
|
config = AdminConfig(
|
|
82
84
|
username=self._username,
|
|
83
85
|
password=self._password,
|
|
@@ -89,14 +91,11 @@ class CliAdminBuilder:
|
|
|
89
91
|
module_registry = ModuleRegistry(deps.builder)
|
|
90
92
|
auth_dep = create_auth_dependency(jwt_service)
|
|
91
93
|
|
|
92
|
-
# Build the top-level router
|
|
93
94
|
router = APIRouter()
|
|
94
95
|
|
|
95
|
-
# Auth routes (login is public, /me is protected)
|
|
96
96
|
auth_router = create_auth_router(config, jwt_service, auth_dependency=auth_dep)
|
|
97
97
|
router.include_router(auth_router, prefix="/auth")
|
|
98
98
|
|
|
99
|
-
# Detect enabled features
|
|
100
99
|
enabled_features: list[str] = list(deps.enabled_features or [])
|
|
101
100
|
|
|
102
101
|
if "filesystem" not in enabled_features and (
|
|
@@ -112,7 +111,6 @@ class CliAdminBuilder:
|
|
|
112
111
|
enabled_features.append("jobs")
|
|
113
112
|
break
|
|
114
113
|
|
|
115
|
-
# Protected routes
|
|
116
114
|
status_router = create_status_router(
|
|
117
115
|
start_time, deps.event_socket_manager, auth_dep, enabled_features,
|
|
118
116
|
registry=deps.registry,
|
|
@@ -131,7 +129,6 @@ class CliAdminBuilder:
|
|
|
131
129
|
ws_clients_router = create_ws_clients_router(deps.event_socket_manager, auth_dep)
|
|
132
130
|
router.include_router(ws_clients_router)
|
|
133
131
|
|
|
134
|
-
# Resolve dashboard SPA
|
|
135
132
|
dashboard_dir = resolve_dashboard_dir(self._dashboard_path)
|
|
136
133
|
dashboard_app = None
|
|
137
134
|
|
|
@@ -139,8 +136,11 @@ class CliAdminBuilder:
|
|
|
139
136
|
dashboard_app = SPAStaticFiles(directory=dashboard_dir, html=True)
|
|
140
137
|
|
|
141
138
|
return CliAdminPlugin(
|
|
139
|
+
prefix="/api/v1/qcli",
|
|
140
|
+
dashboard_prefix="/qcli/admin",
|
|
142
141
|
router=router,
|
|
143
142
|
auth_dependency=auth_dep,
|
|
144
143
|
log_buffer=log_buffer,
|
|
145
144
|
dashboard_app=dashboard_app,
|
|
145
|
+
module_registry=module_registry,
|
|
146
146
|
)
|
|
@@ -5,23 +5,22 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
|
|
8
|
+
"""Resolve the dashboard dist directory.
|
|
9
|
+
|
|
10
|
+
Searches in order: explicit path, bundled directory, npm package
|
|
11
|
+
(``node_modules/@qodalis/cli-server-dashboard/dist``), and relative
|
|
12
|
+
development paths.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
The absolute path to the dashboard directory, or ``None``.
|
|
8
16
|
"""
|
|
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
17
|
if explicit_path and os.path.isdir(explicit_path):
|
|
17
18
|
return os.path.abspath(explicit_path)
|
|
18
19
|
|
|
19
|
-
# 2. Bundled dashboard directory (embedded in this package)
|
|
20
20
|
bundled = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'dashboard')
|
|
21
21
|
if os.path.isdir(bundled):
|
|
22
22
|
return bundled
|
|
23
23
|
|
|
24
|
-
# 3. npm package — search upwards from cwd for node_modules
|
|
25
24
|
current = os.getcwd()
|
|
26
25
|
while current != os.path.dirname(current):
|
|
27
26
|
candidate = os.path.join(
|
|
@@ -31,7 +30,6 @@ def resolve_dashboard_dir(explicit_path: str | None = None) -> str | None:
|
|
|
31
30
|
return os.path.abspath(candidate)
|
|
32
31
|
current = os.path.dirname(current)
|
|
33
32
|
|
|
34
|
-
# 4. Relative development paths
|
|
35
33
|
this_dir = os.path.dirname(os.path.abspath(__file__))
|
|
36
34
|
dev_paths = [
|
|
37
35
|
os.path.join(this_dir, "..", "..", "..", "..", "cli-server-dashboard", "dist"),
|
|
@@ -47,8 +47,6 @@ class LogRingBuffer:
|
|
|
47
47
|
self._broadcast_fn = broadcast_fn
|
|
48
48
|
self._handler: _RingBufferHandler | None = None
|
|
49
49
|
|
|
50
|
-
# -- public API -----------------------------------------------------------
|
|
51
|
-
|
|
52
50
|
def add(self, entry: LogEntry) -> None:
|
|
53
51
|
"""Append a log entry to the buffer."""
|
|
54
52
|
entry.level = (entry.level or "INFO").upper()
|
|
@@ -93,8 +91,6 @@ class LogRingBuffer:
|
|
|
93
91
|
total,
|
|
94
92
|
)
|
|
95
93
|
|
|
96
|
-
# -- logging integration --------------------------------------------------
|
|
97
|
-
|
|
98
94
|
def install_handler(self, logger_name: str = "") -> None:
|
|
99
95
|
"""Install a :class:`logging.Handler` that feeds into this buffer."""
|
|
100
96
|
if self._handler is not None:
|
|
@@ -118,6 +114,7 @@ class _RingBufferHandler(logging.Handler):
|
|
|
118
114
|
self._ring = ring
|
|
119
115
|
|
|
120
116
|
def emit(self, record: logging.LogRecord) -> None:
|
|
117
|
+
"""Convert a log record to a LogEntry and append it to the ring buffer."""
|
|
121
118
|
entry = LogEntry(
|
|
122
119
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
123
120
|
level=record.levelname,
|
|
@@ -1,19 +1,54 @@
|
|
|
1
|
-
"""Module registry — reads modules from CliBuilder and tracks enabled state.
|
|
1
|
+
"""Module registry — reads modules from CliBuilder and tracks enabled state.
|
|
2
|
+
|
|
3
|
+
Implements :class:`ICliProcessorFilter` so that processors belonging to
|
|
4
|
+
disabled modules are blocked at execution time.
|
|
5
|
+
"""
|
|
2
6
|
|
|
3
7
|
from __future__ import annotations
|
|
4
8
|
|
|
5
9
|
from typing import Any, TYPE_CHECKING
|
|
6
10
|
|
|
11
|
+
from qodalis_cli_server_abstractions import ICliProcessorFilter
|
|
12
|
+
|
|
7
13
|
if TYPE_CHECKING:
|
|
14
|
+
from qodalis_cli_server_abstractions import ICliCommandProcessor
|
|
8
15
|
from qodalis_cli.extensions.cli_builder import CliBuilder
|
|
9
16
|
|
|
10
17
|
|
|
11
|
-
class ModuleRegistry:
|
|
12
|
-
"""Provides a read/toggle view over modules registered in :class:`CliBuilder`.
|
|
18
|
+
class ModuleRegistry(ICliProcessorFilter):
|
|
19
|
+
"""Provides a read/toggle view over modules registered in :class:`CliBuilder`.
|
|
20
|
+
|
|
21
|
+
Also acts as a processor filter: processors that belong to a disabled
|
|
22
|
+
module are reported as *not allowed*, causing the executor to reject
|
|
23
|
+
the command with a user-friendly error.
|
|
24
|
+
"""
|
|
13
25
|
|
|
14
26
|
def __init__(self, builder: CliBuilder) -> None:
|
|
15
27
|
self._builder = builder
|
|
16
28
|
self._disabled: set[str] = set()
|
|
29
|
+
# Map each processor instance to its module id for fast lookup.
|
|
30
|
+
self._processor_to_module: dict[int, str] = {}
|
|
31
|
+
self._rebuild_processor_map()
|
|
32
|
+
|
|
33
|
+
def _rebuild_processor_map(self) -> None:
|
|
34
|
+
"""(Re-)build the processor-to-module-id mapping."""
|
|
35
|
+
self._processor_to_module.clear()
|
|
36
|
+
for idx, module in enumerate(self._builder.modules):
|
|
37
|
+
module_id = str(idx)
|
|
38
|
+
for processor in module.processors:
|
|
39
|
+
self._processor_to_module[id(processor)] = module_id
|
|
40
|
+
|
|
41
|
+
# -- ICliProcessorFilter ------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def is_allowed(self, processor: ICliCommandProcessor) -> bool:
|
|
44
|
+
"""Return ``False`` if the processor belongs to a disabled module."""
|
|
45
|
+
module_id = self._processor_to_module.get(id(processor))
|
|
46
|
+
if module_id is None:
|
|
47
|
+
# Processor not registered via a module — always allowed.
|
|
48
|
+
return True
|
|
49
|
+
return module_id not in self._disabled
|
|
50
|
+
|
|
51
|
+
# -- Public API ---------------------------------------------------------
|
|
17
52
|
|
|
18
53
|
def list(self) -> list[dict[str, Any]]:
|
|
19
54
|
"""Return a list of all modules with their enabled/disabled state."""
|
|
@@ -55,10 +90,4 @@ class ModuleRegistry:
|
|
|
55
90
|
"enabled": enabled,
|
|
56
91
|
}
|
|
57
92
|
|
|
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
93
|
return result
|
|
@@ -24,12 +24,12 @@ class SPAStaticFiles(StaticFiles):
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
|
27
|
+
"""Return the static file or fall back to ``index.html`` for SPA routes."""
|
|
27
28
|
try:
|
|
28
29
|
return await super().get_response(path, scope)
|
|
29
30
|
except StarletteHTTPException as exc:
|
|
30
31
|
if exc.status_code != 404:
|
|
31
32
|
raise
|
|
32
|
-
# File not found — serve index.html for SPA client-side routing
|
|
33
33
|
index = os.path.join(self.directory, "index.html") # type: ignore[arg-type]
|
|
34
34
|
if os.path.isfile(index):
|
|
35
35
|
return FileResponse(index, media_type="text/html")
|
{qodalis_cli_server_plugin_admin-1.0.0b1 → qodalis_cli_server_plugin_admin-1.0.0b5}/.gitignore
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|