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
+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,
)