service detail change - md docs and toggable sections.
This commit is contained in:
+247
-105
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user