icons, main .env

This commit is contained in:
JiriUhlir
2026-06-15 14:07:16 +02:00
parent 35d857f653
commit fa4307852c
12 changed files with 773 additions and 220 deletions
+183 -76
View File
@@ -1,37 +1,63 @@
import html
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
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, get_jobs_by_types
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"; worker spustí scripts/<script>).
DEPLOY_CORE_SCRIPT = "deploy-core-service.sh"
CADDY_SCRIPT = "generate-caddyfile.sh"
ENABLED_APPS_SCRIPT = "redeploy-enabled-apps.sh"
# Klíčové AppFactory komponenty, které lze z Portálu znovu nasadit. Pořadí určuje zobrazení v UI.
# key identifikátor komponenty (target_id jobu i suffix job typu / shell skriptu)
# name zobrazený název
# desc krátký popis
# icon FontAwesome ikona
RUNTIME_COMPONENTS = [
("portal", "Portal", "Webové administrační rozhraní AppFactory (tento Portál).", "fa-window-maximize"),
("tools", "Tools", "Sdílené provozní skripty a nástroje (appfactory-tools).", "fa-screwdriver-wrench"),
("webhook", "Webhook", "Příjem a zpracování Gitea webhooků (spouští deploy).", "fa-bolt"),
("worker", "Worker", "Centrální worker zpracovávající úlohy z fronty.", "fa-gears"),
("caddy", "Caddy", "Reverzní proxy a TLS pro Portál i služby.", "fa-shield-halved"),
("gitea", "Gitea", "Git server a registr repozitářů.", "fa-code-branch"),
("registry", "Registry", "Docker registr s image jednotlivých služeb.", "fa-box-archive"),
# 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"),
]
# Mapování komponenty -> typ jobu. Job typy budou později mapovány na maintenance/redeploy-<key>.sh.
COMPONENT_JOB_TYPE = {key: f"redeploy-{key}" for key, _name, _desc, _icon in RUNTIME_COMPONENTS}
RUNTIME_JOB_TYPES = tuple(COMPONENT_JOB_TYPE.values())
COMPONENT_NAMES = {key: name for key, name, _desc, _icon in RUNTIME_COMPONENTS}
# 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
@@ -41,11 +67,97 @@ def require_admin(user: dict) -> None:
raise HTTPException(status_code=403, detail="Runtime Management je dostupný pouze administrátorům")
def _last_action_for(job_type: str) -> str:
jobs = get_jobs(job_type=job_type, limit=1)
if not jobs:
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 = jobs[0]
job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "")
status = render_job_status(job.get("status"))
@@ -62,17 +174,18 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
last_runs = _core_last_runs()
component_rows = ""
for key, name, desc, icon in RUNTIME_COMPONENTS:
last_action = _last_action_for(COMPONENT_JOB_TYPE[key])
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>{html.escape(desc)}</td>
<td>{last_action}</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/redeploy/{key}" class="inline-form"
onsubmit="return confirm('Opravdu chcete redeploy komponenty {html.escape(name)}?');">
<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>
@@ -80,10 +193,10 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
"""
job_rows = ""
for job in get_jobs_by_types(RUNTIME_JOB_TYPES, limit=DASHBOARD_LIMIT):
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 "")
job_type = html.escape(job.get("type", "") 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]}..."
@@ -91,7 +204,7 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
job_rows += f"""
<tr>
<td>{created_at}</td>
<td><a href="/portal/jobs/{job_id}">{job_type}</a></td>
<td><a href="/portal/jobs/{job_id}">{script_label}</a></td>
<td>{status}</td>
<td>{result_cell}</td>
</tr>
@@ -106,35 +219,48 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
<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. Redeploy nespouští akci přímo —
vytvoří úlohu do fronty, kterou zpracuje worker. Dostupné pouze administrátorům.
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>Komponenty</h2>
<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)} &lt;service&gt;</code>.</p>
<table>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Poslední spuštění akce</th>
<th>Akce</th>
</tr>
{component_rows}
<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>Posledních {DASHBOARD_LIMIT} runtime operací</h2>
<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>
<tr>
<th>Čas</th>
<th>Typ</th>
<th>Stav</th>
<th>Výsledek</th>
</tr>
{job_rows}
<thead>
<tr>
<th>Čas</th>
<th>Skript</th>
<th>Stav</th>
<th>Výsledek</th>
</tr>
</thead>
<tbody>{job_rows}</tbody>
</table>
</div>
""",
@@ -142,31 +268,12 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
)
@router.post("/admin/runtime/redeploy/{component}")
def runtime_redeploy_action(component: str, user=Depends(require_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")
if component not in COMPONENT_JOB_TYPE:
raise HTTPException(status_code=404, detail="Neznámá komponenta")
job_type = COMPONENT_JOB_TYPE[component]
name = COMPONENT_NAMES[component]
job_id = create_job(
job_type=job_type,
target_type="runtime",
target_id=component,
payload={"component": component},
user=user,
source="portal_runtime",
)
log_audit_event(
user,
action="runtime.redeploy",
target_type="runtime",
target_id=component,
metadata={"component": component, "job_type": job_type, "job_id": job_id},
)
return RedirectResponse(
url="/portal/admin/runtime?message=" + quote(f"Redeploy komponenty {name} byl zařazen do fronty (úloha #{job_id})."),
status_code=303,
)
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)