service detail change - md docs and toggable sections.

This commit is contained in:
JiriUhlir
2026-08-03 11:47:38 +02:00
parent f67a3fc993
commit eb7ee612c3
4 changed files with 613 additions and 105 deletions
+247 -105
View File
@@ -41,6 +41,7 @@ from ..db.health import get_latest_service_health, get_service_health, get_servi
from ..db.incidents import get_service_incidents
from ..db.jobs import create_job, get_jobs, has_active_deploy_job
from ..environment import AppEnvironmentError, apply_all_app_environments, apply_app_environment, validate_environment_key
from ..repo_docs import RepoDocsError, is_markdown_path, list_markdown_files, read_markdown, render_markdown
from ..routes.deployments import render_status_pill
from ..routes.incidents import render_incident_history_rows
from ..shell import run_command
@@ -298,6 +299,30 @@ def render_health_dot(status: str | None, checked_at: str | None) -> str:
return f'<span class="{class_name}" title="{html.escape(title)}" aria-label="{html.escape(title)}"></span>'
def render_section(title: str, body: str, section_id: str = "", is_open: bool = False) -> str:
"""Sbalitelná karta detailu služby (defaultně zavřená, ať detail nezabírá tolik místa).
Title je hotové HTML (obvykle ikona + text), body celý obsah sekce. Odkaz s kotvou na
zavřenou sekci ji otevře až portal.js (openHashSection) - proto má sekce vlastní id.
"""
if not body.strip():
return ""
id_attr = f' id="{section_id}"' if section_id else ""
open_attr = " open" if is_open else ""
return f"""
<details class="card card-section"{id_attr}{open_attr}>
<summary class="card-section-summary">
<h2>{title}</h2>
<i class="fa-solid fa-chevron-down card-section-chevron" aria-hidden="true"></i>
</summary>
<div class="card-section-body">
{body}
</div>
</details>
"""
@router.get("/")
def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/apps", status_code=303)
@@ -812,27 +837,47 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
else ""
)
detail_tabs = (
# Odkazy jen otevřou a najedou na sekci - všechny sekce jsou defaultně sbalené.
manage_tabs = (
"""
<div class="detail-tabs" aria-label="Sekce detailu služby">
<a class="btn btn-secondary" href="#metadata"><i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata</a>
<a class="btn btn-secondary" href="#promenne"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</a>
<a class="btn btn-secondary" href="#ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</a>
<a class="btn btn-secondary" href="#historie"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie</a>
</div>
"""
if can_manage
else """
else ""
)
detail_tabs = f"""
<div class="detail-tabs" aria-label="Sekce detailu služby">
<a class="btn btn-secondary" href="#dokumentace"><i class="fa-solid fa-book" aria-hidden="true"></i> Dokumentace</a>
{manage_tabs}
<a class="btn btn-secondary" href="#historie"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie</a>
</div>
"""
# Seznam *.md souborů se tahá z Gitea, proto se načítá až po rozbalení sekce (portal.js) -
# nedostupná Gitea tak nikdy nezdrží vykreslení detailu.
docs_card = render_section(
'<i class="fa-solid fa-book" aria-hidden="true"></i> Dokumentace',
f"""
<p class="inline-form">
<a class="btn btn-secondary" href="/apps/{app_url_id}/docs" target="_blank" rel="noopener">
<i class="fa-solid fa-book-open" aria-hidden="true"></i> Swagger / OpenAPI
</a>
<span class="muted">Interaktivní dokumentace API běžící služby.</span>
</p>
<h3 class="docs-subtitle">Soubory *.md v repozitáři</h3>
<div class="doc-list" data-lazy-src="/portal/apps/{app_url_id}/docs-files">
<p class="muted">Seznam souborů se načte po rozbalení sekce.</p>
</div>
""",
section_id="dokumentace",
)
metadata_card = (
f"""
<div class="card" id="metadata">
<h2>Metadata</h2>
render_section(
'<i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata',
f"""
<form method="post" action="/portal/apps/{app_url_id}/metadata" class="metadata-form">
<label>Název</label>
<input name="name" value="{name}" required>
@@ -874,16 +919,17 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit metadata</button>
</div>
</form>
</div>
"""
""",
section_id="metadata",
)
if can_manage
else ""
)
variables_card = (
f"""
<div class="card" id="promenne">
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</h2>
render_section(
'<i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné',
f"""
<form method="post" action="/portal/apps/{app_url_id}/environment/apply" class="inline-form">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Regenerovat .env z databáze</button>
</form>
@@ -909,16 +955,17 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat proměnnou</button>
</div>
</form>
</div>
"""
""",
section_id="promenne",
)
if can_manage
else ""
)
ip_access_card = (
f"""
<div class="card" id="ip-access">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2>
render_section(
'<i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access',
f"""
<p class="muted">
Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody.
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
@@ -956,12 +1003,122 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat pravidlo</button>
</div>
</form>
</div>
"""
""",
section_id="ip-access",
)
if can_manage
else ""
)
summary_card = render_section(
'<i class="fa-solid fa-circle-info" aria-hidden="true"></i> Souhrn',
f"""
<table>
<tr><th>ID</th><td>{escaped_app_id}</td></tr>
<tr><th>N\u00e1zev</th><td>{name}</td></tr>
<tr><th>Popis</th><td>{description}</td></tr>
<tr><th>Vlastn\u00edk</th><td>{owner}</td></tr>
<tr><th>Template</th><td>{template_value}</td></tr>
<tr><th>Runtime</th><td>{runtime}</td></tr>
<tr><th>Repository URL</th><td>{repository_url}</td></tr>
<tr><th>Repository Name</th><td>{repository_name}</td></tr>
<tr><th>Default Branch</th><td>{default_branch}</td></tr>
<tr><th>Domain</th><td>{domain}</td></tr>
<tr><th>Health URL</th><td>{health_url}</td></tr>
<tr><th>Container Port</th><td>{container_port}</td></tr>
<tr><th>Ve\u0159ejn\u00e1 slu\u017eba</th><td>{"Ano" if is_public else "Ne"}</td></tr>
<tr><th>Aktivn\u00ed slu\u017eba</th><td>{"Ano" if is_enabled else "Ne"}</td></tr>
<tr><th>Jazyk</th><td>{language}</td></tr>
<tr><th>Verze</th><td>{version}</td></tr>
<tr><th>Status</th><td><span class="pill">{status}</span></td></tr>
<tr><th>Pam\u011b\u0165</th><td>{memory}</td></tr>
<tr><th>CPU</th><td>{cpus}</td></tr>
<tr><th>Upraveno</th><td>{updated_at}</td></tr>
<tr><th>Logy</th><td><a href="/portal/jobs?target={app_url_id}">Zobrazit \u00falohy a logy</a></td></tr>
</table>
""",
section_id="souhrn",
)
health_card = render_section(
'<i class="fa-solid fa-heart-pulse" aria-hidden="true"></i> Zdrav\u00ed slu\u017eby',
f"""
<table>
<tr><th>Aktu\u00e1ln\u00ed status</th><td>{health_status}</td></tr>
<tr><th>HTTP status</th><td>{health_http_status}</td></tr>
<tr><th>Odezva</th><td>{health_response_time}</td></tr>
<tr><th>Posledn\u00ed kontrola</th><td>{health_checked_at}</td></tr>
</table>
""",
section_id="zdravi",
)
health_history_card = render_section(
'<i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie kontrol',
f"""
<table>
<tr>
<th>\u010cas</th>
<th>Status</th>
<th>HTTP status</th>
<th>Odezva</th>
<th>Chyba</th>
</tr>
{health_rows}
</table>
""",
section_id="historie",
)
incidents_card = render_section(
'<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Incidenty slu\u017eby',
f"""
<table>
<tr>
<th>N\u00e1zev</th>
<th>Za\u010d\u00e1tek</th>
<th>Konec</th>
<th>Trv\u00e1n\u00ed</th>
<th>Stav</th>
</tr>
{render_incident_history_rows(incidents, include_service=False)}
</table>
""",
section_id="incidenty",
)
deployments_card = render_section(
'<i class="fa-solid fa-rocket" aria-hidden="true"></i> Historie nasazen\u00ed',
f"""
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>\u010cas</th>
<th>Spustil</th>
</tr>
{rows}
</table>
""",
section_id="nasazeni",
)
jobs_card = render_section(
'<i class="fa-solid fa-list-check" aria-hidden="true"></i> Historie \u00faloh',
f"""
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Typ</th>
<th>Vytvo\u0159eno</th>
</tr>
{job_rows}
</table>
""",
section_id="ulohy",
)
return page(
"Detail slu\u017eby",
f"""
@@ -979,107 +1136,92 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
{detail_tabs}
{docs_card}
{metadata_card}
{variables_card}
{ip_access_card}
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>ID</th><td>{escaped_app_id}</td></tr>
<tr><th>Název</th><td>{name}</td></tr>
<tr><th>Popis</th><td>{description}</td></tr>
<tr><th>Vlastník</th><td>{owner}</td></tr>
<tr><th>Template</th><td>{template_value}</td></tr>
<tr><th>Runtime</th><td>{runtime}</td></tr>
<tr><th>Repository URL</th><td>{repository_url}</td></tr>
<tr><th>Repository Name</th><td>{repository_name}</td></tr>
<tr><th>Default Branch</th><td>{default_branch}</td></tr>
<tr><th>Domain</th><td>{domain}</td></tr>
<tr><th>Health URL</th><td>{health_url}</td></tr>
<tr><th>Container Port</th><td>{container_port}</td></tr>
<tr><th>Veřejná služba</th><td>{"Ano" if is_public else "Ne"}</td></tr>
<tr><th>Aktivní služba</th><td>{"Ano" if is_enabled else "Ne"}</td></tr>
<tr><th>Jazyk</th><td>{language}</td></tr>
<tr><th>Verze</th><td>{version}</td></tr>
<tr><th>Status</th><td><span class="pill">{status}</span></td></tr>
<tr><th>Paměť</th><td>{memory}</td></tr>
<tr><th>CPU</th><td>{cpus}</td></tr>
<tr><th>Upraveno</th><td>{updated_at}</td></tr>
<tr><th>Logy</th><td><a href="/portal/jobs?target={app_url_id}">Zobrazit úlohy a logy</a></td></tr>
</table>
</div>
{summary_card}
<div class="card">
<h2>Zdraví služby</h2>
<table>
<tr><th>Aktuální status</th><td>{health_status}</td></tr>
<tr><th>HTTP status</th><td>{health_http_status}</td></tr>
<tr><th>Odezva</th><td>{health_response_time}</td></tr>
<tr><th>Poslední kontrola</th><td>{health_checked_at}</td></tr>
</table>
</div>
{health_card}
<div class="card" id="historie">
<h2>Historie kontrol</h2>
<table>
<tr>
<th>Čas</th>
<th>Status</th>
<th>HTTP status</th>
<th>Odezva</th>
<th>Chyba</th>
</tr>
{health_rows}
</table>
</div>
{health_history_card}
<div class="card">
<h2>Incidenty služby</h2>
<table>
<tr>
<th>Název</th>
<th>Začátek</th>
<th>Konec</th>
<th>Trvání</th>
<th>Stav</th>
</tr>
{render_incident_history_rows(incidents, include_service=False)}
</table>
</div>
{incidents_card}
<div class="card">
<h2>Historie nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Čas</th>
<th>Spustil</th>
</tr>
{rows}
</table>
</div>
{deployments_card}
<div class="card">
<h2>Historie úloh</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Typ</th>
<th>Vytvořeno</th>
</tr>
{job_rows}
</table>
</div>
{jobs_card}
""",
user=user,
)
def render_doc_size(size: int) -> str:
if size < 1024:
return f"{size} B"
return f"{round(size / 1024)} kB"
@router.get("/apps/{app_id}/docs-files", response_class=HTMLResponse)
def app_docs_files(app_id: str, user=Depends(require_user)):
"""HTML fragment se seznamem *.md souborů služby (načítá ho portal.js po rozbalení sekce)."""
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
repo = (app.get("repository_name") or app_id).strip()
branch = (app.get("default_branch") or "").strip()
try:
files = list_markdown_files(repo, branch)
except RepoDocsError as exc:
return HTMLResponse(f'<p class="muted">Dokumentaci se nepodařilo načíst: {html.escape(str(exc))}</p>')
if not files:
return HTMLResponse('<p class="muted">V repozitáři služby nejsou žádné soubory *.md.</p>')
items = ""
for item in files:
doc_path = item["path"]
doc_query = urlencode({"path": doc_path})
items += f"""
<details class="doc-file">
<summary>
<i class="fa-solid fa-file-lines" aria-hidden="true"></i>
<span>{html.escape(doc_path)}</span>
<span class="muted doc-file-size">{render_doc_size(item["size"])}</span>
</summary>
<div class="doc-file-body" data-lazy-src="/portal/apps/{quote(app_id, safe='')}/docs-file?{doc_query}">
<p class="muted">Obsah se načte po rozbalení.</p>
</div>
</details>
"""
return HTMLResponse(items)
@router.get("/apps/{app_id}/docs-file", response_class=HTMLResponse)
def app_docs_file(app_id: str, path: str = Query(""), user=Depends(require_user)):
"""HTML fragment s obsahem jednoho *.md souboru z repozitáře služby."""
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
if not is_markdown_path(path):
raise HTTPException(status_code=400, detail="Neplatná cesta k souboru dokumentace")
repo = (app.get("repository_name") or app_id).strip()
branch = (app.get("default_branch") or "").strip()
try:
text = read_markdown(repo, path, branch)
except RepoDocsError as exc:
return HTMLResponse(f'<p class="muted">Soubor se nepodařilo načíst: {html.escape(str(exc))}</p>')
return HTMLResponse(render_markdown(text, repo))
@router.post("/apps/{app_id}/metadata")
def save_app_metadata(
app_id: str,