import html import json from urllib.parse import quote from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import require_user from app.db.audit import log_audit_event from app.db.jobs import create_job, get_jobs_by_target_ids from app.routes.jobs import render_job_status from app.templates.layout import page router = APIRouter() # Portál NEMÁ vlastní deploy logiku. Všechny provozní akce deleguje do existujících shell skriptů # v appfactory-tools přes existující job systém (job_type="run_script"). # Skripty leží v appfactory-tools/scripts/, proto je předáváme i s prefixem scripts/, jinak je # worker hledá ve špatné složce a job selže s "skript nenalezen". DEPLOY_CORE_SCRIPT = "scripts/deploy-core-service.sh" CADDY_SCRIPT = "scripts/generate-caddyfile.sh" ENABLED_APPS_SCRIPT = "scripts/redeploy-enabled-apps.sh" # Skripty považované za "runtime operace" (filtr dashboardu). RUNTIME_SCRIPTS = (DEPLOY_CORE_SCRIPT, CADDY_SCRIPT, ENABLED_APPS_SCRIPT) ALL_CORE_KEY = "redeploy-all-core" ALL_CORE_LABEL = "Redeploy All Core Services" ALL_CORE_ICON = "fa-server" # Core služby nasazované přes deploy-core-service.sh . (key, name, icon, service_arg) CORE_SERVICES = [ ("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"), ("redeploy-worker", "Worker", "fa-gears", "appfactory-worker"), ("redeploy-webhook", "Webhook", "fa-bolt", "appfactory-webhook"), ("redeploy-monitor", "Monitor", "fa-heart-pulse", "appfactory-monitor"), ("redeploy-gateway", "Gateway (Caddy)", "fa-shield-halved", "appfactory-caddy"), ("redeploy-gitea", "Gitea", "fa-code-branch", "appfactory-gitea"), ("redeploy-registry", "Registry", "fa-box-archive", "appfactory-registry"), ] # Spustitelné akce: key -> (label, icon, script_name, args). ALL_CORE_KEY je speciální (smyčka přes core). ACTION_MAP: dict[str, tuple[str, str, str, list[str]]] = {} for _key, _name, _icon, _arg in CORE_SERVICES: ACTION_MAP[_key] = (f"Redeploy {_name}", _icon, DEPLOY_CORE_SCRIPT, [_arg]) ACTION_MAP["regenerate-caddy"] = ("Regenerate Caddy", "fa-shield-halved", CADDY_SCRIPT, []) ACTION_MAP["redeploy-enabled-apps"] = ("Redeploy Enabled Apps", "fa-rocket", ENABLED_APPS_SCRIPT, []) # Pořadí tlačítek po uložení .env (Environment stránka) – dle zadání. ENV_ACTION_KEYS = [ "regenerate-caddy", "redeploy-portal", "redeploy-worker", "redeploy-webhook", "redeploy-monitor", "redeploy-gateway", "redeploy-gitea", "redeploy-registry", ALL_CORE_KEY, ] # Globální tlačítka na Runtime Management stránce (mimo tabulku core služeb). RUNTIME_GLOBAL_ACTION_KEYS = ["regenerate-caddy", "redeploy-enabled-apps", ALL_CORE_KEY] DASHBOARD_LIMIT = 20 def require_admin(user: dict) -> None: if (user.get("role") or "").lower() != "admin": raise HTTPException(status_code=403, detail="Runtime Management je dostupný pouze administrátorům") def _is_valid_action(action_key: str) -> bool: return action_key == ALL_CORE_KEY or action_key in ACTION_MAP def _button_meta(action_key: str) -> tuple[str, str]: if action_key == ALL_CORE_KEY: return ALL_CORE_LABEL, ALL_CORE_ICON label, icon, _script, _args = ACTION_MAP[action_key] return label, icon def enqueue_script_job(script_name: str, args: list[str], user: dict, target_label: str) -> int: """Vytvoří run_script job do existující fronty (worker spustí scripts/ s args) a zaaudituje akci (bez hodnot proměnných). Vrací job_id.""" args = list(args or []) payload = {"script_name": script_name} if args: payload["args"] = args payload["arguments"] = args job_id = create_job( job_type="run_script", target_type="maintenance_script", target_id=script_name, payload=payload, user=user, source="portal_runtime", ) log_audit_event( user, action="runtime.action", target_type="runtime", target_id=target_label, metadata={"script_name": script_name, "args": args, "job_id": job_id}, ) return job_id def run_action(action_key: str, user: dict) -> str: """Spustí runtime akci podle klíče. Vrací zprávu pro uživatele.""" if action_key == ALL_CORE_KEY: job_ids = [enqueue_script_job(DEPLOY_CORE_SCRIPT, [arg], user, arg) for _k, _n, _i, arg in CORE_SERVICES] return f"Redeploy všech core služeb byl zařazen do fronty ({len(job_ids)} úloh)." label, _icon, script, args = ACTION_MAP[action_key] job_id = enqueue_script_job(script, args, user, label) return f"Akce {label} byla zařazena do fronty (úloha #{job_id})." def render_action_buttons(keys: list[str], next_url: str) -> str: next_html = html.escape(next_url, quote=True) buttons = "" for key in keys: label, icon = _button_meta(key) buttons += f"""
""" return buttons def _job_args(job: dict) -> list[str]: try: payload = json.loads(job.get("payload_json") or "{}") except (TypeError, ValueError): return [] args = payload.get("args") or payload.get("arguments") or [] return [str(a) for a in args] if isinstance(args, list) else [] def _job_script_label(job: dict) -> str: target = job.get("target_id") or job.get("type") or "" args = _job_args(job) return f"{target} {' '.join(args)}".strip() def _core_last_runs() -> dict: """Najde poslední deploy-core-service.sh job pro každou core službu (podle argumentu v payloadu).""" latest: dict[str, dict] = {} for job in get_jobs_by_target_ids([DEPLOY_CORE_SCRIPT], limit=80): args = _job_args(job) service = args[0] if args else None if service and service not in latest: latest[service] = job return latest def _render_last_run(job: dict | None) -> str: if not job: return '' job_id = html.escape(str(job.get("id", ""))) created_at = html.escape(job.get("created_at", "") or "") status = render_job_status(job.get("status")) return f'{status}
{created_at} · #{job_id}' @router.get("/admin/runtime", response_class=HTMLResponse) def runtime_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)): require_admin(user) notice = "" if message: notice = f'

{html.escape(message)}

' if error: notice = f'

{html.escape(error)}

' last_runs = _core_last_runs() component_rows = "" for key, name, icon, service_arg in CORE_SERVICES: component_rows += f""" {html.escape(name)} {html.escape(service_arg)} {_render_last_run(last_runs.get(service_arg))}
""" job_rows = "" for job in get_jobs_by_target_ids(RUNTIME_SCRIPTS, limit=DASHBOARD_LIMIT): job_id = html.escape(str(job.get("id", ""))) created_at = html.escape(job.get("created_at", "") or "") script_label = html.escape(_job_script_label(job)) status = render_job_status(job.get("status")) result_text = job.get("error_text") or job.get("result_json") or "" result_preview = result_text if len(result_text) <= 160 else f"{result_text[:157]}..." result_cell = f"{html.escape(result_preview)}" if result_preview else '' job_rows += f""" {created_at} {script_label} {status} {result_cell} """ if not job_rows: job_rows = 'Zatím nebyly spuštěné žádné runtime operace.' return page( "Runtime Management", f"""

Runtime Management

Centrální správa klíčových AppFactory komponent. Akce nespouští docker přímo — vytvoří úlohu do fronty, kterou zpracuje worker (existující shell skripty v appfactory-tools). Dostupné pouze administrátorům.

{notice}

Core služby

Redeploy přes {html.escape(DEPLOY_CORE_SCRIPT)} <service>.

{component_rows}
Komponenta Service Poslední spuštění Akce

Globální akce

{render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")}

Posledních {DASHBOARD_LIMIT} runtime operací

{job_rows}
Čas Skript Stav Výsledek
""", user=user, ) @router.post("/admin/runtime/action/{action_key}") def runtime_action(action_key: str, next: str = Form("/portal/admin/runtime"), user=Depends(require_user)): require_admin(user) if not _is_valid_action(action_key): raise HTTPException(status_code=404, detail="Neznámá akce") message = run_action(action_key, user) redirect_to = next if next.startswith("/portal/admin/") else "/portal/admin/runtime" return RedirectResponse(url=f"{redirect_to}?message=" + quote(message), status_code=303)