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()