icons, main .env
This commit is contained in:
+19
-7
@@ -20,10 +20,11 @@ from app.db.alerting import (
|
||||
from app.db.audit import log_audit_event
|
||||
from app.routes.jobs import pretty_json
|
||||
from app.templates.layout import page
|
||||
from app.tools_repo import TOOLS_REPO_DIR, commit_and_push
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALERTS_DIR = Path("/opt/appfactory/workspace/appfactory-tools/alerts")
|
||||
ALERTS_DIR = TOOLS_REPO_DIR / "alerts"
|
||||
MAX_SCRIPT_BYTES = 100 * 1024
|
||||
EVENT_TYPES = ("incident.opened", "incident.resolved")
|
||||
DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash
|
||||
@@ -325,7 +326,7 @@ def alert_rules_page(request: Request, user=Depends(require_user)):
|
||||
"Alert pravidla",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Alert pravidla</h2>
|
||||
<h2><i class="fa-solid fa-bell" aria-hidden="true"></i> Alert pravidla</h2>
|
||||
<p class="muted">Správa pravidel pro spouštění alert skriptů při událostech incidentů.</p>
|
||||
<p>
|
||||
<a class="btn" href="/portal/alerting/rules/new">+ Nové alert pravidlo</a>
|
||||
@@ -360,7 +361,7 @@ def new_alert_rule_form(request: Request, user=Depends(require_user)):
|
||||
"Nové alert pravidlo",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Nové alert pravidlo</h2>
|
||||
<h2><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Nové alert pravidlo</h2>
|
||||
<p><a class="btn" href="/portal/alerting/rules">← Zpět</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -383,6 +384,10 @@ def create_alert_rule_action(
|
||||
):
|
||||
metadata = form_metadata(name, description, event_type, service_id, script_name, is_enabled)
|
||||
rule_id = create_alert_rule(metadata)
|
||||
created_name = metadata["script_name"]
|
||||
if not script_path(created_name).exists():
|
||||
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
|
||||
commit_and_push(f"alerts/{created_name}", f"Vytvořen alert skript {created_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="alert_rule.created",
|
||||
@@ -407,7 +412,7 @@ def alert_rule_detail(rule_id: int, request: Request, user=Depends(require_user)
|
||||
rule.get("name", "") or "Alert pravidlo",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>{html.escape(rule.get("name", "") or "")}</h2>
|
||||
<h2><i class="fa-solid fa-bell" aria-hidden="true"></i> {html.escape(rule.get("name", "") or "")}</h2>
|
||||
<p>
|
||||
<a class="btn" href="/portal/alerting/rules">← Zpět na alert pravidla</a>
|
||||
<a class="btn btn-secondary" href="/portal/alerting/rules/{rule_id}/edit">Upravit</a>
|
||||
@@ -469,7 +474,7 @@ def edit_alert_rule_form(rule_id: int, request: Request, user=Depends(require_us
|
||||
"Upravit alert pravidlo",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Upravit alert pravidlo</h2>
|
||||
<h2><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit alert pravidlo</h2>
|
||||
<p><a class="btn" href="/portal/alerting/rules/{rule_id}">← Zpět</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -530,6 +535,12 @@ def delete_alert_rule_action(rule_id: int, user=Depends(require_user)):
|
||||
raise HTTPException(status_code=404, detail="Alert pravidlo nenalezeno")
|
||||
if not delete_alert_rule(rule_id):
|
||||
raise HTTPException(status_code=409, detail="Alert pravidlo nelze smazat")
|
||||
script_name = clean_optional(rule.get("script_name"))
|
||||
if script_name:
|
||||
path = script_path(script_name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
commit_and_push(f"alerts/{script_name}", f"Smazán alert skript {script_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="alert_rule.deleted",
|
||||
@@ -549,6 +560,7 @@ def update_alert_script(rule_id: int, content: str = Form(...), user=Depends(req
|
||||
raise HTTPException(status_code=404, detail="Alert pravidlo nenalezeno")
|
||||
script_name = validate_script_name(rule.get("script_name", "") or "")
|
||||
save_script_file(script_name, content)
|
||||
commit_and_push(f"alerts/{script_name}", f"Úprava alert skriptu {script_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="alert_script.updated",
|
||||
@@ -566,7 +578,7 @@ def alert_events_page(request: Request, user=Depends(require_user)):
|
||||
"Alert eventy",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Alert eventy</h2>
|
||||
<h2><i class="fa-solid fa-bell-concierge" aria-hidden="true"></i> Alert eventy</h2>
|
||||
<p class="muted">Historie vyvolaných alertů a jejich zpracování.</p>
|
||||
<p><a class="btn" href="/portal/alerting/rules">Alert pravidla</a></p>
|
||||
</div>
|
||||
@@ -617,7 +629,7 @@ def alert_event_detail(event_id: int, request: Request, user=Depends(require_use
|
||||
f"Alert event #{event_id}",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Alert event #{html.escape(str(event_id))}</h2>
|
||||
<h2><i class="fa-solid fa-bell-concierge" aria-hidden="true"></i> Alert event #{html.escape(str(event_id))}</h2>
|
||||
<p>
|
||||
<a class="btn" href="/portal/alerting/events">← Zpět na alert eventy</a>
|
||||
<a class="btn btn-secondary" href="/portal/alerting/rules">Alert pravidla</a>
|
||||
|
||||
@@ -24,7 +24,9 @@ PORTAL_SECTIONS = [
|
||||
("fa-gauge-high", "Přehled", "/portal/operations",
|
||||
"Operační přehled systému, běžící nasazení a stav služeb.", "admin"),
|
||||
("fa-server", "Runtime Management", "/portal/admin/runtime",
|
||||
"Redeploy klíčových komponent (Portal, Tools, Webhook, Worker, Caddy, Gitea, Registry) přes job frontu.", "admin"),
|
||||
"Redeploy core služeb (Portal, Worker, Webhook, Monitor, Gateway, Gitea, Registry) přes deploy-core-service.sh v job frontě.", "admin"),
|
||||
("fa-sliders", "Environment", "/portal/admin/environment",
|
||||
"Bezpečná úprava hlavního appfactory.env (backup, validace) + navazující restart/regenerate akce přes job frontu.", "admin"),
|
||||
("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness",
|
||||
"Připravenost a deploy core služeb AppFactory.", "admin"),
|
||||
("fa-rocket", "Nasazení", "/portal/deployments",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import html
|
||||
import os
|
||||
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.env_file import ENV_PATH, KEY_RE, read_entries, save_entries
|
||||
from app.routes.runtime import ENV_ACTION_KEYS, render_action_buttons
|
||||
from app.templates.layout import page
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def require_admin(user: dict) -> None:
|
||||
if (user.get("role") or "").lower() != "admin":
|
||||
raise HTTPException(status_code=403, detail="Environment je dostupný pouze administrátorům")
|
||||
|
||||
|
||||
def _render_rows(entries: list[tuple[str, str]]) -> str:
|
||||
rows = ""
|
||||
for key, value in entries:
|
||||
key_html = html.escape(key, quote=True)
|
||||
value_html = html.escape(value, quote=True)
|
||||
rows += f"""
|
||||
<tr>
|
||||
<td><input name="key" value="{key_html}" class="env-key" readonly></td>
|
||||
<td><input name="value" value="{value_html}" class="env-value"></td>
|
||||
<td class="actions-cell">
|
||||
<button type="button" class="btn-secondary" onclick="this.closest('tr').remove()">Smazat</button>
|
||||
</td>
|
||||
</tr>
|
||||
"""
|
||||
return rows
|
||||
|
||||
|
||||
def _render_page(user: dict, entries: list[tuple[str, str]], message: str = "", error: str = "") -> str:
|
||||
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>'
|
||||
|
||||
action_buttons = render_action_buttons(ENV_ACTION_KEYS, "/portal/admin/environment")
|
||||
|
||||
return page(
|
||||
"Environment",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Environment</h2>
|
||||
<p class="muted">
|
||||
Úprava hlavní AppFactory konfigurace <code>{html.escape(ENV_PATH)}</code>.
|
||||
Zdroj pravdy zůstává tento soubor. Při uložení se vytvoří časově označený backup.
|
||||
Dostupné pouze administrátorům.
|
||||
</p>
|
||||
{notice}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Proměnné</h2>
|
||||
<form method="post" action="/portal/admin/environment">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 30%">Klíč</th>
|
||||
<th>Hodnota</th>
|
||||
<th style="width: 1%">Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="env-rows">
|
||||
{_render_rows(entries)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" onclick="envAddRow()"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat klíč</button>
|
||||
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit</button>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Klíč musí odpovídat <code>^[A-Z_][A-Z0-9_]*$</code>. Prázdné hodnoty jsou povolené,
|
||||
duplicitní klíče ne. Hodnoty s mezerami se uloží v uvozovkách.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Jak se změny projeví</h2>
|
||||
<ol>
|
||||
<li>Proměnné používané shell skripty: stačí znovu spustit příslušný skript.</li>
|
||||
<li>Portal / Worker / Webhook / Monitor: je potřeba restart kontejnerů (tlačítka níže).</li>
|
||||
<li>Domény, HTTPS a Caddy hodnoty: spusťte <code>generate-caddyfile.sh</code> (Regenerate Caddy).</li>
|
||||
<li>UID/GID proměnné: je potřeba opatrný restart/redeploy celého stacku.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Navazující akce</h2>
|
||||
<p class="muted">Akce nevolají docker přímo — vytvoří úlohu do fronty (zpracuje worker). Historii najdete v
|
||||
<a href="/portal/admin/runtime">Runtime Management</a> a <a href="/portal/jobs">Úlohách</a>.</p>
|
||||
<div class="inline-form">
|
||||
{action_buttons}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function envAddRow() {{
|
||||
const tbody = document.getElementById("env-rows");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = '<td><input name="key" class="env-key" placeholder="NOVY_KLIC"></td>'
|
||||
+ '<td><input name="value" class="env-value" placeholder="hodnota"></td>'
|
||||
+ '<td class="actions-cell"><button type="button" class="btn-secondary" onclick="this.closest(\\'tr\\').remove()">Smazat</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
}}
|
||||
</script>
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/environment", response_class=HTMLResponse)
|
||||
def environment_page(request: Request, message: str = "", error: str = "", user=Depends(require_user)):
|
||||
require_admin(user)
|
||||
return _render_page(user, read_entries(), message=message, error=error)
|
||||
|
||||
|
||||
@router.post("/admin/environment", response_class=HTMLResponse)
|
||||
def environment_save(
|
||||
request: Request,
|
||||
key: list[str] = Form(default=[]),
|
||||
value: list[str] = Form(default=[]),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
require_admin(user)
|
||||
|
||||
# Sestav uspořádaný seznam dvojic; prázdné řádky (bez klíče i hodnoty) ignoruj.
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for raw_key, raw_value in zip(key, value):
|
||||
k = (raw_key or "").strip()
|
||||
v = (raw_value or "").replace("\r", "").replace("\n", " ")
|
||||
if not k and not v:
|
||||
continue
|
||||
pairs.append((k, v))
|
||||
|
||||
# Validace: klíč podle regexu, žádné duplicity.
|
||||
invalid_keys = sorted({k for k, _ in pairs if not KEY_RE.match(k)})
|
||||
seen: set[str] = set()
|
||||
duplicate_keys = sorted({k for k, _ in pairs if k in seen or seen.add(k)})
|
||||
|
||||
if invalid_keys or duplicate_keys:
|
||||
problems = []
|
||||
if invalid_keys:
|
||||
problems.append("neplatné klíče: " + ", ".join(invalid_keys))
|
||||
if duplicate_keys:
|
||||
problems.append("duplicitní klíče: " + ", ".join(duplicate_keys))
|
||||
error = "Změny nebyly uloženy — " + "; ".join(problems) + "."
|
||||
# Re-render z odeslaných dat, ať admin nepřijde o rozdělanou editaci.
|
||||
return _render_page(user, pairs, error=error)
|
||||
|
||||
# Spočítej změněné klíče (pro audit – bez hodnot).
|
||||
original = dict(read_entries())
|
||||
new_map = dict(pairs)
|
||||
added = set(new_map) - set(original)
|
||||
removed = set(original) - set(new_map)
|
||||
modified = {k for k in set(new_map) & set(original) if new_map[k] != original[k]}
|
||||
changed_keys = sorted(added | removed | modified)
|
||||
|
||||
if not changed_keys:
|
||||
return RedirectResponse(
|
||||
url="/portal/admin/environment?message=" + quote("Žádné změny k uložení."),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
try:
|
||||
backup_path = save_entries(pairs)
|
||||
except OSError as exc:
|
||||
return _render_page(user, pairs, error=f"Soubor se nepodařilo uložit: {exc.strerror or exc}")
|
||||
|
||||
log_audit_event(
|
||||
user,
|
||||
action="environment.updated",
|
||||
target_type="environment",
|
||||
target_id=os.path.basename(ENV_PATH),
|
||||
metadata={"changed_keys": changed_keys, "backup": os.path.basename(backup_path) if backup_path else None},
|
||||
)
|
||||
|
||||
backup_note = f" Backup: {os.path.basename(backup_path)}." if backup_path else ""
|
||||
message = (
|
||||
f"Uloženo {len(changed_keys)} změněných klíčů.{backup_note} "
|
||||
"Změny se projeví podle typu proměnné různě – viz doporučené akce níže."
|
||||
)
|
||||
return RedirectResponse(
|
||||
url="/portal/admin/environment?message=" + quote(message),
|
||||
status_code=303,
|
||||
)
|
||||
+183
-76
@@ -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)} <service></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)
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import html
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth import require_user
|
||||
from app.config import (
|
||||
DEFAULT_GITEA_ORG,
|
||||
get_gitea_admin_token,
|
||||
get_gitea_server_url,
|
||||
read_env_value,
|
||||
)
|
||||
from app.db.audit import log_audit_event
|
||||
from app.db.jobs import create_job
|
||||
from app.db.scheduled_scripts import (
|
||||
@@ -25,13 +18,10 @@ from app.db.scheduled_scripts import (
|
||||
)
|
||||
from app.routes.deployments import render_status_pill
|
||||
from app.templates.layout import page
|
||||
from app.tools_repo import TOOLS_REPO_DIR, commit_and_push
|
||||
|
||||
router = APIRouter()
|
||||
TOOLS_REPO_DIR = Path("/opt/appfactory/workspace/appfactory-tools")
|
||||
TOOLS_REPO_NAME = "appfactory-tools"
|
||||
MAINTENANCE_DIR = TOOLS_REPO_DIR / "maintenance"
|
||||
GIT_AUTHOR_NAME = "AppFactory Portal"
|
||||
GIT_AUTHOR_EMAIL = "portal@appfactory.local"
|
||||
MAX_SCRIPT_BYTES = 100 * 1024
|
||||
DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -153,71 +143,6 @@ def save_script_file(script_name: str, content: str) -> None:
|
||||
raise HTTPException(status_code=500, detail="Soubor se nepodařilo uložit")
|
||||
|
||||
|
||||
def _git(args: list[str]):
|
||||
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(TOOLS_REPO_DIR), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
raise HTTPException(status_code=500, detail="Git příkaz selhal nebo vypršel limit")
|
||||
|
||||
|
||||
def _git_actor(user: dict) -> str:
|
||||
return (user.get("email") or user.get("display_name") or user.get("username") or "neznámý").strip()
|
||||
|
||||
|
||||
def _gitea_push_remote() -> tuple[str, str | None]:
|
||||
"""Vrátí (remote, token). Remote je autentizovaná gitea URL z tokenů v proměnných,
|
||||
při absenci tokenu fallback na origin. Token vracíme zvlášť, aby šel zamaskovat v chybách."""
|
||||
token = get_gitea_admin_token()
|
||||
server = get_gitea_server_url()
|
||||
if not token or "://" not in server:
|
||||
return "origin", None
|
||||
scheme, rest = server.split("://", 1)
|
||||
org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
|
||||
return f"{scheme}://{token}@{rest}/{org}/{TOOLS_REPO_NAME}.git", token
|
||||
|
||||
|
||||
def _git_error_detail(result, token: str | None = None) -> str:
|
||||
"""Zkombinuje git stderr/stdout do čitelného důvodu chyby (s maskováním tokenu)."""
|
||||
detail = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
if token and detail:
|
||||
detail = detail.replace(token, "***")
|
||||
return html.escape(detail) if detail else "git nevrátil žádný výstup"
|
||||
|
||||
|
||||
def commit_and_push(script_name: str, message: str, user: dict) -> None:
|
||||
"""Zacommituje a pushne změnu jednoho maintenance skriptu do gitea (appfactory-tools)."""
|
||||
rel_path = f"maintenance/{script_name}"
|
||||
|
||||
add = _git(["add", "--", rel_path])
|
||||
if add.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Git add selhal (v {TOOLS_REPO_DIR}): {_git_error_detail(add)}")
|
||||
|
||||
# Žádná změna oproti HEAD -> přeskočíme, ať nevznikají prázdné commity.
|
||||
if _git(["diff", "--cached", "--quiet", "--", rel_path]).returncode == 0:
|
||||
return
|
||||
|
||||
commit = _git([
|
||||
"-c", f"user.name={GIT_AUTHOR_NAME}",
|
||||
"-c", f"user.email={GIT_AUTHOR_EMAIL}",
|
||||
"commit", "-m", f"{message} (portál: {_git_actor(user)})",
|
||||
])
|
||||
if commit.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Git commit selhal (v {TOOLS_REPO_DIR}): {_git_error_detail(commit)}")
|
||||
|
||||
branch = (_git(["rev-parse", "--abbrev-ref", "HEAD"]).stdout or "").strip() or "main"
|
||||
remote, token = _gitea_push_remote()
|
||||
push = _git(["push", remote, f"HEAD:{branch}"])
|
||||
if push.returncode != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Git push selhal: {_git_error_detail(push, token)}")
|
||||
|
||||
|
||||
def validate_schedule_type(value: str) -> str:
|
||||
schedule_type = clean_optional(value)
|
||||
if schedule_type not in SCHEDULE_TYPES:
|
||||
@@ -410,7 +335,7 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
|
||||
"Plánované skripty",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Plánované skripty</h2>
|
||||
<h2><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</h2>
|
||||
<p class="muted">Přehled skriptů spouštěných schedulerem nebo ručně z portálu.</p>
|
||||
<p><a class="btn" href="/portal/scheduled-scripts/new">+ Nový plánovaný skript</a></p>
|
||||
</div>
|
||||
@@ -444,7 +369,7 @@ def new_scheduled_script_form(request: Request, user=Depends(require_user)):
|
||||
"Nový plánovaný skript",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Nový plánovaný skript</h2>
|
||||
<h2><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Nový plánovaný skript</h2>
|
||||
<p><a class="btn" href="/portal/scheduled-scripts">← Zpět</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -472,7 +397,7 @@ def create_scheduled_script_action(
|
||||
created_name = metadata["script_name"]
|
||||
if not script_path(created_name).exists():
|
||||
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
|
||||
commit_and_push(created_name, f"Vytvořen skript {created_name}", user)
|
||||
commit_and_push(f"maintenance/{created_name}", f"Vytvořen skript {created_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="scheduled_script.created",
|
||||
@@ -510,7 +435,7 @@ def scheduled_script_detail(script_id: int, request: Request, user=Depends(requi
|
||||
html.escape(script.get("name", "") or "Plánovaný skript"),
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>{html.escape(script.get("name", "") or "")}</h2>
|
||||
<h2><i class="fa-solid fa-calendar-day" aria-hidden="true"></i> {html.escape(script.get("name", "") or "")}</h2>
|
||||
<p>
|
||||
<a class="btn" href="/portal/scheduled-scripts">← Zpět na plánované skripty</a>
|
||||
<a class="btn btn-secondary" href="/portal/scheduled-scripts/{script_id}/edit">Upravit</a>
|
||||
@@ -560,7 +485,7 @@ def edit_scheduled_script_form(script_id: int, request: Request, user=Depends(re
|
||||
"Upravit plánovaný skript",
|
||||
f"""
|
||||
<div class="card">
|
||||
<h2>Upravit plánovaný skript</h2>
|
||||
<h2><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit plánovaný skript</h2>
|
||||
<p><a class="btn" href="/portal/scheduled-scripts/{script_id}">← Zpět</a></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -637,7 +562,7 @@ def update_scheduled_script_file(
|
||||
raise HTTPException(status_code=404, detail="Scheduled script not found")
|
||||
script_name = validate_script_name(script.get("script_name", "") or "")
|
||||
save_script_file(script_name, content)
|
||||
commit_and_push(script_name, f"Úprava skriptu {script_name}", user)
|
||||
commit_and_push(f"maintenance/{script_name}", f"Úprava skriptu {script_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="scheduled_script.file_updated",
|
||||
@@ -682,7 +607,7 @@ def delete_scheduled_script_action(script_id: int, user=Depends(require_user)):
|
||||
path = script_path(script_name)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
commit_and_push(script_name, f"Smazán skript {script_name}", user)
|
||||
commit_and_push(f"maintenance/{script_name}", f"Smazán skript {script_name}", user)
|
||||
log_audit_event(
|
||||
user,
|
||||
action="scheduled_script.deleted",
|
||||
|
||||
Reference in New Issue
Block a user