282 lines
11 KiB
Python
282 lines
11 KiB
Python
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 <service>. (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/<script_name> 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"""
|
||
<form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
|
||
onsubmit="return confirm('Spustit akci {html.escape(label)}?');">
|
||
<input type="hidden" name="next" value="{next_html}">
|
||
<button type="submit"><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(label)}</button>
|
||
</form>
|
||
"""
|
||
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 '<span class="muted">—</span>'
|
||
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}<br><a href="/portal/jobs/{job_id}"><small>{created_at} · #{job_id}</small></a>'
|
||
|
||
|
||
@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'<p class="alert">{html.escape(message)}</p>'
|
||
if error:
|
||
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
||
|
||
last_runs = _core_last_runs()
|
||
component_rows = ""
|
||
for key, name, icon, service_arg in CORE_SERVICES:
|
||
component_rows += f"""
|
||
<tr>
|
||
<td><strong><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(name)}</strong></td>
|
||
<td><code>{html.escape(service_arg)}</code></td>
|
||
<td>{_render_last_run(last_runs.get(service_arg))}</td>
|
||
<td class="actions-cell">
|
||
<form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
|
||
onsubmit="return confirm('Spustit redeploy služby {html.escape(name)}?');">
|
||
<input type="hidden" name="next" value="/portal/admin/runtime">
|
||
<button type="submit"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Redeploy</button>
|
||
</form>
|
||
</td>
|
||
</tr>
|
||
"""
|
||
|
||
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"<code>{html.escape(result_preview)}</code>" if result_preview else '<span class="muted">—</span>'
|
||
job_rows += f"""
|
||
<tr>
|
||
<td>{created_at}</td>
|
||
<td><a href="/portal/jobs/{job_id}">{script_label}</a></td>
|
||
<td>{status}</td>
|
||
<td>{result_cell}</td>
|
||
</tr>
|
||
"""
|
||
|
||
if not job_rows:
|
||
job_rows = '<tr><td colspan="4">Zatím nebyly spuštěné žádné runtime operace.</td></tr>'
|
||
|
||
return page(
|
||
"Runtime Management",
|
||
f"""
|
||
<div class="card">
|
||
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</h2>
|
||
<p class="muted">
|
||
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.
|
||
</p>
|
||
{notice}
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2><i class="fa-solid fa-cubes" aria-hidden="true"></i> Core služby</h2>
|
||
<p class="muted">Redeploy přes <code>{html.escape(DEPLOY_CORE_SCRIPT)} <service></code>.</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Komponenta</th>
|
||
<th>Service</th>
|
||
<th>Poslední spuštění</th>
|
||
<th>Akce</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>{component_rows}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2><i class="fa-solid fa-bolt" aria-hidden="true"></i> Globální akce</h2>
|
||
<div class="inline-form">
|
||
{render_action_buttons(RUNTIME_GLOBAL_ACTION_KEYS, "/portal/admin/runtime")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2><i class="fa-solid fa-list-check" aria-hidden="true"></i> Posledních {DASHBOARD_LIMIT} runtime operací</h2>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Čas</th>
|
||
<th>Skript</th>
|
||
<th>Stav</th>
|
||
<th>Výsledek</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>{job_rows}</tbody>
|
||
</table>
|
||
</div>
|
||
""",
|
||
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)
|