icons, main .env
This commit is contained in:
+6
-6
@@ -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
@@ -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
@@ -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
@@ -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">← Zpět</a></p>
|
<p><a class="btn" href="/portal/alerting/rules">← 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">← Zpět na alert pravidla</a>
|
<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>
|
<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}">← Zpět</a></p>
|
<p><a class="btn" href="/portal/alerting/rules/{rule_id}">← 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">← Zpět na alert eventy</a>
|
<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>
|
<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",
|
("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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
+175
-68
@@ -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)} <service></code>.</p>
|
||||||
<table>
|
<table>
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Název</th>
|
<th>Komponenta</th>
|
||||||
<th>Popis</th>
|
<th>Service</th>
|
||||||
<th>Poslední spuštění akce</th>
|
<th>Poslední spuštění</th>
|
||||||
<th>Akce</th>
|
<th>Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
{component_rows}
|
</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>
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Čas</th>
|
<th>Čas</th>
|
||||||
<th>Typ</th>
|
<th>Skript</th>
|
||||||
<th>Stav</th>
|
<th>Stav</th>
|
||||||
<th>Výsledek</th>
|
<th>Výsledek</th>
|
||||||
</tr>
|
</tr>
|
||||||
{job_rows}
|
</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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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řilo uložit")
|
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:
|
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ánované skripty",
|
"Plánované skripty",
|
||||||
f"""
|
f"""
|
||||||
<div class="card">
|
<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 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>
|
<p><a class="btn" href="/portal/scheduled-scripts/new">+ Nový plánovaný skript</a></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,7 +369,7 @@ def new_scheduled_script_form(request: Request, user=Depends(require_user)):
|
|||||||
"Nový plánovaný skript",
|
"Nový plánovaný skript",
|
||||||
f"""
|
f"""
|
||||||
<div class="card">
|
<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>
|
<p><a class="btn" href="/portal/scheduled-scripts">← Zpě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řen skript {created_name}", user)
|
commit_and_push(f"maintenance/{created_name}", f"Vytvoř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ánovaný skript"),
|
html.escape(script.get("name", "") or "Plánovaný 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">← Zpět na plánované skripty</a>
|
<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>
|
<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",
|
"Upravit plánovaný skript",
|
||||||
f"""
|
f"""
|
||||||
<div class="card">
|
<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>
|
<p><a class="btn" href="/portal/scheduled-scripts/{script_id}">← Zpě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"Úprava skriptu {script_name}", user)
|
commit_and_push(f"maintenance/{script_name}", f"Ú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án skript {script_name}", user)
|
commit_and_push(f"maintenance/{script_name}", f"Smazán skript {script_name}", user)
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="scheduled_script.deleted",
|
action="scheduled_script.deleted",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-17
@@ -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ř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ž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> Ú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ánované 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í</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á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"""
|
||||||
|
<button type="button" class="nav-toggle" aria-label="Otevřít menu" onclick="togglePortalNav()"><i class="fa-solid fa-bars" aria-hidden="true"></i></button>
|
||||||
|
<div class="nav-wrap" id="portal-nav">
|
||||||
<nav>
|
<nav>
|
||||||
<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/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</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/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy</a>
|
||||||
<a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</a>
|
<a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</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/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>
|
||||||
<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>
|
<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>
|
||||||
{admin_menu}
|
{admin_menu}
|
||||||
</nav>
|
|
||||||
"""
|
|
||||||
user_panel = f"""
|
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<span>{display_name}</span>
|
<span class="user-name">{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>
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
|
user_panel = ""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<html>
|
<html>
|
||||||
|
|||||||
@@ -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)}")
|
||||||
Reference in New Issue
Block a user