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
+30
View File
@@ -298,3 +298,33 @@ def require_user(request: Request) -> dict[str, Any]:
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": "/portal/login"}, headers={"Location": "/portal/login"},
) )
# --- Role-based access control ----------------------------------------------
# Portal roles, from least to most privileged:
# viewer read-only: sees which services run and their documentation, nothing more.
# developer everything a viewer sees + operational service workflows (Git/clone, deploy,
# variables, resources, metadata). Cannot delete services or access admin areas.
# admin full access, including deleting services and the Administration / Users area.
def is_admin(user: dict | None) -> bool:
return bool(user) and (user.get("role") or "").lower() == "admin"
def is_developer(user: dict | None) -> bool:
"""True for developers and admins — the roles allowed to operate on services."""
return bool(user) and (user.get("role") or "").lower() in ("admin", "developer")
def require_admin(request: Request) -> dict[str, Any]:
user = require_user(request)
if not is_admin(user):
raise HTTPException(status_code=403, detail="Tato akce je dostupná jen administrátorům.")
return user
def require_developer(request: Request) -> dict[str, Any]:
user = require_user(request)
if not is_developer(user):
raise HTTPException(status_code=403, detail="Tato akce je dostupná jen vývojářům a administrátorům.")
return user
+1
View File
@@ -1,6 +1,7 @@
DEPLOY_SCRIPT = "/tools/deploy-app.sh" DEPLOY_SCRIPT = "/tools/deploy-app.sh"
DELETE_APP_SCRIPT = "/tools/delete-app.sh" DELETE_APP_SCRIPT = "/tools/delete-app.sh"
BACKUP_SCRIPT = "/tools/backup-appfactory.sh" BACKUP_SCRIPT = "/tools/backup-appfactory.sh"
RESTORE_SCRIPT = "/tools/restore-appfactory.sh"
GENERATE_COMPOSE_SCRIPT = "/tools/generate-apps-compose.sh" GENERATE_COMPOSE_SCRIPT = "/tools/generate-apps-compose.sh"
CATALOG_FILE = "/opt/appfactory/apps/catalog.yml" CATALOG_FILE = "/opt/appfactory/apps/catalog.yml"
+3 -1
View File
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from .config import read_env_bool, read_env_value from .config import read_env_bool, read_env_value
from .routes import alerting, apps, audit, auth, backups, deployments, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, users, workers from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, users, workers
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -23,8 +23,10 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(health.router) app.include_router(health.router)
app.include_router(catalog.router)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(apps.router) app.include_router(apps.router)
app.include_router(developers.router)
app.include_router(backups.router) app.include_router(backups.router)
app.include_router(deployments.router) app.include_router(deployments.router)
app.include_router(incidents.router) app.include_router(incidents.router)
+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 import APIRouter, Depends, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse 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 ( from ..config import (
DEFAULT_GITEA_ORG, DEFAULT_GITEA_ORG,
DELETE_APP_SCRIPT, DELETE_APP_SCRIPT,
@@ -300,6 +300,11 @@ def apps_page(
gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG) gitea_org = read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
host = get_appfactory_host(request.url.hostname or "") 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 = "" rows = ""
for index, item in enumerate(apps, start=1): 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 "" 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 "" ssh_clone = html.escape(f"git clone ssh://git@{host}:2222/{gitea_org}/{app_id}.git") if host else ""
rows += f""" # Resources: developers/admins can edit; viewers see only the read-only summary.
<tr class="service-row"> if can_manage:
<td class="service-name-cell"> resource_edit = f"""
<div class="service-title"> <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>
{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>
</div> </div>
<dialog class="resource-modal" id="{resource_modal_id}"> <dialog class="resource-modal" id="{resource_modal_id}">
<form method="post" action="/portal/update-resources"> <form method="post" action="/portal/update-resources">
@@ -355,9 +346,13 @@ def apps_page(
<button type="submit">Použít</button> <button type="submit">Použít</button>
</div> </div>
</form> </form>
</dialog> </dialog>"""
</td> else:
<td> 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"> <details class="clone-details">
<summary>Příkazy pro klonování</summary> <summary>Příkazy pro klonování</summary>
<label class="muted">HTTP</label> <label class="muted">HTTP</label>
@@ -371,13 +366,13 @@ def apps_page(
<input readonly value="{ssh_clone}" id="ssh-{app_id}"> <input readonly value="{ssh_clone}" id="ssh-{app_id}">
<button type="button" onclick="return copyText(this)">Kopírovat</button> <button type="button" onclick="return copyText(this)">Kopírovat</button>
</div> </div>
</details> </details>"""
</td> else:
<td class="action-icons-cell"> git_cell = '<span class="muted">—</span>'
<div class="action-icons">
<a class="icon-action" href="/portal/apps/{app_url_id}" title="Detail služby" aria-label="Detail služby"> # Deploy actions for developers/admins; delete is admin-only.
<i class="fa-solid fa-circle-info" aria-hidden="true"></i> manage_icons = (
</a> f"""
<a class="icon-action" href="/portal/deployments?app_id={app_url_id}" title="Nasazení" aria-label="Nasazení"> <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> <i class="fa-solid fa-rocket" aria-hidden="true"></i>
</a> </a>
@@ -385,13 +380,51 @@ def apps_page(
<button type="submit" class="icon-action" title="Nasadit znovu" aria-label="Nasadit znovu"> <button type="submit" class="icon-action" title="Nasadit znovu" aria-label="Nasadit znovu">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> <i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i>
</button> </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ář.');"> <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}"> <input type="hidden" name="app_id" value="{app_id}">
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat"> <button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat">
<i class="fa-solid fa-trash" aria-hidden="true"></i> <i class="fa-solid fa-trash" aria-hidden="true"></i>
</button> </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> </div>
</td> </td>
</tr> </tr>
@@ -445,33 +478,42 @@ def apps_page(
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>'
# 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( return page(
"Služby", "Služby",
f""" f"""
<div class="grid"> <div class="grid">
<div class="card"> <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> <p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
</div> </div>
<div class="card"> <div class="card">
<h2>Zálohy</h2> <h2><i class="fa-solid fa-box-archive" aria-hidden="true"></i> 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> <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">Spravovat zálohy</a> <a class="btn" href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Spravovat zálohy</a>
</div> </div>
<div class="card"> <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> <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> </div>
<div class="card"> <div class="card">
<h2>Nasazené služby</h2> <h2>Nasazené služby</h2>
{notice} {notice}
<p><a class="btn" href="/portal/new-app">+ Nová služba</a></p> {manage_toolbar}
<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>
<form method="get" action="/portal/apps" class="filter-form"> <form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby"> <input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select> <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> <option value="health"{" selected" if sort == "health" else ""}>Podle zdraví</option>
</select> </select>
<input type="hidden" name="page" value="1"> <input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button> <button type="submit"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps">Reset</a> <a class="btn btn-secondary" href="/portal/apps"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Reset</a>
</form> </form>
{pagination} {pagination}
<table> <table>
@@ -489,7 +531,7 @@ def apps_page(
<th>Služba</th> <th>Služba</th>
<th>Status</th> <th>Status</th>
<th>Dokumentace</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>Git</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
@@ -503,7 +545,7 @@ def apps_page(
@router.post("/apps/environment/apply-all") @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: try:
message = apply_all_environments_message() message = apply_all_environments_message()
except AppEnvironmentError as exc: except AppEnvironmentError as exc:
@@ -524,6 +566,10 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
if not app: if not app:
raise HTTPException(status_code=404, detail="App not found") 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( log_audit_event(
user, user,
action="service.health.view", action="service.health.view",
@@ -661,29 +707,36 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
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>'
return page( # Editable sections are developer/admin only. Viewers keep the read-only Souhrn / Zdrav\u00ed /
"Detail slu\u017eby", # Historie cards below, which already present the service metadata without write access.
redeploy_block = (
f""" f"""
<div class="card"> <form method="post" action="/portal/apps/{app_url_id}/redeploy" onsubmit="return confirm('Spustit nov\u00e9 nasazen\u00ed slu\u017eby {escaped_app_id}?');">
<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}?');">
<button type="submit">Nasadit znovu</button> <button type="submit">Nasadit znovu</button>
</form> </form>
</div> """
if can_manage
else ""
)
detail_tabs = (
"""
<div class="detail-tabs" aria-label="Sekce detailu slu&zcaron;by"> <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="#metadata">Metadata</a>
<a class="btn btn-secondary" href="#promenne">Prom&ecaron;nn&eacute;</a> <a class="btn btn-secondary" href="#promenne">Prom&ecaron;nn&eacute;</a>
<a class="btn btn-secondary" href="#historie">Historie</a> <a class="btn btn-secondary" href="#historie">Historie</a>
</div> </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"> <div class="card" id="metadata">
<h2>Metadata</h2> <h2>Metadata</h2>
<form method="post" action="/portal/apps/{app_url_id}/metadata" class="metadata-form"> <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> </div>
</form> </form>
</div> </div>
"""
if can_manage
else ""
)
variables_card = (
f"""
<div class="card" id="promenne"> <div class="card" id="promenne">
<h2>Prom&ecaron;nn&eacute;</h2> <h2>Prom&ecaron;nn&eacute;</h2>
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form"> <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> </div>
</form> </form>
</div> </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"> <div class="card">
<h2>Souhrn</h2> <h2>Souhrn</h2>
@@ -869,7 +953,7 @@ def save_app_metadata(
container_port: str = Form(""), container_port: str = Form(""),
is_public: str | None = Form(None), is_public: str | None = Form(None),
is_enabled: str | None = Form(None), is_enabled: str | None = Form(None),
user=Depends(require_user), user=Depends(require_developer),
): ):
app = get_app(app_id) app = get_app(app_id)
if not app: if not app:
@@ -911,7 +995,7 @@ def add_app_variable(
key: str = Form(...), key: str = Form(...),
value: str = Form(""), value: str = Form(""),
is_secret: str | None = Form(None), is_secret: str | None = Form(None),
user=Depends(require_user), user=Depends(require_developer),
): ):
app = get_app(app_id) app = get_app(app_id)
if not app: if not app:
@@ -946,7 +1030,7 @@ def save_app_variable(
key: str = Form(...), key: str = Form(...),
value: str = Form(""), value: str = Form(""),
is_secret: str | None = Form(None), is_secret: str | None = Form(None),
user=Depends(require_user), user=Depends(require_developer),
): ):
app = get_app(app_id) app = get_app(app_id)
if not app: if not app:
@@ -980,7 +1064,7 @@ def save_app_variable(
@router.post("/apps/{app_id}/variables/{variable_id}/delete") @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) app = get_app(app_id)
if not app: if not app:
raise HTTPException(status_code=404, detail="App not found") 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") @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) app = get_app(app_id)
if not app: if not app:
raise HTTPException(status_code=404, detail="App not found") 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") @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) app = get_app(app_id)
if not app: if not app:
raise HTTPException(status_code=404, detail="App not found") 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) @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) template_options = render_template_options(get_app_templates(create_enabled=True), "", include_blank=False)
return page( return page(
"Nová služba", "Nová služba",
@@ -1114,7 +1198,7 @@ def create_app(
owner: str = Form(""), owner: str = Form(""),
memory: str = Form(""), memory: str = Form(""),
cpus: str = Form(""), cpus: str = Form(""),
user=Depends(require_user), user=Depends(require_developer),
): ):
selected_template = get_app_template(template, create_enabled=True) selected_template = get_app_template(template, create_enabled=True)
if not selected_template: if not selected_template:
@@ -1260,7 +1344,7 @@ def create_app(
@router.post("/delete-app", response_class=HTMLResponse) @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]) result = run_command([DELETE_APP_SCRIPT, app_id])
status = "OK" if result.returncode == 0 else "FAILED" status = "OK" if result.returncode == 0 else "FAILED"
log_audit_event( log_audit_event(
@@ -1288,7 +1372,7 @@ def update_resources(
app_id: str = Form(...), app_id: str = Form(...),
memory: str = Form(""), memory: str = Form(""),
cpus: str = Form(""), cpus: str = Form(""),
user=Depends(require_user), user=Depends(require_developer),
): ):
memory = memory.strip() memory = memory.strip()
cpus = cpus.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 ..auth import require_user
from ..backups import backup_dir, is_backup_path, list_backups 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 ..db.audit import log_audit_event
from ..shell import run_command from ..shell import run_command
from ..templates.layout import page, render_result 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_path = html.escape(str(path), quote=True)
backup_url_name = quote(path.name, safe="") backup_url_name = quote(path.name, safe="")
download_action = ( 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 if can_download
else "" else ""
) )
# Restore overwrites live data, so it is admin-only and guarded by an explicit confirm.
restore_cmd = html.escape( restore_action = (
f"sudo /home/jiri/workspace/appfactory-tools/scripts/restore-appfactory.sh --force {path}", f"""
quote=True, <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""" rows += f"""
@@ -49,42 +59,46 @@ def backups_page(request: Request, user=Depends(require_user)):
<td> <td>
<div class="copy-row"> <div class="copy-row">
<input readonly value="{backup_path}"> <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> </div>
</td> </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"> <td class="actions-cell">
{download_action} {download_action}
{restore_action}
<form method="post" action="/portal/backups/delete" onsubmit="return confirm('Smazat zálohu {backup_name}?');"> <form method="post" action="/portal/backups/delete" onsubmit="return confirm('Smazat zálohu {backup_name}?');">
<input type="hidden" name="backup_path" value="{backup_path}"> <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> </form>
</td> </td>
</tr> </tr>
""" """
if not rows: 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( return page(
"Zálohy", "Zálohy",
f""" f"""
<div class="card"> <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"> <p class="muted">
Zálohy neobsahují samotný adresář se zálohami. Zálohy neobsahují samotný adresář se zálohami.
Obnova starší zálohy by neměla smazat novější soubory .tar.gz. 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> </p>
<form method="post" action="/portal/backups/create"> <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> </form>
</div> </div>
@@ -94,7 +108,6 @@ def backups_page(request: Request, user=Depends(require_user)):
<tr> <tr>
<th>Záloha</th> <th>Záloha</th>
<th>Cesta</th> <th>Cesta</th>
<th>Příkaz pro obnovu</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
{rows} {rows}
@@ -169,21 +182,38 @@ def delete_backup(backup_path: str = Form(...), user=Depends(require_user)):
return RedirectResponse(url="/portal/backups", status_code=303) return RedirectResponse(url="/portal/backups", status_code=303)
@router.post("/backups/restore-trigger") @router.post("/backups/restore", response_class=HTMLResponse)
def restore_trigger(backup_path: str = Form(...), user=Depends(require_user)): def restore_backup(backup_path: str = Form(...), user=Depends(require_user)):
target = Path(backup_path) # 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): if not is_backup_path(target):
return HTMLResponse("Neplatná cesta k záloze", status_code=400) 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( log_audit_event(
user, user,
action="restore_trigger", action="backup_restore",
target_type="backup", target_type="backup",
target_id=target.name, target_id=target.name,
metadata={ metadata={
"backup_path": str(target), "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: def render_role_overview() -> str:
role_rows = ( role_rows = (
("admin", "Full administration, user management, role changes, disabling/enabling users, and admin-only operations."), ("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", "Operational portal access for application and service workflows. No access to Administration / Users."), ("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", "Default Google role. Read-oriented portal access where routes allow it. No access to Administration / Users."), ("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 = "" rows = ""
for role, description in role_rows: for role, description in role_rows:
+57 -4
View File
@@ -436,19 +436,72 @@ button:hover,
} }
.info-dot { .info-dot {
position: relative;
display: inline-flex; display: inline-flex;
width: 18px; width: 18px;
height: 18px; height: 18px;
margin-left: 4px; margin-left: 4px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid var(--muted);
border-radius: 999px;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 13px;
font-weight: 800;
line-height: 1; line-height: 1;
text-transform: none; text-transform: none;
cursor: help;
}
.info-dot:hover,
.info-dot:focus-visible {
color: var(--secondary-dark);
outline: none;
}
/* Custom tooltip bubble — the native title attribute is slow and easy to miss, so info hints
render as an immediate, styled popover on hover/focus. */
.info-dot[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 20;
width: max-content;
max-width: 260px;
padding: 8px 10px;
border-radius: 8px;
background: #1f2937;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
text-align: left;
white-space: normal;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease;
}
.info-dot[data-tooltip]::before {
content: "";
position: absolute;
top: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
z-index: 20;
border: 6px solid transparent;
border-bottom-color: #1f2937;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease;
}
.info-dot[data-tooltip]:hover::after,
.info-dot[data-tooltip]:focus-visible::after,
.info-dot[data-tooltip]:hover::before,
.info-dot[data-tooltip]:focus-visible::before {
opacity: 1;
visibility: visible;
} }
input, input,
+19 -18
View File
@@ -16,29 +16,30 @@ def page(title: str, body: str, user=None) -> str:
if (user.get("role") or "").lower() == "admin": if (user.get("role") or "").lower() == "admin":
admin_menu = """ admin_menu = """
<div class="nav-menu"> <div class="nav-menu">
<button type="button" class="nav-menu-button">Admin</button> <button type="button" class="nav-menu-button"><i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> Admin</button>
<div class="nav-menu-panel"> <div class="nav-menu-panel">
<a href="/portal/operations">P&rcaron;ehled</a> <a href="/portal/operations"><i class="fa-solid fa-gauge-high" aria-hidden="true"></i> P&rcaron;ehled</a>
<a href="/portal/apps">Slu&zcaron;by</a> <a href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Slu&zcaron;by</a>
<a href="/portal/jobs">&Uacute;lohy</a> <a href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> &Uacute;lohy</a>
<a href="/portal/scheduled-scripts">Pl&aacute;novan&eacute; skripty</a> <a href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</a>
<a href="/portal/alerting/rules">Alerting</a> <a href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>
<a href="/portal/migration-readiness">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">Nasazen&iacute;</a> <a href="/portal/deployments"><i class="fa-solid fa-rocket" aria-hidden="true"></i> Nasazen&iacute;</a>
<a href="/portal/incidents">Incidenty</a> <a href="/portal/incidents"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Incidenty</a>
<a href="/portal/workers">Workery</a> <a href="/portal/workers"><i class="fa-solid fa-gears" aria-hidden="true"></i> Workery</a>
<a href="/portal/admin/users">Users</a> <a href="/portal/admin/users"><i class="fa-solid fa-users" aria-hidden="true"></i> Users</a>
<a href="/portal/audit">Audit</a> <a href="/portal/audit"><i class="fa-solid fa-clipboard-list" aria-hidden="true"></i> Audit</a>
<a href="/portal/backups">Z&aacute;lohy</a> <a href="/portal/backups"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Z&aacute;lohy</a>
</div> </div>
</div> </div>
""" """
nav = f""" nav = f"""
<nav> <nav>
<a class="nav-link" href="/portal/apps">Slu&zcaron;by</a> <a class="nav-link" href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Slu&zcaron;by</a>
<a class="nav-link" href="/portal/jobs">&Uacute;lohy</a> <a class="nav-link" href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> &Uacute;lohy</a>
<a class="nav-link" href="/portal/scheduled-scripts">Pl&aacute;novan&eacute; skripty</a> <a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Pl&aacute;novan&eacute; skripty</a>
<a class="nav-link" href="/portal/alerting/rules">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&yacute;voj&aacute;&rcaron;e</a>
{admin_menu} {admin_menu}
</nav> </nav>
""" """
@@ -47,7 +48,7 @@ def page(title: str, body: str, user=None) -> str:
<span>{display_name}</span> <span>{display_name}</span>
<span class="muted">@{username}</span> <span class="muted">@{username}</span>
<form method="post" action="/portal/logout"> <form method="post" action="/portal/logout">
<button type="submit" class="btn-secondary">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>
""" """