UI fixes and menu

This commit is contained in:
JiriUhlir
2026-06-15 11:00:09 +02:00
parent 3bb762d08a
commit bf4796d153
10 changed files with 485 additions and 119 deletions
+152 -68
View File
@@ -5,7 +5,7 @@ from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from ..auth import require_user
from ..auth import is_admin, is_developer, require_admin, require_developer, require_user
from ..config import (
DEFAULT_GITEA_ORG,
DELETE_APP_SCRIPT,
@@ -300,6 +300,11 @@ def apps_page(
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
host = get_appfactory_host(request.url.hostname or "")
# Role-based visibility: a viewer only sees which services run and their documentation.
# Developers also get Git/clone and operational actions; only admins see the delete button.
can_manage = is_developer(user)
can_delete = is_admin(user)
rows = ""
for index, item in enumerate(apps, start=1):
@@ -319,24 +324,10 @@ def apps_page(
http_clone = html.escape(f"git clone {gitea_url}/{gitea_org}/{app_id}.git") if gitea_url else ""
ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git") if host else ""
rows += f"""
<tr class="service-row">
<td class="service-name-cell">
<div class="service-title">
{health_dot}
<div>
<strong>{app_id}</strong><br>
<span class="muted">/apps/{app_id}</span>
</div>
</div>
</td>
<td><span class="pill">{status}</span></td>
<td><a href="{docs}" target="_blank" rel="noopener">Swagger</a></td>
<td>
<div class="resource-summary">
<span>Paměť: <strong>{memory_label}</strong></span>
<span>CPU: <strong>{cpus_label}</strong></span>
<button type="button" class="btn btn-compact btn-secondary" onclick="openResourceModal('{resource_modal_id}')">Upravit</button>
# Resources: developers/admins can edit; viewers see only the read-only summary.
if can_manage:
resource_edit = f"""
<button type="button" class="btn btn-compact btn-secondary" onclick="openResourceModal('{resource_modal_id}')"><i class="fa-solid fa-gear" aria-hidden="true"></i> Upravit</button>
</div>
<dialog class="resource-modal" id="{resource_modal_id}">
<form method="post" action="/portal/update-resources">
@@ -355,9 +346,13 @@ def apps_page(
<button type="submit">Použít</button>
</div>
</form>
</dialog>
</td>
<td>
</dialog>"""
else:
resource_edit = "</div>"
# Git clone commands are an operational concern — visible to developers/admins only.
if can_manage and (http_clone or ssh_clone):
git_cell = f"""
<details class="clone-details">
<summary>Příkazy pro klonování</summary>
<label class="muted">HTTP</label>
@@ -371,13 +366,13 @@ def apps_page(
<input readonly value="{ssh_clone}" id="ssh-{app_id}">
<button type="button" onclick="return copyText(this)">Kopírovat</button>
</div>
</details>
</td>
<td class="action-icons-cell">
<div class="action-icons">
<a class="icon-action" href="/portal/apps/{app_url_id}" title="Detail služby" aria-label="Detail služby">
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
</a>
</details>"""
else:
git_cell = '<span class="muted">—</span>'
# Deploy actions for developers/admins; delete is admin-only.
manage_icons = (
f"""
<a class="icon-action" href="/portal/deployments?app_id={app_url_id}" title="Nasazení" aria-label="Nasazení">
<i class="fa-solid fa-rocket" aria-hidden="true"></i>
</a>
@@ -385,13 +380,51 @@ def apps_page(
<button type="submit" class="icon-action" title="Nasadit znovu" aria-label="Nasadit znovu">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i>
</button>
</form>
</form>"""
if can_manage
else ""
)
delete_icon = (
f"""
<form method="post" action="/portal/delete-app" onsubmit="return confirm('Smazat {app_id}? Tím se odstraní kontejner, image, workspace, záznam v katalogu a Gitea repozitář.');">
<input type="hidden" name="app_id" value="{app_id}">
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat">
<i class="fa-solid fa-trash" aria-hidden="true"></i>
</button>
</form>
</form>"""
if can_delete
else ""
)
rows += f"""
<tr class="service-row">
<td class="service-name-cell">
<div class="service-title">
{health_dot}
<div>
<strong>{app_id}</strong><br>
<span class="muted">/apps/{app_id}</span>
</div>
</div>
</td>
<td><span class="pill">{status}</span></td>
<td><a href="{docs}" target="_blank" rel="noopener">Swagger</a></td>
<td>
<div class="resource-summary">
<span>Paměť: <strong>{memory_label}</strong></span>
<span>CPU: <strong>{cpus_label}</strong></span>
{resource_edit}
</td>
<td>
{git_cell}
</td>
<td class="action-icons-cell">
<div class="action-icons">
<a class="icon-action" href="/portal/apps/{app_url_id}" title="Detail služby" aria-label="Detail služby">
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
</a>
{manage_icons}
{delete_icon}
</div>
</td>
</tr>
@@ -445,33 +478,42 @@ def apps_page(
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
# Creating services and regenerating .env files are write actions — developers/admins only.
manage_toolbar = (
"""
<p><a class="btn" href="/portal/new-app"><i class="fa-solid fa-plus" aria-hidden="true"></i> Nová služba</a></p>
<form method="post" action="/portal/apps/environment/apply-all" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> Regenerovat v&scaron;echny .env z datab&aacute;ze</button>
</form>
"""
if can_manage
else ""
)
return page(
"Služby",
f"""
<div class="grid">
<div class="card">
<h2>Služby</h2>
<h2><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</h2>
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
</div>
<div class="card">
<h2>Zálohy</h2>
<p class="muted">Vytváření záloh a kopírování příkazů pro obnovu. Obnova je záměrně ruční a chráněná.</p>
<a class="btn" href="/portal/backups">Spravovat zálohy</a>
<h2><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Zálohy</h2>
<p class="muted">Vytváření, stažení a obnova záloh. Obnova je chráněná a dostupná jen administrátorům.</p>
<a class="btn" href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Spravovat zálohy</a>
</div>
<div class="card">
<h2>Nasazení</h2>
<h2><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazení</h2>
<p class="muted">Historie posledních běhů nasazení, stavů a výstupů z deploy procesu.</p>
<a class="btn" href="/portal/deployments">Zobrazit nasazení</a>
<a class="btn" href="/portal/deployments"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Zobrazit nasazení</a>
</div>
</div>
<div class="card">
<h2>Nasazené služby</h2>
{notice}
<p><a class="btn" href="/portal/new-app">+ Nová služba</a></p>
<form method="post" action="/portal/apps/environment/apply-all" class="inline-form">
<button type="submit" class="btn-secondary">Regenerovat v&scaron;echny .env z datab&aacute;ze</button>
</form>
{manage_toolbar}
<form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select>
@@ -480,8 +522,8 @@ def apps_page(
<option value="health"{" selected" if sort == "health" else ""}>Podle zdraví</option>
</select>
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
<button type="submit"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Reset</a>
</form>
{pagination}
<table>
@@ -489,7 +531,7 @@ def apps_page(
<th>Služba</th>
<th>Status</th>
<th>Dokumentace</th>
<th>Prostředky <span class="info-dot" title="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro.">i</span></th>
<th>Prostředky <span class="info-dot" tabindex="0" role="note" aria-label="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro." data-tooltip="Paměť je limit RAM. CPU určuje maximální podíl výpočetního výkonu. Příklad: 0,50 = polovina jádra, 1,00 = celé jádro."><i class="fa-solid fa-circle-info" aria-hidden="true"></i></span></th>
<th>Git</th>
<th>Akce</th>
</tr>
@@ -503,7 +545,7 @@ def apps_page(
@router.post("/apps/environment/apply-all")
def apply_all_app_environments_action(user=Depends(require_user)):
def apply_all_app_environments_action(user=Depends(require_developer)):
try:
message = apply_all_environments_message()
except AppEnvironmentError as exc:
@@ -524,6 +566,10 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
if not app:
raise HTTPException(status_code=404, detail="App not found")
# Viewers may open the detail (services + docs are read-only here); editing the service,
# its variables or triggering a redeploy is reserved for developers/admins.
can_manage = is_developer(user)
log_audit_event(
user,
action="service.health.view",
@@ -661,29 +707,36 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
if error:
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
return page(
"Detail slu\u017eby",
# Editable sections are developer/admin only. Viewers keep the read-only Souhrn / Zdrav\u00ed /
# Historie cards below, which already present the service metadata without write access.
redeploy_block = (
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
{notice}
<p>
<a class="btn" href="/portal/apps">&larr; Zpět na služby</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení služby</a>
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}">Úlohy služby</a>
</p>
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nové nasazení služby {escaped_app_id}?');">
<form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nov\u00e9 nasazen\u00ed slu\u017eby {escaped_app_id}?');">
<button type="submit">Nasadit znovu</button>
</form>
</div>
"""
if can_manage
else ""
)
detail_tabs = (
"""
<div class="detail-tabs" aria-label="Sekce detailu slu&zcaron;by">
<a class="btn btn-secondary" href="#metadata">Metadata</a>
<a class="btn btn-secondary" href="#promenne">Prom&ecaron;nn&eacute;</a>
<a class="btn btn-secondary" href="#historie">Historie</a>
</div>
"""
if can_manage
else """
<div class="detail-tabs" aria-label="Sekce detailu slu&zcaron;by">
<a class="btn btn-secondary" href="#historie">Historie</a>
</div>
"""
)
metadata_card = (
f"""
<div class="card" id="metadata">
<h2>Metadata</h2>
<form method="post" action="/portal/apps/{app_url_id}/metadata" class="metadata-form">
@@ -728,7 +781,13 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
</div>
</form>
</div>
"""
if can_manage
else ""
)
variables_card = (
f"""
<div class="card" id="promenne">
<h2>Prom&ecaron;nn&eacute;</h2>
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form">
@@ -757,6 +816,31 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
</div>
</form>
</div>
"""
if can_manage
else ""
)
return page(
"Detail slu\u017eby",
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
<p class="muted">{name}</p>
{notice}
<p>
<a class="btn" href="/portal/apps">&larr; Zpět na služby</a>
<a class="btn btn-secondary" href="/portal/deployments?app_id={app_url_id}">Nasazení služby</a>
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}">Úlohy služby</a>
</p>
{redeploy_block}
</div>
{detail_tabs}
{metadata_card}
{variables_card}
<div class="card">
<h2>Souhrn</h2>
@@ -869,7 +953,7 @@ def save_app_metadata(
container_port: str = Form(""),
is_public: str | None = Form(None),
is_enabled: str | None = Form(None),
user=Depends(require_user),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -911,7 +995,7 @@ def add_app_variable(
key: str = Form(...),
value: str = Form(""),
is_secret: str | None = Form(None),
user=Depends(require_user),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -946,7 +1030,7 @@ def save_app_variable(
key: str = Form(...),
value: str = Form(""),
is_secret: str | None = Form(None),
user=Depends(require_user),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
@@ -980,7 +1064,7 @@ def save_app_variable(
@router.post("/apps/{app_id}/variables/{variable_id}/delete")
def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user)):
def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1007,7 +1091,7 @@ def remove_app_variable(app_id: str, variable_id: int, user=Depends(require_user
@router.post("/apps/{app_id}/environment/apply")
def apply_app_environment_action(app_id: str, user=Depends(require_user)):
def apply_app_environment_action(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1028,7 +1112,7 @@ def apply_app_environment_action(app_id: str, user=Depends(require_user)):
@router.post("/apps/{app_id}/redeploy")
def redeploy_app(app_id: str, user=Depends(require_user)):
def redeploy_app(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
@@ -1062,7 +1146,7 @@ def redeploy_app(app_id: str, user=Depends(require_user)):
@router.get("/new-app", response_class=HTMLResponse)
def new_app_form(request: Request, user=Depends(require_user)):
def new_app_form(request: Request, user=Depends(require_developer)):
template_options = render_template_options(get_app_templates(create_enabled=True), "", include_blank=False)
return page(
"Nová služba",
@@ -1114,7 +1198,7 @@ def create_app(
owner: str = Form(""),
memory: str = Form(""),
cpus: str = Form(""),
user=Depends(require_user),
user=Depends(require_developer),
):
selected_template = get_app_template(template, create_enabled=True)
if not selected_template:
@@ -1260,7 +1344,7 @@ def create_app(
@router.post("/delete-app", response_class=HTMLResponse)
def delete_app(app_id: str = Form(...), user=Depends(require_user)):
def delete_app(app_id: str = Form(...), user=Depends(require_admin)):
result = run_command([DELETE_APP_SCRIPT, app_id])
status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event(
@@ -1288,7 +1372,7 @@ def update_resources(
app_id: str = Form(...),
memory: str = Form(""),
cpus: str = Form(""),
user=Depends(require_user),
user=Depends(require_developer),
):
memory = memory.strip()
cpus = cpus.strip()
+55 -25
View File
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from ..auth import require_user
from ..backups import backup_dir, is_backup_path, list_backups
from ..config import BACKUP_SCRIPT
from ..config import BACKUP_SCRIPT, RESTORE_SCRIPT
from ..db.audit import log_audit_event
from ..shell import run_command
from ..templates.layout import page, render_result
@@ -30,14 +30,24 @@ def backups_page(request: Request, user=Depends(require_user)):
backup_path = html.escape(str(path), quote=True)
backup_url_name = quote(path.name, safe="")
download_action = (
f'<p><a class="btn btn-secondary" href="/portal/backups/download/{backup_url_name}">Stáhnout</a></p>'
f'<a class="btn btn-secondary" href="/portal/backups/download/{backup_url_name}">'
f'<i class="fa-solid fa-download" aria-hidden="true"></i> Stáhnout</a>'
if can_download
else ""
)
restore_cmd = html.escape(
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}",
quote=True,
# Restore overwrites live data, so it is admin-only and guarded by an explicit confirm.
restore_action = (
f"""
<form method="post" action="/portal/backups/restore"
onsubmit="return confirm('Obnovit zálohu {backup_name}?\\n\\nTato akce přepíše aktuální data AppFactory a může restartovat běžící služby. Akce je nevratná.');">
<input type="hidden" name="backup_path" value="{backup_path}">
<button type="submit" class="btn btn-secondary">
<i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Obnovit
</button>
</form>
"""
if can_download
else ""
)
rows += f"""
@@ -49,42 +59,46 @@ def backups_page(request: Request, user=Depends(require_user)):
<td>
<div class="copy-row">
<input readonly value="{backup_path}">
<button type="button" class="copy-button" data-copy-value="{backup_path}" onclick="copyText(this)">Kopírovat</button>
<button type="button" class="copy-button" data-copy-value="{backup_path}" onclick="copyText(this)">
<i class="fa-solid fa-copy" aria-hidden="true"></i> Kopírovat
</button>
</div>
</td>
<td>
<div class="copy-row">
<input readonly value="{restore_cmd}">
<button type="button" class="copy-button" data-copy-value="{restore_cmd}" onclick="copyText(this)">Kopírovat</button>
</div>
<div class="muted">Obnova je nebezpečná a musí se spustit ručně přes SSH.</div>
</td>
<td class="actions-cell">
{download_action}
{restore_action}
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Smazat zálohu {backup_name}?');">
<input type="hidden" name="backup_path" value="{backup_path}">
<button type="submit" class="danger">Smazat</button>
<button type="submit" class="danger">
<i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat
</button>
</form>
</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="4">Nebyly nalezeny žádné zálohy.</td></tr>'
rows = '<tr><td colspan="3">Nebyly nalezeny žádné zálohy.</td></tr>'
restore_hint = (
"Obnovu lze spustit přímo z portálu tlačítkem Obnovit (pouze administrátor)."
if can_download
else "Obnovu zálohy může spustit pouze administrátor."
)
return page(
"Zálohy",
f"""
<div class="card">
<h2>Správa záloh</h2>
<h2><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Správa záloh</h2>
<p class="muted">
Zálohy neobsahují samotný adresář se zálohami.
Obnova starší zálohy by neměla smazat novější soubory .tar.gz.
Obnova zůstává ruční, aby nedošlo k nechtěnému přepsání dat.
{restore_hint} Obnova přepíše aktuální data a je nevratná.
</p>
<form method="post" action="/portal/backups/create">
<button type="submit">Vytvořit zálohu</button>
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Vytvořit zálohu</button>
</form>
</div>
@@ -94,7 +108,6 @@ def backups_page(request: Request, user=Depends(require_user)):
<tr>
<th>Záloha</th>
<th>Cesta</th>
<th>Příkaz pro obnovu</th>
<th>Akce</th>
</tr>
{rows}
@@ -169,21 +182,38 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)):
return RedirectResponse(url="/portal/backups", status_code=303)
@router.post("/backups/restore-trigger")
def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)):
target = Path(backup_path)
@router.post("/backups/restore", response_class=HTMLResponse)
def restore_backup(backup_path: str = Form(...), user=Depends(require_user)):
# Restoring overwrites live data and may restart services, so it is restricted to admins.
if not is_admin(user):
raise HTTPException(status_code=403, detail="Only admins can restore backups")
target = Path(backup_path)
if not is_backup_path(target):
return HTMLResponse("Neplatná cesta k záloze", status_code=400)
target = target.resolve()
if not target.exists() or not target.is_file():
raise HTTPException(status_code=404, detail="Backup not found")
result = run_command([RESTORE_SCRIPT, "--force", str(target)])
status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event(
user,
action="restore_trigger",
action="backup_restore",
target_type="backup",
target_id=target.name,
metadata={
"backup_path": str(target),
"status": status,
"returncode": result.returncode,
},
)
return RedirectResponse(url="/portal/backups", status_code=303)
return render_result(
title=f"Obnova zálohy: {status}",
back_url="/portal/backups",
sections=[("Výstup", result.stdout), ("Chyba", result.stderr)],
user=user,
)
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter
from ..catalog import load_apps
router = APIRouter()
@router.get("/catalog")
def services_catalog():
"""Public, unauthenticated JSON catalog of deployed services.
Returns each service's name and a link to its documentation. Intended to be exposed
publicly (the AppFactory reverse proxy maps the public ``/apps`` onto this endpoint).
The data comes from the generated catalog (catalog.yml) and contains no secrets.
"""
services = []
for item in load_apps():
app_id = item.get("id", "")
if not app_id:
continue
services.append({
"name": item.get("name", app_id),
"docs": item.get("docs", f"/apps/{app_id}/docs"),
})
return {"services": services}
+139
View File
@@ -0,0 +1,139 @@
import html
from fastapi import APIRouter, Depends
from ..auth import require_user
from ..templates.layout import page
router = APIRouter()
# Portal sections (the admin/agenda links shown in the navigation). role: minimum role that can
# actually use the section; viewers may see read-only data where a section allows it.
PORTAL_SECTIONS = [
("fa-server", "Služby", "/portal/apps",
"Přehled nasazených služeb: stav, dokumentace, prostředky (RAM/CPU), Git a akce.", "viewer (čtení) / developer / admin"),
("fa-list-check", "Úlohy", "/portal/jobs",
"Fronta a historie úloh (nasazení, skripty) včetně stavů a logů.", "developer / admin"),
("fa-calendar-days", "Plánované skripty", "/portal/scheduled-scripts",
"Cron-like skripty spouštěné na pozadí; úpravy jen pro administrátory.", "developer / admin"),
("fa-bell", "Alerting", "/portal/alerting/rules",
"Pravidla alertů a jejich skripty; úpravy jen pro administrátory.", "developer / admin"),
("fa-gauge-high", "Přehled", "/portal/operations",
"Operační přehled systému, běžící nasazení a stav služeb.", "admin"),
("fa-diagram-project", "Migration Readiness", "/portal/migration-readiness",
"Připravenost a deploy core služeb AppFactory.", "admin"),
("fa-rocket", "Nasazení", "/portal/deployments",
"Historie deploy běhů, stavů a výstupů z deploy procesu.", "developer / admin"),
("fa-triangle-exclamation", "Incidenty", "/portal/incidents",
"Evidované incidenty služeb.", "developer / admin"),
("fa-gears", "Workery", "/portal/workers",
"Stav workerů zpracovávajících úlohy.", "admin"),
("fa-users", "Users", "/portal/admin/users",
"Správa uživatelů a rolí (viewer / developer / admin).", "admin"),
("fa-clipboard-list", "Audit", "/portal/audit",
"Auditní log akcí provedených v portálu.", "admin"),
("fa-box-archive", "Zálohy", "/portal/backups",
"Vytváření, stažení a obnova záloh AppFactory. Obnova je jen pro administrátory.", "admin"),
]
# Public / API endpoints not gated by the portal login.
PUBLIC_ENDPOINTS = [
("GET", "/apps", "Veřejný JSON katalog služeb (název + odkaz na dokumentaci). Routuje se přes Caddy."),
("GET", "/portal/health", "Health endpoint portálu (HTTP 200, pokud portál běží)."),
("GET", "/apps/&lt;app-id&gt;/docs", "Swagger/OpenAPI dokumentace konkrétní služby."),
("GET", "/apps/&lt;app-id&gt;/health", "Health endpoint konkrétní služby."),
]
# Roles model.
ROLES = [
("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání."),
("developer", "Vše co viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace."),
("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů, audit a admin-only operace."),
]
@router.get("/developers")
def developers_page(user=Depends(require_user)):
section_rows = ""
for icon, title, href, desc, roles in PORTAL_SECTIONS:
section_rows += f"""
<tr>
<td><a href="{href}"><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(title)}</a></td>
<td><code>{html.escape(href)}</code></td>
<td>{html.escape(desc)}</td>
<td><span class="muted">{html.escape(roles)}</span></td>
</tr>
"""
endpoint_rows = ""
for method, path, desc in PUBLIC_ENDPOINTS:
endpoint_rows += f"""
<tr>
<td><span class="pill">{html.escape(method)}</span></td>
<td><code>{path}</code></td>
<td>{html.escape(desc)}</td>
</tr>
"""
role_rows = ""
for role, desc in ROLES:
role_rows += f"""
<tr>
<td><span class="pill pill-muted">{html.escape(role)}</span></td>
<td>{html.escape(desc)}</td>
</tr>
"""
return page(
"Pro vývojáře",
f"""
<div class="card">
<h2><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</h2>
<p class="muted">
Přehled všech sekcí portálu, veřejných endpointů a rolí. Portál běží za reverzní proxy
na cestě <code>/portal</code>; jednotlivé služby na <code>/apps/&lt;app-id&gt;</code>.
</p>
</div>
<div class="card">
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Sekce portálu (agendy)</h2>
<table>
<tr>
<th>Sekce</th>
<th>Odkaz</th>
<th>Popis</th>
<th>Role</th>
</tr>
{section_rows}
</table>
</div>
<div class="card">
<h2><i class="fa-solid fa-plug" aria-hidden="true"></i> Veřejné / API endpointy</h2>
<table>
<tr>
<th>Metoda</th>
<th>Cesta</th>
<th>Popis</th>
</tr>
{endpoint_rows}
</table>
<p class="muted">
Veřejný katalog <code>/apps</code> vrací JSON se seznamem služeb a odkazy na jejich dokumentaci.
</p>
</div>
<div class="card">
<h2><i class="fa-solid fa-user-shield" aria-hidden="true"></i> Role a oprávnění</h2>
<table>
<tr>
<th>Role</th>
<th>Co může</th>
</tr>
{role_rows}
</table>
</div>
""",
user=user,
)
+3 -3
View File
@@ -45,9 +45,9 @@ def render_sync_pill(status: str) -> str:
def render_role_overview() -> str:
role_rows = (
("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."),
("developer", "Operational portal access for application and service workflows. No access to Administration / Users."),
("viewer", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."),
("admin", "Plný přístup: vše co developer + mazání služeb, správa záloh a jejich obnova, správa uživatelů a rolí, admin-only operace."),
("developer", "Vidí běžící služby a dokumentaci jako viewer + provozní workflow služeb: Git/klonování, nasazení, proměnné, prostředky, metadata. Nemůže mazat služby ani do Administrace / Uživatelů."),
("viewer", "Pouze pro čtení: vidí jaké služby běží a jejich dokumentaci (Swagger). Bez Gitu, úprav, nasazení a mazání. Bez přístupu do Administrace / Uživatelů. Výchozí role pro Google přihlášení."),
)
rows = ""
for role, description in role_rows: