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
+6 -6
View File
@@ -292,23 +292,23 @@ def get_jobs(
return [dict(row) for row in rows] return [dict(row) for row in rows]
def get_jobs_by_types(job_types, limit: int = 20): def get_jobs_by_target_ids(target_ids, limit: int = 20):
run_migrations() run_migrations()
job_types = list(job_types) target_ids = list(target_ids)
if not job_types: if not target_ids:
return [] return []
con = get_connection() con = get_connection()
placeholders = ",".join("?" for _ in job_types) placeholders = ",".join("?" for _ in target_ids)
rows = con.execute( rows = con.execute(
f""" f"""
SELECT * SELECT *
FROM jobs FROM jobs
WHERE type IN ({placeholders}) WHERE target_id IN ({placeholders})
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
""", """,
(*job_types, limit), (*target_ids, limit),
).fetchall() ).fetchall()
con.close() con.close()
+142
View File
@@ -0,0 +1,142 @@
"""Bezpečné čtení a zápis hlavního AppFactory env souboru (/opt/appfactory/config/appfactory.env).
Zdroj pravdy zůstává soubor na disku — nikdy se neukládá do DB. Modul pracuje výhradně
s pevnou cestou APPFACTORY_ENV; žádná jiná cesta není povolená.
"""
import os
import re
import shutil
from datetime import datetime
from app.config import APPFACTORY_ENV
ENV_PATH = APPFACTORY_ENV
# Povolený tvar klíče (shell env konvence).
KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
# Hodnota, kterou lze zapsat bez uvozovek (žádné mezery ani shell-speciální znaky).
_SIMPLE_VALUE_RE = re.compile(r"^[A-Za-z0-9_./:@%+,=-]*$")
def _parse_line(line: str):
"""Vrátí (key, value) pro řádek typu KEY=value, jinak None (komentář/prázdné/neparsovatelné)."""
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
return None
key, _, raw_value = line.partition("=")
key = key.strip()
if not KEY_RE.match(key):
return None
return key, _parse_value(raw_value)
def _unescape_double(value: str) -> str:
out = []
i = 0
while i < len(value):
char = value[i]
if char == "\\" and i + 1 < len(value) and value[i + 1] in '"\\$`':
out.append(value[i + 1])
i += 2
else:
out.append(char)
i += 1
return "".join(out)
def _parse_value(raw: str) -> str:
value = raw.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
return _unescape_double(value[1:-1])
if len(value) >= 2 and value[0] == "'" and value[-1] == "'":
return value[1:-1]
return value
def format_value(value: str) -> str:
"""Naformátuje hodnotu pro shell env soubor — prosté hodnoty bez uvozovek,
hodnoty s mezerami/speciálními znaky v uvozovkách s bezpečným escapem."""
if value == "":
return ""
if _SIMPLE_VALUE_RE.match(value):
return value
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("$", "\\$")
.replace("`", "\\`")
)
return f'"{escaped}"'
def read_entries() -> list[tuple[str, str]]:
"""Načte uspořádaný seznam (key, value) ze souboru. Duplicitní klíče bere jen poprvé."""
try:
with open(ENV_PATH, "r", encoding="utf-8") as handle:
lines = handle.readlines()
except FileNotFoundError:
return []
entries: list[tuple[str, str]] = []
seen: set[str] = set()
for line in lines:
parsed = _parse_line(line)
if not parsed:
continue
key, value = parsed
if key in seen:
continue
seen.add(key)
entries.append((key, value))
return entries
def save_entries(desired_pairs: list[tuple[str, str]]) -> str | None:
"""Zapíše nový obsah souboru z požadovaného uspořádaného seznamu (key, value).
Zachová komentáře, prázdné řádky a původní pořadí klíčů. Smazané klíče vypustí,
nové přidá na konec. Před zápisem vytvoří časově označený backup. Vrací cestu backupu.
"""
desired_map = dict(desired_pairs)
desired_order = [key for key, _ in desired_pairs]
try:
with open(ENV_PATH, "r", encoding="utf-8") as handle:
original_lines = handle.readlines()
except FileNotFoundError:
original_lines = []
out_lines: list[str] = []
emitted: set[str] = set()
for line in original_lines:
parsed = _parse_line(line)
if not parsed:
out_lines.append(line.rstrip("\n"))
continue
key, _value = parsed
if key not in desired_map or key in emitted:
continue # smazaný klíč nebo duplicitní původní řádek
out_lines.append(f"{key}={format_value(desired_map[key])}")
emitted.add(key)
for key in desired_order:
if key not in emitted:
out_lines.append(f"{key}={format_value(desired_map[key])}")
emitted.add(key)
content = "\n".join(out_lines) + "\n"
backup_path = None
if os.path.exists(ENV_PATH):
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = f"{ENV_PATH}.bak-{timestamp}"
shutil.copy2(ENV_PATH, backup_path)
tmp_path = f"{ENV_PATH}.tmp-{os.getpid()}"
with open(tmp_path, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
os.replace(tmp_path, ENV_PATH)
return backup_path
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .config import read_env_bool, read_env_value from .config import read_env_bool, read_env_value
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, health, incidents, jobs, migration_readiness, operations, runtime, scheduled_scripts, users, workers from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, migration_readiness, operations, runtime, scheduled_scripts, users, workers
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -39,6 +39,7 @@ def create_app() -> FastAPI:
app.include_router(users.router) app.include_router(users.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(runtime.router) app.include_router(runtime.router)
app.include_router(environment.router)
return app return app
+19 -7
View File
@@ -20,10 +20,11 @@ from app.db.alerting import (
from app.db.audit import log_audit_event from app.db.audit import log_audit_event
from app.routes.jobs import pretty_json from app.routes.jobs import pretty_json
from app.templates.layout import page from app.templates.layout import page
from app.tools_repo import TOOLS_REPO_DIR, commit_and_push
router = APIRouter() router = APIRouter()
ALERTS_DIR = Path("/opt/appfactory/workspace/appfactory-tools/alerts") ALERTS_DIR = TOOLS_REPO_DIR / "alerts"
MAX_SCRIPT_BYTES = 100 * 1024 MAX_SCRIPT_BYTES = 100 * 1024
EVENT_TYPES = ("incident.opened", "incident.resolved") EVENT_TYPES = ("incident.opened", "incident.resolved")
DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash
@@ -325,7 +326,7 @@ def alert_rules_page(request: Request, user=Depends(require_user)):
"Alert pravidla", "Alert pravidla",
f""" f"""
<div class="card"> <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 class="muted">Správa pravidel pro spouštění alert skriptů při událostech incidentů.</p>
<p> <p>
<a class="btn" href="/portal/alerting/rules/new">+ Nové alert pravidlo</a> <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", "Nové alert pravidlo",
f""" f"""
<div class="card"> <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">&larr; Zpět</a></p> <p><a class="btn" href="/portal/alerting/rules">&larr; Zpět</a></p>
</div> </div>
<div class="card"> <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) metadata = form_metadata(name, description, event_type, service_id, script_name, is_enabled)
rule_id = create_alert_rule(metadata) 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( log_audit_event(
user, user,
action="alert_rule.created", 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", rule.get("name", "") or "Alert pravidlo",
f""" f"""
<div class="card"> <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> <p>
<a class="btn" href="/portal/alerting/rules">&larr; Zpět na alert pravidla</a> <a class="btn" href="/portal/alerting/rules">&larr; Zpět na alert pravidla</a>
<a class="btn btn-secondary" href="/portal/alerting/rules/{rule_id}/edit">Upravit</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", "Upravit alert pravidlo",
f""" f"""
<div class="card"> <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}">&larr; Zpět</a></p> <p><a class="btn" href="/portal/alerting/rules/{rule_id}">&larr; Zpět</a></p>
</div> </div>
<div class="card"> <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") raise HTTPException(status_code=404, detail="Alert pravidlo nenalezeno")
if not delete_alert_rule(rule_id): if not delete_alert_rule(rule_id):
raise HTTPException(status_code=409, detail="Alert pravidlo nelze smazat") 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( log_audit_event(
user, user,
action="alert_rule.deleted", 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") raise HTTPException(status_code=404, detail="Alert pravidlo nenalezeno")
script_name = validate_script_name(rule.get("script_name", "") or "") script_name = validate_script_name(rule.get("script_name", "") or "")
save_script_file(script_name, content) save_script_file(script_name, content)
commit_and_push(f"alerts/{script_name}", f"Úprava alert skriptu {script_name}", user)
log_audit_event( log_audit_event(
user, user,
action="alert_script.updated", action="alert_script.updated",
@@ -566,7 +578,7 @@ def alert_events_page(request: Request, user=Depends(require_user)):
"Alert eventy", "Alert eventy",
f""" f"""
<div class="card"> <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 class="muted">Historie vyvolaných alertů a jejich zpracování.</p>
<p><a class="btn" href="/portal/alerting/rules">Alert pravidla</a></p> <p><a class="btn" href="/portal/alerting/rules">Alert pravidla</a></p>
</div> </div>
@@ -617,7 +629,7 @@ def alert_event_detail(event_id: int, request: Request, user=Depends(require_use
f"Alert event #{event_id}", f"Alert event #{event_id}",
f""" f"""
<div class="card"> <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> <p>
<a class="btn" href="/portal/alerting/events">&larr; Zpět na alert eventy</a> <a class="btn" href="/portal/alerting/events">&larr; Zpět na alert eventy</a>
<a class="btn btn-secondary" href="/portal/alerting/rules">Alert pravidla</a> <a class="btn btn-secondary" href="/portal/alerting/rules">Alert pravidla</a>
+3 -1
View File
@@ -24,7 +24,9 @@ PORTAL_SECTIONS = [
("fa-gauge-high", "Přehled", "/portal/operations", ("fa-gauge-high", "Přehled", "/portal/operations",
"Operační přehled systému, běžící nasazení a stav služeb.", "admin"), "Operační přehled systému, běžící nasazení a stav služeb.", "admin"),
("fa-server", "Runtime Management", "/portal/admin/runtime", ("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", ("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness",
"Připravenost a deploy core služeb AppFactory.", "admin"), "Připravenost a deploy core služeb AppFactory.", "admin"),
("fa-rocket", "Nasazení", "/portal/deployments", ("fa-rocket", "Nasazení", "/portal/deployments",
+195
View File
@@ -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
View File
@@ -1,37 +1,63 @@
import html import html
import json
from urllib.parse import quote 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 fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user from app.auth import require_user
from app.db.audit import log_audit_event 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.routes.jobs import render_job_status
from app.templates.layout import page from app.templates.layout import page
router = APIRouter() 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. # Skripty považované za "runtime operace" (filtr dashboardu).
# key identifikátor komponenty (target_id jobu i suffix job typu / shell skriptu) RUNTIME_SCRIPTS = (DEPLOY_CORE_SCRIPT, CADDY_SCRIPT, ENABLED_APPS_SCRIPT)
# name zobrazený název
# desc krátký popis ALL_CORE_KEY = "redeploy-all-core"
# icon FontAwesome ikona ALL_CORE_LABEL = "Redeploy All Core Services"
RUNTIME_COMPONENTS = [ ALL_CORE_ICON = "fa-server"
("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"), # Core služby nasazované přes deploy-core-service.sh <service>. (key, name, icon, service_arg)
("webhook", "Webhook", "Příjem a zpracování Gitea webhooků (spouští deploy).", "fa-bolt"), CORE_SERVICES = [
("worker", "Worker", "Centrální worker zpracovávající úlohy z fronty.", "fa-gears"), ("redeploy-portal", "Portal", "fa-window-maximize", "appfactory-portal"),
("caddy", "Caddy", "Reverzní proxy a TLS pro Portál i služby.", "fa-shield-halved"), ("redeploy-worker", "Worker", "fa-gears", "appfactory-worker"),
("gitea", "Gitea", "Git server a registr repozitářů.", "fa-code-branch"), ("redeploy-webhook", "Webhook", "fa-bolt", "appfactory-webhook"),
("registry", "Registry", "Docker registr s image jednotlivých služeb.", "fa-box-archive"), ("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. # Spustitelné akce: key -> (label, icon, script_name, args). ALL_CORE_KEY je speciální (smyčka přes core).
COMPONENT_JOB_TYPE = {key: f"redeploy-{key}" for key, _name, _desc, _icon in RUNTIME_COMPONENTS} ACTION_MAP: dict[str, tuple[str, str, str, list[str]]] = {}
RUNTIME_JOB_TYPES = tuple(COMPONENT_JOB_TYPE.values()) for _key, _name, _icon, _arg in CORE_SERVICES:
COMPONENT_NAMES = {key: name for key, name, _desc, _icon in RUNTIME_COMPONENTS} 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 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") raise HTTPException(status_code=403, detail="Runtime Management je dostupný pouze administrátorům")
def _last_action_for(job_type: str) -> str: def _is_valid_action(action_key: str) -> bool:
jobs = get_jobs(job_type=job_type, limit=1) return action_key == ALL_CORE_KEY or action_key in ACTION_MAP
if not jobs:
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>' return '<span class="muted">—</span>'
job = jobs[0]
job_id = html.escape(str(job.get("id", ""))) job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "") created_at = html.escape(job.get("created_at", "") or "")
status = render_job_status(job.get("status")) status = render_job_status(job.get("status"))
@@ -62,17 +174,18 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
if error: if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>' notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
last_runs = _core_last_runs()
component_rows = "" component_rows = ""
for key, name, desc, icon in RUNTIME_COMPONENTS: for key, name, icon, service_arg in CORE_SERVICES:
last_action = _last_action_for(COMPONENT_JOB_TYPE[key])
component_rows += f""" component_rows += f"""
<tr> <tr>
<td><strong><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(name)}</strong></td> <td><strong><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(name)}</strong></td>
<td>{html.escape(desc)}</td> <td><code>{html.escape(service_arg)}</code></td>
<td>{last_action}</td> <td>{_render_last_run(last_runs.get(service_arg))}</td>
<td class="actions-cell"> <td class="actions-cell">
<form method="post" action="/portal/admin/runtime/redeploy/{key}" class="inline-form" <form method="post" action="/portal/admin/runtime/action/{key}" class="inline-form"
onsubmit="return confirm('Opravdu chcete redeploy komponenty {html.escape(name)}?');"> 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> <button type="submit"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Redeploy</button>
</form> </form>
</td> </td>
@@ -80,10 +193,10 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
""" """
job_rows = "" 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", ""))) job_id = html.escape(str(job.get("id", "")))
created_at = html.escape(job.get("created_at", "") or "") 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")) status = render_job_status(job.get("status"))
result_text = job.get("error_text") or job.get("result_json") or "" 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_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""" job_rows += f"""
<tr> <tr>
<td>{created_at}</td> <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>{status}</td>
<td>{result_cell}</td> <td>{result_cell}</td>
</tr> </tr>
@@ -106,35 +219,48 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
<div class="card"> <div class="card">
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</h2> <h2><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</h2>
<p class="muted"> <p class="muted">
Centrální správa klíčových AppFactory komponent. Redeploy nespouští akci přímo — Centrální správa klíčových AppFactory komponent. Akce nespouští docker přímo —
vytvoří úlohu do fronty, kterou zpracuje worker. Dostupné pouze administrátorům. vytvoří úlohu do fronty, kterou zpracuje worker (existující shell skripty v appfactory-tools).
Dostupné pouze administrátorům.
</p> </p>
{notice} {notice}
</div> </div>
<div class="card"> <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> <table>
<tr> <thead>
<th>Název</th> <tr>
<th>Popis</th> <th>Komponenta</th>
<th>Poslední spuštění akce</th> <th>Service</th>
<th>Akce</th> <th>Poslední spuštění</th>
</tr> <th>Akce</th>
{component_rows} </tr>
</thead>
<tbody>{component_rows}</tbody>
</table> </table>
</div> </div>
<div class="card"> <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> <table>
<tr> <thead>
<th>Čas</th> <tr>
<th>Typ</th> <th>Čas</th>
<th>Stav</th> <th>Skript</th>
<th>Výsledek</th> <th>Stav</th>
</tr> <th>Výsledek</th>
{job_rows} </tr>
</thead>
<tbody>{job_rows}</tbody>
</table> </table>
</div> </div>
""", """,
@@ -142,31 +268,12 @@ def runtime_page(request: Request, message: str = "", error: str = "", user=Depe
) )
@router.post("/admin/runtime/redeploy/{component}") @router.post("/admin/runtime/action/{action_key}")
def runtime_redeploy_action(component: str, user=Depends(require_user)): def runtime_action(action_key: str, next: str = Form("/portal/admin/runtime"), user=Depends(require_user)):
require_admin(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: message = run_action(action_key, user)
raise HTTPException(status_code=404, detail="Neznámá komponenta") redirect_to = next if next.startswith("/portal/admin/") else "/portal/admin/runtime"
return RedirectResponse(url=f"{redirect_to}?message=" + quote(message), status_code=303)
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,
)
+8 -83
View File
@@ -1,18 +1,11 @@
import html import html
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user 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.audit import log_audit_event
from app.db.jobs import create_job from app.db.jobs import create_job
from app.db.scheduled_scripts import ( 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.routes.deployments import render_status_pill
from app.templates.layout import page from app.templates.layout import page
from app.tools_repo import TOOLS_REPO_DIR, commit_and_push
router = APIRouter() router = APIRouter()
TOOLS_REPO_DIR = Path("/opt/appfactory/workspace/appfactory-tools")
TOOLS_REPO_NAME = "appfactory-tools"
MAINTENANCE_DIR = TOOLS_REPO_DIR / "maintenance" MAINTENANCE_DIR = TOOLS_REPO_DIR / "maintenance"
GIT_AUTHOR_NAME = "AppFactory Portal"
GIT_AUTHOR_EMAIL = "portal@appfactory.local"
MAX_SCRIPT_BYTES = 100 * 1024 MAX_SCRIPT_BYTES = 100 * 1024
DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash DEFAULT_SCRIPT_CONTENT = """#!/usr/bin/env bash
set -euo pipefail 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&rcaron;ilo ulo&zcaron;it") raise HTTPException(status_code=500, detail="Soubor se nepoda&rcaron;ilo ulo&zcaron;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&rcaron;&iacute;kaz selhal nebo vypr&scaron;el limit")
def _git_actor(user: dict) -> str:
return (user.get("email") or user.get("display_name") or user.get("username") or "nezn&aacute;m&yacute;").strip()
def _gitea_push_remote() -> tuple[str, str | None]:
"""Vr&aacute;t&iacute; (remote, token). Remote je autentizovan&aacute; gitea URL z token&uring; v prom&ecaron;nn&yacute;ch,
p&rcaron;i absenci tokenu fallback na origin. Token vrac&iacute;me zvl&aacute;&scaron;&tcaron;, aby &scaron;el zamaskovat v chyb&aacute;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 &ccaron;iteln&eacute;ho d&uring;vodu chyby (s maskov&aacute;n&iacute;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&aacute;til &zcaron;&aacute;dn&yacute; v&yacute;stup"
def commit_and_push(script_name: str, message: str, user: dict) -> None:
"""Zacommituje a pushne zm&ecaron;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)}")
# &Zcaron;&aacute;dn&aacute; zm&ecaron;na oproti HEAD -> p&rcaron;esko&ccaron;&iacute;me, a&tcaron; nevznikaj&iacute; pr&aacute;zdn&eacute; 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&aacute;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: def validate_schedule_type(value: str) -> str:
schedule_type = clean_optional(value) schedule_type = clean_optional(value)
if schedule_type not in SCHEDULE_TYPES: if schedule_type not in SCHEDULE_TYPES:
@@ -410,7 +335,7 @@ def scheduled_scripts_page(request: Request, user=Depends(require_user)):
"Pl&aacute;novan&eacute; skripty", "Pl&aacute;novan&eacute; skripty",
f""" f"""
<div class="card"> <div class="card">
<h2>Pl&aacute;novan&eacute; skripty</h2> <h2><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</h2>
<p class="muted">P&rcaron;ehled skript&uring; spou&scaron;t&ecaron;n&yacute;ch schedulerem nebo ru&ccaron;n&ecaron; z port&aacute;lu.</p> <p class="muted">P&rcaron;ehled skript&uring; spou&scaron;t&ecaron;n&yacute;ch schedulerem nebo ru&ccaron;n&ecaron; z port&aacute;lu.</p>
<p><a class="btn" href="/portal/scheduled-scripts/new">+ Nov&yacute; pl&aacute;novan&yacute; skript</a></p> <p><a class="btn" href="/portal/scheduled-scripts/new">+ Nov&yacute; pl&aacute;novan&yacute; skript</a></p>
</div> </div>
@@ -444,7 +369,7 @@ def new_scheduled_script_form(request: Request, user=Depends(require_user)):
"Nov&yacute; pl&aacute;novan&yacute; skript", "Nov&yacute; pl&aacute;novan&yacute; skript",
f""" f"""
<div class="card"> <div class="card">
<h2>Nov&yacute; pl&aacute;novan&yacute; skript</h2> <h2><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> Nov&yacute; pl&aacute;novan&yacute; skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t</a></p> <p><a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t</a></p>
</div> </div>
<div class="card"> <div class="card">
@@ -472,7 +397,7 @@ def create_scheduled_script_action(
created_name = metadata["script_name"] created_name = metadata["script_name"]
if not script_path(created_name).exists(): if not script_path(created_name).exists():
save_script_file(created_name, DEFAULT_SCRIPT_CONTENT) save_script_file(created_name, DEFAULT_SCRIPT_CONTENT)
commit_and_push(created_name, f"Vytvo&rcaron;en skript {created_name}", user) commit_and_push(f"maintenance/{created_name}", f"Vytvo&rcaron;en skript {created_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.created", 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&aacute;novan&yacute; skript"), html.escape(script.get("name", "") or "Pl&aacute;novan&yacute; skript"),
f""" f"""
<div class="card"> <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> <p>
<a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t na pl&aacute;novan&eacute; skripty</a> <a class="btn" href="/portal/scheduled-scripts">&larr; Zp&ecaron;t na pl&aacute;novan&eacute; skripty</a>
<a class="btn btn-secondary" href="/portal/scheduled-scripts/{script_id}/edit">Upravit</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&aacute;novan&yacute; skript", "Upravit pl&aacute;novan&yacute; skript",
f""" f"""
<div class="card"> <div class="card">
<h2>Upravit pl&aacute;novan&yacute; skript</h2> <h2><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> Upravit pl&aacute;novan&yacute; skript</h2>
<p><a class="btn" href="/portal/scheduled-scripts/{script_id}">&larr; Zp&ecaron;t</a></p> <p><a class="btn" href="/portal/scheduled-scripts/{script_id}">&larr; Zp&ecaron;t</a></p>
</div> </div>
<div class="card"> <div class="card">
@@ -637,7 +562,7 @@ def update_scheduled_script_file(
raise HTTPException(status_code=404, detail="Scheduled script not found") raise HTTPException(status_code=404, detail="Scheduled script not found")
script_name = validate_script_name(script.get("script_name", "") or "") script_name = validate_script_name(script.get("script_name", "") or "")
save_script_file(script_name, content) save_script_file(script_name, content)
commit_and_push(script_name, f"&Uacute;prava skriptu {script_name}", user) commit_and_push(f"maintenance/{script_name}", f"&Uacute;prava skriptu {script_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.file_updated", 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) path = script_path(script_name)
if path.exists(): if path.exists():
path.unlink() path.unlink()
commit_and_push(script_name, f"Smaz&aacute;n skript {script_name}", user) commit_and_push(f"maintenance/{script_name}", f"Smaz&aacute;n skript {script_name}", user)
log_audit_event( log_audit_event(
user, user,
action="scheduled_script.deleted", action="scheduled_script.deleted",
+8
View File
@@ -1,3 +1,11 @@
function togglePortalNav() {
const wrap = document.getElementById("portal-nav");
if (wrap) {
wrap.classList.toggle("open");
}
return false;
}
function writeClipboard(value) { function writeClipboard(value) {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(value); return navigator.clipboard.writeText(value);
+80 -21
View File
@@ -49,6 +49,33 @@ nav {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
flex-wrap: wrap; flex-wrap: wrap;
width: 100%;
}
.nav-toggle {
display: none;
margin-left: auto;
width: 42px;
height: 42px;
min-height: 42px;
padding: 0;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.22);
color: white;
font-size: 18px;
}
.nav-toggle:hover {
background: rgba(255, 255, 255, 0.16);
}
.nav-wrap {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
} }
.brand { .brand {
@@ -87,19 +114,16 @@ nav a,
nav a:hover, nav a:hover,
.nav-menu-button:hover, .nav-menu-button:hover,
.nav-menu:focus-within .nav-menu-button, .nav-menu[open] > .nav-menu-button {
.nav-menu:hover .nav-menu-button {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
text-decoration: none; text-decoration: none;
} }
.nav-menu { .nav-menu {
position: relative; position: relative;
padding-bottom: 10px;
margin-bottom: -10px;
} }
.nav-menu-button { .nav-menu > summary.nav-menu-button {
background: transparent; background: transparent;
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 0.20); border: 1px solid rgba(255, 255, 255, 0.20);
@@ -107,6 +131,12 @@ nav a:hover,
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
cursor: pointer;
list-style: none;
}
.nav-menu > summary.nav-menu-button::-webkit-details-marker {
display: none;
} }
.nav-menu-button::after { .nav-menu-button::after {
@@ -130,31 +160,29 @@ nav a:hover,
box-shadow: 0 12px 28px rgba(10, 31, 42, 0.28); box-shadow: 0 12px 28px rgba(10, 31, 42, 0.28);
} }
.nav-menu-panel::before { .nav-menu[open] .nav-menu-panel {
content: "";
position: absolute;
left: 0;
right: 0;
top: -10px;
height: 10px;
}
.nav-menu:hover .nav-menu-panel,
.nav-menu:focus-within .nav-menu-panel {
display: block; display: block;
} }
.nav-menu-panel a { .nav-menu-panel a {
display: block; display: flex;
align-items: center;
gap: 9px;
margin: 0; margin: 0;
padding: 10px 12px; padding: 10px 12px;
white-space: nowrap; white-space: nowrap;
} }
.nav-menu-panel a:hover {
background: rgba(255, 255, 255, 0.10);
text-decoration: none;
}
.user-menu { .user-menu {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-left: auto;
color: white; color: white;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -215,6 +243,16 @@ main {
.card h2 { .card h2 {
color: var(--secondary); color: var(--secondary);
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 0;
}
.card h2 .fa-solid {
color: var(--primary);
font-size: 0.92em;
} }
.grid { .grid {
@@ -1041,17 +1079,34 @@ pre {
@media (max-width: 980px) { @media (max-width: 980px) {
header { header {
align-items: flex-start; padding: 12px 16px;
flex-direction: column; gap: 12px;
padding: 14px 18px;
} }
.brand img { .brand img {
width: 120px; width: 120px;
} }
.nav-toggle {
display: inline-flex;
}
.nav-wrap {
flex-basis: 100%;
flex-direction: column;
align-items: stretch;
gap: 6px;
display: none;
}
.nav-wrap.open {
display: flex;
}
nav { nav {
width: 100%; flex-direction: column;
align-items: stretch;
gap: 4px;
} }
.nav-menu-panel { .nav-menu-panel {
@@ -1063,6 +1118,10 @@ pre {
.user-menu { .user-menu {
width: 100%; width: 100%;
margin-left: 0;
margin-top: 8px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
} }
+28 -25
View File
@@ -15,44 +15,47 @@ def page(title: str, body: str, user=None) -> str:
admin_menu = "" admin_menu = ""
if (user.get("role") or "").lower() == "admin": if (user.get("role") or "").lower() == "admin":
admin_menu = """ admin_menu = """
<div class="nav-menu"> <details class="nav-menu">
<button type="button" class="nav-menu-button"><i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> Admin</button> <summary class="nav-menu-button"><i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> Admin</summary>
<div class="nav-menu-panel"> <div class="nav-menu-panel">
<a href="/portal/operations"><i class="fa-solid fa-gauge-high" aria-hidden="true"></i> P&rcaron;ehled</a> <a href="/portal/operations"><i class="fa-solid fa-gauge-high" aria-hidden="true"></i> Přehled</a>
<a href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Slu&zcaron;by</a> <a href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</a>
<a href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> &Uacute;lohy</a> <a href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy</a>
<a href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</a> <a href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</a>
<a href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a> <a href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>
<a href="/portal/migration-readiness"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Migration Readiness</a> <a href="/portal/migration-readiness"><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Migration Readiness</a>
<a href="/portal/deployments"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazen&iacute;</a> <a href="/portal/deployments"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazení</a>
<a href="/portal/incidents"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Incidenty</a> <a href="/portal/incidents"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Incidenty</a>
<a href="/portal/workers"><i class="fa-solid fa-gears" aria-hidden="true"></i> Workery</a> <a href="/portal/workers"><i class="fa-solid fa-gears" aria-hidden="true"></i> Workery</a>
<a href="/portal/admin/runtime"><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</a> <a href="/portal/admin/runtime"><i class="fa-solid fa-server" aria-hidden="true"></i> Runtime Management</a>
<a href="/portal/admin/environment"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Environment</a>
<a href="/portal/admin/users"><i class="fa-solid fa-users" aria-hidden="true"></i> Users</a> <a href="/portal/admin/users"><i class="fa-solid fa-users" aria-hidden="true"></i> Users</a>
<a href="/portal/audit"><i class="fa-solid fa-clipboard-list" aria-hidden="true"></i> Audit</a> <a href="/portal/audit"><i class="fa-solid fa-clipboard-list" aria-hidden="true"></i> Audit</a>
<a href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Z&aacute;lohy</a> <a href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Zálohy</a>
</div> </div>
</div> </details>
""" """
nav = f""" nav = f"""
<nav> <button type="button" class="nav-toggle" aria-label="Otevřít menu" onclick="togglePortalNav()"><i class="fa-solid fa-bars" aria-hidden="true"></i></button>
<a class="nav-link" href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Slu&zcaron;by</a> <div class="nav-wrap" id="portal-nav">
<a class="nav-link" href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> &Uacute;lohy</a> <nav>
<a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</a> <a class="nav-link" href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</a>
<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a> <a class="nav-link" href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy</a>
<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro v&yacute;voj&aacute;&rcaron;e</a> <a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</a>
{admin_menu} <a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>
</nav> <a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>
""" {admin_menu}
user_panel = f""" <div class="user-menu">
<div class="user-menu"> <span class="user-name">{display_name}</span>
<span>{display_name}</span> <span class="muted">@{username}</span>
<span class="muted">@{username}</span> <form method="post" action="/portal/logout">
<form method="post" action="/portal/logout"> <button type="submit" class="btn-secondary"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> Odhlásit</button>
<button type="submit" class="btn-secondary"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> Odhlásit</button> </form>
</form> </div>
</nav>
</div> </div>
""" """
user_panel = ""
return f""" return f"""
<html> <html>
+99
View File
@@ -0,0 +1,99 @@
"""Sdílená git logika pro zápis do pracovního klonu appfactory-tools.
Portál zapisuje .sh soubory přímo do podsložek tohoto repa (maintenance/, alerts/).
Aby to nedělalo nepořádek v gitea, každý zápis/smazání rovnou commitne + pushne.
"""
import html
import os
import subprocess
from pathlib import Path
from fastapi import HTTPException
from app.config import (
DEFAULT_GITEA_ORG,
get_gitea_admin_token,
get_gitea_server_url,
read_env_value,
)
TOOLS_REPO_DIR = Path("/opt/appfactory/workspace/appfactory-tools")
TOOLS_REPO_NAME = "appfactory-tools"
GIT_AUTHOR_NAME = "AppFactory Portal"
GIT_AUTHOR_EMAIL = "portal@appfactory.local"
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(rel_path: str, message: str, user: dict) -> None:
"""Zacommituje a pushne změnu jednoho souboru (cesta relativní k repu) do gitea."""
# Repozitář v nedořešeném merge konfliktu blokuje jakýkoli commit (i jen jednoho souboru).
# Soubor je už uložený na disku; commit projde po ručním vyřešení konfliktu na serveru.
if (_git(["ls-files", "--unmerged"]).stdout or "").strip():
raise HTTPException(
status_code=409,
detail=(
f"Repozitář {TOOLS_REPO_DIR} je v nedořešeném merge konfliktu (unmerged soubory), "
"proto nelze commitnout. Soubor je uložený na disku. Vyřešte konflikt na serveru "
"(např. `git merge --abort` nebo ručně `git add` + commit) a akci zopakujte."
),
)
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)}")