UI fixes and menu
This commit is contained in:
+152
-68
@@ -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šechny .env z databá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šechny .env z databá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">← 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žby">
|
||||
<a class="btn btn-secondary" href="#metadata">Metadata</a>
|
||||
<a class="btn btn-secondary" href="#promenne">Proměnné</a>
|
||||
<a class="btn btn-secondary" href="#historie">Historie</a>
|
||||
</div>
|
||||
"""
|
||||
if can_manage
|
||||
else """
|
||||
<div class="detail-tabs" aria-label="Sekce detailu služ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ěnné</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">← 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()
|
||||
|
||||
Reference in New Issue
Block a user