celery-manager 0.1.0a2__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.
- celery_manager/__init__.py +47 -0
- celery_manager/api/__init__.py +0 -0
- celery_manager/api/app.py +83 -0
- celery_manager/api/deps.py +34 -0
- celery_manager/api/routes/__init__.py +0 -0
- celery_manager/api/routes/audit.py +42 -0
- celery_manager/api/routes/auth.py +55 -0
- celery_manager/api/routes/schedules.py +72 -0
- celery_manager/api/routes/settings.py +83 -0
- celery_manager/api/routes/tasks.py +204 -0
- celery_manager/api/security.py +143 -0
- celery_manager/api/serialization.py +55 -0
- celery_manager/api/sse.py +89 -0
- celery_manager/auth/__init__.py +0 -0
- celery_manager/auth/sessions.py +19 -0
- celery_manager/auth/users.py +51 -0
- celery_manager/cli.py +46 -0
- celery_manager/config.py +38 -0
- celery_manager/control/__init__.py +55 -0
- celery_manager/control/audit.py +20 -0
- celery_manager/control/schedules.py +271 -0
- celery_manager/control/settings.py +266 -0
- celery_manager/control/tasks.py +319 -0
- celery_manager/dashboard/__init__.py +6 -0
- celery_manager/dashboard/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
- celery_manager/dashboard/static/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
- celery_manager/dashboard/static/assets/index-AUSbcb6p.js +11 -0
- celery_manager/dashboard/static/assets/index-CzsGQV47.css +1 -0
- celery_manager/dashboard/static/favicon.svg +4 -0
- celery_manager/dashboard/static/index.html +42 -0
- celery_manager/db/__init__.py +0 -0
- celery_manager/db/models.py +124 -0
- celery_manager/db/session.py +34 -0
- celery_manager/decorator.py +451 -0
- celery_manager/events/__init__.py +0 -0
- celery_manager/events/listener.py +104 -0
- celery_manager/logcapture.py +19 -0
- celery_manager/progress.py +321 -0
- celery_manager/runtime.py +54 -0
- celery_manager-0.1.0a2.dist-info/METADATA +225 -0
- celery_manager-0.1.0a2.dist-info/RECORD +54 -0
- celery_manager-0.1.0a2.dist-info/WHEEL +4 -0
- celery_manager-0.1.0a2.dist-info/entry_points.txt +2 -0
- celery_manager-0.1.0a2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""celery-manager — a dashboard and control API for Celery background tasks.
|
|
2
|
+
|
|
3
|
+
The public surface spec §4 assumes, importable straight from the package
|
|
4
|
+
root::
|
|
5
|
+
|
|
6
|
+
import celery_manager
|
|
7
|
+
from celery_manager import CeleryManagerConfig, setup, track, report_progress
|
|
8
|
+
|
|
9
|
+
celery_manager.setup(config) # wire this process (worker or API)
|
|
10
|
+
|
|
11
|
+
@app.task(bind=True)
|
|
12
|
+
@celery_manager.track()
|
|
13
|
+
def send_report(self): ...
|
|
14
|
+
|
|
15
|
+
`report_progress(current, total=None, message=None)` is called from *inside*
|
|
16
|
+
a task body to publish live progress to the dashboard while the task is still
|
|
17
|
+
running. It is throttled, tolerant of bad arguments, and — like everything
|
|
18
|
+
else here — never raises into the task that called it::
|
|
19
|
+
|
|
20
|
+
@app.task(bind=True)
|
|
21
|
+
@celery_manager.track()
|
|
22
|
+
def import_rows(self, rows):
|
|
23
|
+
for i, row in enumerate(rows):
|
|
24
|
+
handle(row)
|
|
25
|
+
celery_manager.report_progress(i + 1, len(rows), f"row {i + 1}")
|
|
26
|
+
|
|
27
|
+
`celery_manager.control` (task/schedule/audit operations) is re-exported here
|
|
28
|
+
too, so `celery_manager.control.list_task_runs(...)` works without a separate
|
|
29
|
+
import of the submodule.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from celery_manager import control
|
|
33
|
+
from celery_manager.config import CeleryManagerConfig
|
|
34
|
+
from celery_manager.decorator import track
|
|
35
|
+
from celery_manager.progress import report_progress
|
|
36
|
+
from celery_manager.runtime import setup
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0a2"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"CeleryManagerConfig",
|
|
42
|
+
"__version__",
|
|
43
|
+
"control",
|
|
44
|
+
"report_progress",
|
|
45
|
+
"setup",
|
|
46
|
+
"track",
|
|
47
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, FastAPI, Request
|
|
6
|
+
from fastapi.responses import StreamingResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
from celery_manager.api.deps import get_current_user
|
|
10
|
+
from celery_manager.api.routes import (
|
|
11
|
+
audit as audit_routes,
|
|
12
|
+
)
|
|
13
|
+
from celery_manager.api.routes import (
|
|
14
|
+
auth as auth_routes,
|
|
15
|
+
)
|
|
16
|
+
from celery_manager.api.routes import (
|
|
17
|
+
schedules as schedule_routes,
|
|
18
|
+
)
|
|
19
|
+
from celery_manager.api.routes import (
|
|
20
|
+
settings as settings_routes,
|
|
21
|
+
)
|
|
22
|
+
from celery_manager.api.routes import (
|
|
23
|
+
tasks as task_routes,
|
|
24
|
+
)
|
|
25
|
+
from celery_manager.api.security import SecurityMiddleware
|
|
26
|
+
from celery_manager.api.sse import EventBus, sse_stream
|
|
27
|
+
from celery_manager.config import CeleryManagerConfig
|
|
28
|
+
from celery_manager.control.schedules import sync_periodic_schedules
|
|
29
|
+
from celery_manager.runtime import setup as setup_runtime
|
|
30
|
+
|
|
31
|
+
# The placeholder `CeleryManagerConfig.secret_key` ships with. It is public —
|
|
32
|
+
# it's in this file and the docs — so a session cookie signed with it can be
|
|
33
|
+
# forged for any username by anyone.
|
|
34
|
+
_DEFAULT_SECRET_KEY = "change-me"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_app(config: CeleryManagerConfig):
|
|
38
|
+
"""Build the celery-manager FastAPI app. Returns (app, session_factory)
|
|
39
|
+
— callers that need direct DB access (tests, the events listener CLI)
|
|
40
|
+
use the session_factory; HTTP consumers just use the app."""
|
|
41
|
+
# Checked here rather than in CeleryManagerConfig: worker and listener
|
|
42
|
+
# processes build the same config but never sign or verify a session, so
|
|
43
|
+
# they're free to leave secret_key alone. Only a process serving HTTP is
|
|
44
|
+
# a forgery risk, and it should refuse to start, not start insecurely.
|
|
45
|
+
if not config.secret_key or not config.secret_key.strip() or config.secret_key == _DEFAULT_SECRET_KEY:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"CeleryManagerConfig.secret_key is unset or still the default "
|
|
48
|
+
f"{_DEFAULT_SECRET_KEY!r}; anyone could forge a dashboard session with it. "
|
|
49
|
+
"Set it to a private random value, e.g. the output of "
|
|
50
|
+
'`python -c "import secrets; print(secrets.token_urlsafe(32))"`.'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Single shared entry point for "wire celery-manager into this process"
|
|
54
|
+
# (init_db + session factory + decorator configure) rather than an inline
|
|
55
|
+
# copy of it, so the API process and the worker process — which must call
|
|
56
|
+
# `celery_manager.setup(config)` itself, see runtime.setup's docstring —
|
|
57
|
+
# can never drift out of sync.
|
|
58
|
+
session_factory = setup_runtime(config)
|
|
59
|
+
|
|
60
|
+
with session_factory() as session:
|
|
61
|
+
sync_periodic_schedules(session, config.celery_app)
|
|
62
|
+
|
|
63
|
+
app = FastAPI(title="celery-manager")
|
|
64
|
+
app.state.cm_config = config
|
|
65
|
+
app.state.cm_session_factory = session_factory
|
|
66
|
+
app.state.cm_event_bus = EventBus()
|
|
67
|
+
app.add_middleware(SecurityMiddleware)
|
|
68
|
+
|
|
69
|
+
app.include_router(auth_routes.router)
|
|
70
|
+
app.include_router(task_routes.router)
|
|
71
|
+
app.include_router(schedule_routes.router)
|
|
72
|
+
app.include_router(audit_routes.router)
|
|
73
|
+
app.include_router(settings_routes.router)
|
|
74
|
+
|
|
75
|
+
@app.get("/api/stream")
|
|
76
|
+
def stream(request: Request, username: str = Depends(get_current_user)):
|
|
77
|
+
return StreamingResponse(sse_stream(app.state.cm_event_bus), media_type="text/event-stream")
|
|
78
|
+
|
|
79
|
+
static_dir = Path(__file__).parent.parent / "dashboard" / "static"
|
|
80
|
+
if static_dir.exists():
|
|
81
|
+
app.mount("/", StaticFiles(directory=static_dir, html=True), name="dashboard")
|
|
82
|
+
|
|
83
|
+
return app, session_factory
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import Cookie, HTTPException, Request
|
|
4
|
+
|
|
5
|
+
from celery_manager.auth.sessions import verify_session
|
|
6
|
+
from celery_manager.auth.users import user_exists
|
|
7
|
+
|
|
8
|
+
# Not plain `session`: celery-manager is usually mounted inside a host app,
|
|
9
|
+
# and Flask's and Starlette's own session cookies are both called `session`
|
|
10
|
+
# — two cookies of the same name on overlapping paths clobber each other.
|
|
11
|
+
SESSION_COOKIE = "cm_session"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cookie_path(request: Request) -> str:
|
|
15
|
+
"""Scope the session cookie to wherever this app is mounted, so a
|
|
16
|
+
dashboard under `/celery` doesn't send its credential to every other
|
|
17
|
+
path on the host. Must be identical on set and delete — a browser only
|
|
18
|
+
removes a cookie whose path matches the one it was set with."""
|
|
19
|
+
return request.scope.get("root_path") or "/"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_current_user(request: Request, session: str | None = Cookie(default=None, alias=SESSION_COOKIE)) -> str:
|
|
23
|
+
config = request.app.state.cm_config
|
|
24
|
+
if session is None:
|
|
25
|
+
raise HTTPException(status_code=401, detail="not authenticated")
|
|
26
|
+
username = verify_session(config.secret_key, session)
|
|
27
|
+
if username is None:
|
|
28
|
+
raise HTTPException(status_code=401, detail="invalid or expired session")
|
|
29
|
+
# Sessions are stateless signed tokens, so a valid signature alone would
|
|
30
|
+
# keep a deleted account working for the rest of its 7 days.
|
|
31
|
+
with request.app.state.cm_session_factory() as db_session:
|
|
32
|
+
if not user_exists(db_session, username):
|
|
33
|
+
raise HTTPException(status_code=401, detail="invalid or expired session")
|
|
34
|
+
return username
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, Request
|
|
4
|
+
|
|
5
|
+
from celery_manager.api.deps import get_current_user
|
|
6
|
+
from celery_manager.api.serialization import utc
|
|
7
|
+
from celery_manager.control.tasks import MAX_PAGE_SIZE
|
|
8
|
+
from celery_manager.db.models import AuditLog
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api/audit")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("")
|
|
14
|
+
def list_audit(request: Request, limit: int = 50, username: str = Depends(get_current_user)):
|
|
15
|
+
"""The most recent audit entries, newest first.
|
|
16
|
+
|
|
17
|
+
`limit` is clamped the same way `GET /api/tasks` clamps its own — and for
|
|
18
|
+
a sharper reason here: SQLite reads `LIMIT -1` as *no limit*, so
|
|
19
|
+
`?limit=-1` handed back the entire audit log in one response. Same
|
|
20
|
+
reasoning as the task list: clamp rather than reject, because a stale
|
|
21
|
+
bookmark deserves a capped page rather than a 400.
|
|
22
|
+
"""
|
|
23
|
+
limit = max(1, min(limit, MAX_PAGE_SIZE))
|
|
24
|
+
session_factory = request.app.state.cm_session_factory
|
|
25
|
+
with session_factory() as session:
|
|
26
|
+
rows = session.query(AuditLog).order_by(AuditLog.id.desc()).limit(limit).all()
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
"username": r.username,
|
|
30
|
+
"action": r.action,
|
|
31
|
+
"target_type": r.target_type,
|
|
32
|
+
"target_id": r.target_id,
|
|
33
|
+
"details": r.details,
|
|
34
|
+
# Marked UTC for the same reason the task routes mark theirs:
|
|
35
|
+
# SQLite hands the value back naive, and the audit screen
|
|
36
|
+
# renders it with `new Date(...).toLocaleString()`, which reads
|
|
37
|
+
# an unmarked timestamp as local time and shifts every entry by
|
|
38
|
+
# the viewer's offset.
|
|
39
|
+
"created_at": utc(r.created_at),
|
|
40
|
+
}
|
|
41
|
+
for r in rows
|
|
42
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from celery_manager.api.deps import SESSION_COOKIE, cookie_path, get_current_user
|
|
7
|
+
from celery_manager.auth.sessions import sign_session
|
|
8
|
+
from celery_manager.auth.users import verify_password
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/api")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoginRequest(BaseModel):
|
|
14
|
+
username: str
|
|
15
|
+
password: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/auth/login")
|
|
19
|
+
def login(payload: LoginRequest, request: Request, response: Response):
|
|
20
|
+
config = request.app.state.cm_config
|
|
21
|
+
session_factory = request.app.state.cm_session_factory
|
|
22
|
+
with session_factory() as db_session:
|
|
23
|
+
user = verify_password(db_session, payload.username, payload.password)
|
|
24
|
+
if user is None:
|
|
25
|
+
raise HTTPException(status_code=401, detail="invalid credentials")
|
|
26
|
+
token = sign_session(config.secret_key, user.username)
|
|
27
|
+
response.set_cookie(
|
|
28
|
+
SESSION_COOKIE,
|
|
29
|
+
token,
|
|
30
|
+
path=cookie_path(request),
|
|
31
|
+
httponly=True,
|
|
32
|
+
samesite="lax",
|
|
33
|
+
secure=config.cookie_secure,
|
|
34
|
+
)
|
|
35
|
+
return {"username": user.username}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.post("/auth/logout")
|
|
39
|
+
def logout(request: Request, response: Response):
|
|
40
|
+
config = request.app.state.cm_config
|
|
41
|
+
# Same attributes as the login cookie: some browsers ignore a deletion
|
|
42
|
+
# whose Secure/SameSite don't match, and a mismatched path deletes nothing.
|
|
43
|
+
response.delete_cookie(
|
|
44
|
+
SESSION_COOKIE,
|
|
45
|
+
path=cookie_path(request),
|
|
46
|
+
httponly=True,
|
|
47
|
+
samesite="lax",
|
|
48
|
+
secure=config.cookie_secure,
|
|
49
|
+
)
|
|
50
|
+
return {"ok": True}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get("/whoami")
|
|
54
|
+
def whoami(username: str = Depends(get_current_user)):
|
|
55
|
+
return {"username": username}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from celery_manager.api.deps import get_current_user
|
|
10
|
+
from celery_manager.control.schedules import (
|
|
11
|
+
list_periodic_schedules,
|
|
12
|
+
list_upcoming_with_overrides,
|
|
13
|
+
pause_occurrence,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/api/schedules")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PauseRequest(BaseModel):
|
|
20
|
+
target_time: datetime
|
|
21
|
+
# Deliberately narrower than ScheduleOverride.action's column, which also
|
|
22
|
+
# permits "reschedule". Nothing downstream implements reschedule — the
|
|
23
|
+
# decorator only ever acts on "skip"/"cancel" — so accepting the value
|
|
24
|
+
# here persisted an override that silently did nothing while the
|
|
25
|
+
# dashboard cheerfully displayed the occurrence as paused. Rejecting it
|
|
26
|
+
# at the boundary (Pydantic returns 422) is honest; storing a lie is not.
|
|
27
|
+
# Widen this the day reschedule is actually implemented, not before.
|
|
28
|
+
action: Literal["skip", "cancel"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/{name}/occurrences/pause")
|
|
32
|
+
def pause(name: str, payload: PauseRequest, request: Request, username: str = Depends(get_current_user)):
|
|
33
|
+
session_factory = request.app.state.cm_session_factory
|
|
34
|
+
eta_tolerance_seconds = request.app.state.cm_config.eta_tolerance_seconds
|
|
35
|
+
with session_factory() as session:
|
|
36
|
+
override = pause_occurrence(
|
|
37
|
+
session, name, payload.target_time, payload.action,
|
|
38
|
+
created_by=username, eta_tolerance_seconds=eta_tolerance_seconds,
|
|
39
|
+
)
|
|
40
|
+
return {"id": override.id, "action": override.action, "target_time": override.target_time}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/{name}/occurrences")
|
|
44
|
+
def occurrences(name: str, request: Request, username: str = Depends(get_current_user)):
|
|
45
|
+
"""Every upcoming occurrence for this schedule (not just already-paused
|
|
46
|
+
ones) so the dashboard has something to act on — see
|
|
47
|
+
list_upcoming_with_overrides in Task 6.
|
|
48
|
+
|
|
49
|
+
`list_upcoming_with_overrides` recomputes occurrence timestamps
|
|
50
|
+
relative to "now" on every call, so a `target_time` a user paused a
|
|
51
|
+
few seconds ago won't be byte-identical to the same logical
|
|
52
|
+
occurrence recomputed on this request. It handles that itself via
|
|
53
|
+
`eta_tolerance_seconds`-window matching (same concept as the
|
|
54
|
+
decorator's own skip-check window) rather than exact equality — we
|
|
55
|
+
just need to pass the configured tolerance through.
|
|
56
|
+
"""
|
|
57
|
+
celery_app = request.app.state.cm_config.celery_app
|
|
58
|
+
session_factory = request.app.state.cm_session_factory
|
|
59
|
+
eta_tolerance_seconds = request.app.state.cm_config.eta_tolerance_seconds
|
|
60
|
+
with session_factory() as session:
|
|
61
|
+
return list_upcoming_with_overrides(session, celery_app, name, eta_tolerance_seconds=eta_tolerance_seconds)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("")
|
|
65
|
+
def list_schedules(request: Request, username: str = Depends(get_current_user)):
|
|
66
|
+
session_factory = request.app.state.cm_session_factory
|
|
67
|
+
with session_factory() as session:
|
|
68
|
+
schedules = list_periodic_schedules(session)
|
|
69
|
+
return [
|
|
70
|
+
{"name": s.name, "task_name": s.task_name, "schedule_spec": s.schedule_spec, "enabled": s.enabled}
|
|
71
|
+
for s in schedules
|
|
72
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
from celery_manager.api.deps import get_current_user
|
|
7
|
+
from celery_manager.control.settings import get_settings, update_settings
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/api/settings")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("")
|
|
13
|
+
def read_settings(request: Request):
|
|
14
|
+
"""The deployment's branding. **Deliberately unauthenticated.**
|
|
15
|
+
|
|
16
|
+
The login screen has to render this deployment's branding — its wordmark,
|
|
17
|
+
its accent, its logo — and the login screen is by definition pre-auth. An
|
|
18
|
+
authenticated endpoint here would mean the dashboard shows stock branding
|
|
19
|
+
until the moment you sign in, which defeats the feature for the one screen
|
|
20
|
+
an operator's users see most often.
|
|
21
|
+
|
|
22
|
+
That is safe only because the payload is non-sensitive by construction: a
|
|
23
|
+
product name, a hex colour and an image URL, and nothing else, because
|
|
24
|
+
`control.settings.DEFAULT_SETTINGS` is a closed allowlist. It is why that
|
|
25
|
+
allowlist must stay closed — see the comment on it before adding a key.
|
|
26
|
+
|
|
27
|
+
`updated_by` and `updated_at` are deliberately *not* returned. They are
|
|
28
|
+
the one genuinely sensitive thing this table holds: `updated_by` is a
|
|
29
|
+
valid username, and handing an unauthenticated visitor a real account name
|
|
30
|
+
turns a password-guessing attack from two unknowns into one. Nothing in
|
|
31
|
+
the UI needs them, so they are omitted rather than served behind a second
|
|
32
|
+
authenticated route that would exist only to be tidy.
|
|
33
|
+
"""
|
|
34
|
+
session_factory = request.app.state.cm_session_factory
|
|
35
|
+
with session_factory() as session:
|
|
36
|
+
return get_settings(session)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SettingsUpdate(BaseModel):
|
|
40
|
+
"""PUT body. Every field optional, so a caller can send just the one it
|
|
41
|
+
is changing rather than read-modify-writing the whole set.
|
|
42
|
+
|
|
43
|
+
`extra="forbid"` so an unrecognized key is a 422 from pydantic, matching
|
|
44
|
+
what `update_settings` does with unknown keys when it is called directly
|
|
45
|
+
from Python. Silently dropping an unknown field would let a typo look like
|
|
46
|
+
a successful save.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(extra="forbid")
|
|
50
|
+
|
|
51
|
+
brand_name: str | None = None
|
|
52
|
+
brand_accent: str | None = None
|
|
53
|
+
brand_logo_url: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.put("")
|
|
57
|
+
def write_settings(payload: SettingsUpdate, request: Request, username: str = Depends(get_current_user)):
|
|
58
|
+
"""Update branding. Requires a session; returns the new effective settings.
|
|
59
|
+
|
|
60
|
+
Any authenticated user may rebrand the dashboard. There is no role system
|
|
61
|
+
in this app — `get_current_user` yields a username and nothing else — so
|
|
62
|
+
"admin only" is not expressible today without inventing one. That is an
|
|
63
|
+
accepted v1 limitation, not an oversight: the blast radius is cosmetic and
|
|
64
|
+
every change is attributed in the audit log, which is how you find out who
|
|
65
|
+
turned the nav pink.
|
|
66
|
+
"""
|
|
67
|
+
# `exclude_unset` is what makes a partial update possible: it keeps the
|
|
68
|
+
# difference between "brand_accent was not in the JSON" (leave it alone)
|
|
69
|
+
# and "brand_accent was sent as null" (an explicit value, which the
|
|
70
|
+
# validators reject as a non-string rather than silently ignoring).
|
|
71
|
+
values = payload.model_dump(exclude_unset=True)
|
|
72
|
+
|
|
73
|
+
session_factory = request.app.state.cm_session_factory
|
|
74
|
+
with session_factory() as session:
|
|
75
|
+
try:
|
|
76
|
+
return update_settings(session, values, username)
|
|
77
|
+
except ValueError as exc:
|
|
78
|
+
# 422, not 400: this is a well-formed request whose *content*
|
|
79
|
+
# failed validation, which is the same code FastAPI/pydantic
|
|
80
|
+
# already returns for a bad body — so a client has one error shape
|
|
81
|
+
# to handle, not two. The message is human-readable on purpose;
|
|
82
|
+
# it is rendered straight into the settings form.
|
|
83
|
+
raise HTTPException(status_code=422, detail=str(exc))
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from celery_manager.api.deps import get_current_user
|
|
9
|
+
from celery_manager.api.serialization import utc, utc_bound
|
|
10
|
+
from celery_manager.control.tasks import (
|
|
11
|
+
MAX_PAGE_SIZE,
|
|
12
|
+
TaskRunNotFound,
|
|
13
|
+
bulk_retry,
|
|
14
|
+
cancel_task,
|
|
15
|
+
count_task_runs,
|
|
16
|
+
get_task_run,
|
|
17
|
+
list_retries_of,
|
|
18
|
+
list_task_runs,
|
|
19
|
+
retry_task,
|
|
20
|
+
start_task,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
router = APIRouter(prefix="/api/tasks")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("")
|
|
27
|
+
def list_tasks(
|
|
28
|
+
request: Request,
|
|
29
|
+
status: str | None = None,
|
|
30
|
+
task_name: str | None = None,
|
|
31
|
+
q: str | None = None,
|
|
32
|
+
date_from: datetime | None = None,
|
|
33
|
+
date_to: datetime | None = None,
|
|
34
|
+
limit: int = 50,
|
|
35
|
+
offset: int = 0,
|
|
36
|
+
username: str = Depends(get_current_user),
|
|
37
|
+
):
|
|
38
|
+
"""One page of task runs, plus the total matching the same filters.
|
|
39
|
+
|
|
40
|
+
Returns an envelope (`items`/`total`/`limit`/`offset`) rather than a bare
|
|
41
|
+
array. The array shape had no room for a total, which left the UI unable
|
|
42
|
+
to tell "50 results" from "the first 50 of 1,284" — and with the old hard
|
|
43
|
+
50-row cap and no offset, older history was simply unreachable. Changing
|
|
44
|
+
the shape is a deliberate break, taken while the package is unreleased at
|
|
45
|
+
0.1.0 and its only consumers are our own SPA and docs/rest-api.md.
|
|
46
|
+
"""
|
|
47
|
+
# Clamp rather than reject: `?limit=10000000` is more often a stale
|
|
48
|
+
# bookmark than an attack, but either way it must not turn one HTTP
|
|
49
|
+
# request into a full-table load, and a 400 here would only give the
|
|
50
|
+
# caller a broken page instead of a capped one. Negative values are
|
|
51
|
+
# meaningless to OFFSET/LIMIT, so floor them too.
|
|
52
|
+
limit = max(1, min(limit, MAX_PAGE_SIZE))
|
|
53
|
+
offset = max(0, offset)
|
|
54
|
+
|
|
55
|
+
session_factory = request.app.state.cm_session_factory
|
|
56
|
+
with session_factory() as session:
|
|
57
|
+
filters = {
|
|
58
|
+
"status": status, "task_name": task_name, "q": q,
|
|
59
|
+
"date_from": utc_bound(date_from), "date_to": utc_bound(date_to),
|
|
60
|
+
}
|
|
61
|
+
# The same filter dict feeds both calls, so `total` can never be a
|
|
62
|
+
# count of some other query than the one `items` came from.
|
|
63
|
+
runs = list_task_runs(session, limit=limit, offset=offset, **filters)
|
|
64
|
+
total = count_task_runs(session, **filters)
|
|
65
|
+
return {
|
|
66
|
+
"items": [
|
|
67
|
+
{
|
|
68
|
+
"task_id": r.task_id,
|
|
69
|
+
"task_name": r.task_name,
|
|
70
|
+
"status": r.status,
|
|
71
|
+
"started_at": utc(r.started_at),
|
|
72
|
+
"finished_at": utc(r.finished_at),
|
|
73
|
+
"runtime_seconds": r.runtime_seconds,
|
|
74
|
+
# Progress is opt-in (only tasks that report it have any),
|
|
75
|
+
# so these are usually null. Shipped raw: the frontend
|
|
76
|
+
# derives percentage and ETA, because it is the side that
|
|
77
|
+
# knows how much space it has to say it in.
|
|
78
|
+
"progress_current": r.progress_current,
|
|
79
|
+
"progress_total": r.progress_total,
|
|
80
|
+
"progress_message": r.progress_message,
|
|
81
|
+
"progress_updated_at": utc(r.progress_updated_at),
|
|
82
|
+
}
|
|
83
|
+
for r in runs
|
|
84
|
+
],
|
|
85
|
+
"total": total,
|
|
86
|
+
"limit": limit,
|
|
87
|
+
"offset": offset,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/{task_id}")
|
|
92
|
+
def get_task(task_id: str, request: Request, username: str = Depends(get_current_user)):
|
|
93
|
+
session_factory = request.app.state.cm_session_factory
|
|
94
|
+
with session_factory() as session:
|
|
95
|
+
run = get_task_run(session, task_id)
|
|
96
|
+
if run is None:
|
|
97
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
98
|
+
return {
|
|
99
|
+
"task_id": run.task_id,
|
|
100
|
+
"task_name": run.task_name,
|
|
101
|
+
"status": run.status,
|
|
102
|
+
"args": run.args,
|
|
103
|
+
"kwargs": run.kwargs,
|
|
104
|
+
"exception": run.exception,
|
|
105
|
+
"traceback": run.traceback,
|
|
106
|
+
"log_output": run.log_output,
|
|
107
|
+
# Operational detail already stored on TaskRun (by the events
|
|
108
|
+
# listener for worker/queue, by the decorator for the timings) but
|
|
109
|
+
# never surfaced here — so the dashboard's detail view could not
|
|
110
|
+
# answer "which worker ran this, off which queue, and how long did
|
|
111
|
+
# it take". The list endpoint returned started_at/finished_at while
|
|
112
|
+
# this one didn't, which also made the frontend's TaskDetail type
|
|
113
|
+
# (extending TaskSummary) claim fields the response never sent.
|
|
114
|
+
"queue": run.queue,
|
|
115
|
+
"worker": run.worker,
|
|
116
|
+
"eta": utc(run.eta),
|
|
117
|
+
"started_at": utc(run.started_at),
|
|
118
|
+
"finished_at": utc(run.finished_at),
|
|
119
|
+
"runtime_seconds": run.runtime_seconds,
|
|
120
|
+
"progress_current": run.progress_current,
|
|
121
|
+
"progress_total": run.progress_total,
|
|
122
|
+
"progress_message": run.progress_message,
|
|
123
|
+
"progress_updated_at": utc(run.progress_updated_at),
|
|
124
|
+
# Both directions of the retry chain: the run this one was
|
|
125
|
+
# retried from, and the runs retried from it. `retried_by` is a
|
|
126
|
+
# list because an operator can retry the same failed run more than
|
|
127
|
+
# once, and each attempt is its own run.
|
|
128
|
+
"retried_from_id": run.retried_from_id,
|
|
129
|
+
"retried_by": [r.task_id for r in list_retries_of(session, task_id)],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class StartRequest(BaseModel):
|
|
134
|
+
task_name: str
|
|
135
|
+
args: list = []
|
|
136
|
+
kwargs: dict = {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post("/start")
|
|
140
|
+
def start(payload: StartRequest, request: Request, username: str = Depends(get_current_user)):
|
|
141
|
+
celery_app = request.app.state.cm_config.celery_app
|
|
142
|
+
session_factory = request.app.state.cm_session_factory
|
|
143
|
+
# session + username are what make this action land in the audit log,
|
|
144
|
+
# same as the retry route below.
|
|
145
|
+
with session_factory() as session:
|
|
146
|
+
task_id = start_task(
|
|
147
|
+
celery_app, payload.task_name, payload.args, payload.kwargs,
|
|
148
|
+
session=session, username=username,
|
|
149
|
+
)
|
|
150
|
+
return {"task_id": task_id}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.post("/{task_id}/cancel")
|
|
154
|
+
def cancel(task_id: str, request: Request, terminate: bool = False, username: str = Depends(get_current_user)):
|
|
155
|
+
celery_app = request.app.state.cm_config.celery_app
|
|
156
|
+
session_factory = request.app.state.cm_session_factory
|
|
157
|
+
with session_factory() as session:
|
|
158
|
+
cancel_task(celery_app, task_id, terminate=terminate, session=session, username=username)
|
|
159
|
+
return {"ok": True}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/{task_id}/retry")
|
|
163
|
+
def retry(task_id: str, request: Request, username: str = Depends(get_current_user)):
|
|
164
|
+
"""Re-dispatch a stored run. 404 if there is no such run.
|
|
165
|
+
|
|
166
|
+
A retry of an unknown task id used to escape as an unhandled `ValueError`
|
|
167
|
+
and reach the client as a 500 — which reads as "the dashboard is broken"
|
|
168
|
+
rather than "that task id doesn't exist", and is indistinguishable from a
|
|
169
|
+
genuine server fault to anyone reading logs or alerting on 5xx. It is the
|
|
170
|
+
same condition `GET /api/tasks/{task_id}` already answers with a 404, so
|
|
171
|
+
it gets the same status. Only `TaskRunNotFound` is translated: a
|
|
172
|
+
`ValueError` from undecodable stored args is a real server-side problem
|
|
173
|
+
and must keep surfacing as one.
|
|
174
|
+
"""
|
|
175
|
+
celery_app = request.app.state.cm_config.celery_app
|
|
176
|
+
session_factory = request.app.state.cm_session_factory
|
|
177
|
+
with session_factory() as session:
|
|
178
|
+
try:
|
|
179
|
+
new_id = retry_task(celery_app, session, task_id, username=username)
|
|
180
|
+
except TaskRunNotFound:
|
|
181
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
182
|
+
return {"new_task_id": new_id}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class BulkRetryRequest(BaseModel):
|
|
186
|
+
task_ids: list[str]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.post("/retry-bulk")
|
|
190
|
+
def retry_bulk(payload: BulkRetryRequest, request: Request, username: str = Depends(get_current_user)):
|
|
191
|
+
"""Re-dispatch several stored runs. 404 if any of them is unknown.
|
|
192
|
+
|
|
193
|
+
`bulk_retry` stops at the first unknown id (documented limitation — it is
|
|
194
|
+
not partial-failure aware), so an unknown id in the batch is reported the
|
|
195
|
+
same way a single unknown id is, rather than as a 500.
|
|
196
|
+
"""
|
|
197
|
+
celery_app = request.app.state.cm_config.celery_app
|
|
198
|
+
session_factory = request.app.state.cm_session_factory
|
|
199
|
+
with session_factory() as session:
|
|
200
|
+
try:
|
|
201
|
+
new_ids = bulk_retry(celery_app, session, payload.task_ids, username=username)
|
|
202
|
+
except TaskRunNotFound as exc:
|
|
203
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
204
|
+
return {"new_task_ids": new_ids}
|