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
+176
View File
@@ -0,0 +1,176 @@
"""Čtení markdown dokumentace služby z jejího Gitea repozitáře.
Portál běží bez práv k workspace na disku (viz komentář v config.py), ale má Gitea admin
token, takže seznam i obsah *.md souborů čte přes Gitea API:
- seznam: GET /api/v1/repos/{org}/{repo}/git/trees/{ref}?recursive=true
- obsah: GET /api/v1/repos/{org}/{repo}/raw/{path}?ref={ref}
- render: POST /api/v1/markdown (Gitea si HTML samo sanitizuje)
Když Gitea není nakonfigurovaná nebo repozitář neexistuje, vyhodí RepoDocsError
se srozumitelným důvodem - volající ho zobrazí místo obsahu.
"""
import html
import json
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request as UrlRequest
from urllib.request import urlopen
from app.config import (
DEFAULT_GITEA_ORG,
get_gitea_admin_token,
get_gitea_server_url,
read_env_value,
)
from app.logging_config import get_logger
logger = get_logger(__name__)
# Limity drží stránku svižnou i u repozitáře s rozsáhlou dokumentací.
MAX_DOC_FILES = 100
MAX_DOC_BYTES = 300_000
REQUEST_TIMEOUT = 8
class RepoDocsError(RuntimeError):
"""Dokumentaci se nepodařilo načíst (chybí token, Gitea je nedostupná, repo neexistuje)."""
def gitea_org() -> str:
return read_env_value("GITEA_ORG", DEFAULT_GITEA_ORG)
def is_markdown_path(path: str) -> bool:
"""Povolíme jen relativní cestu k *.md uvnitř repozitáře.
Cesta jde do Gitea API, ne na lokální disk, ale i tak odmítáme "..", absolutní cesty
a zpětná lomítka - ať se přes parametr nedá sáhnout nikam jinam než na dokumentaci.
"""
path = (path or "").strip()
if not path or not path.lower().endswith(".md"):
return False
if path.startswith("/") or "\\" in path or "\x00" in path:
return False
return ".." not in path.split("/")
def list_markdown_files(repo: str, branch: str = "") -> list[dict]:
"""Vrátí [{"path", "size"}] všech *.md souborů v repozitáři.
Řazení: soubory v kořeni (README.md, AGENTS.md) nahoře, pak abecedně podle cesty.
"""
repo = (repo or "").strip()
if not repo:
raise RepoDocsError("Služba nemá evidovaný název repozitáře.")
ref = (branch or "").strip() or _default_branch(repo)
payload = _request_json(f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/git/trees/{quote(ref, safe='')}?recursive=true&per_page=1000")
files = []
for entry in payload.get("tree") or []:
if entry.get("type") != "blob":
continue
path = entry.get("path") or ""
if not is_markdown_path(path):
continue
files.append({"path": path, "size": int(entry.get("size") or 0)})
files.sort(key=lambda item: (item["path"].count("/"), item["path"].lower()))
return files[:MAX_DOC_FILES]
def read_markdown(repo: str, path: str, branch: str = "") -> str:
"""Vrátí obsah *.md souboru jako text (oříznutý na MAX_DOC_BYTES)."""
repo = (repo or "").strip()
if not repo:
raise RepoDocsError("Služba nemá evidovaný název repozitáře.")
if not is_markdown_path(path):
raise RepoDocsError("Neplatná cesta k souboru dokumentace.")
ref = (branch or "").strip() or _default_branch(repo)
encoded_path = "/".join(quote(part, safe="") for part in path.split("/"))
raw = _request_bytes(
f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/raw/{encoded_path}?ref={quote(ref, safe='')}",
accept="text/plain",
)
truncated = len(raw) > MAX_DOC_BYTES
text = raw[:MAX_DOC_BYTES].decode("utf-8", errors="replace")
if truncated:
text += "\n\n*(Soubor je delší, náhled je zkrácený.)*"
return text
def render_markdown(text: str, repo: str) -> str:
"""Vyrenderuje markdown do HTML přes Gitea API.
Portál nemá markdown knihovnu a Gitea render umí a zároveň si výstupní HTML sanitizuje.
Když render selže, vrátíme obsah jako neformátovaný (escapovaný) text - náhled tak
funguje i při výpadku render endpointu.
"""
payload = json.dumps(
{
"text": text,
"mode": "gfm",
"context": f"/{gitea_org()}/{repo}",
"wiki": False,
}
).encode("utf-8")
try:
rendered = _request_bytes(
"/api/v1/markdown",
method="POST",
data=payload,
accept="text/html",
content_type="application/json",
).decode("utf-8", errors="replace")
return f'<div class="md-body">{rendered}</div>'
except RepoDocsError as exc:
logger.warning("Gitea markdown render selhal (%s), zobrazuji neformátovaný text: %s", repo, exc)
return f'<pre class="md-raw">{html.escape(text)}</pre>'
def _default_branch(repo: str) -> str:
"""Výchozí větev repozitáře podle Gitea (služby zakládané portálem mají main)."""
payload = _request_json(f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}")
return (payload.get("default_branch") or "main").strip() or "main"
def _request_json(path: str) -> dict:
body = _request_bytes(path).decode("utf-8", errors="replace")
try:
return json.loads(body) if body else {}
except json.JSONDecodeError as exc:
raise RepoDocsError(f"Gitea vrátila neplatnou odpověď: {exc}") from exc
def _request_bytes(
path: str,
method: str = "GET",
data: bytes | None = None,
accept: str = "application/json",
content_type: str = "",
) -> bytes:
base_url = get_gitea_server_url().rstrip("/")
token = get_gitea_admin_token()
if not base_url:
raise RepoDocsError("Gitea není nakonfigurovaná (chybí GITEA_URL).")
if not token:
raise RepoDocsError("Chybí Gitea admin token.")
headers = {"Accept": accept, "Authorization": f"token {token}"}
if content_type:
headers["Content-Type"] = content_type
request = UrlRequest(f"{base_url}{path}", data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=REQUEST_TIMEOUT) as response:
return response.read()
except HTTPError as exc:
if exc.code == 404:
raise RepoDocsError("Repozitář nebo soubor v Gitea neexistuje.") from exc
raise RepoDocsError(f"Gitea API vrátila HTTP {exc.code}.") from exc
except (URLError, TimeoutError) as exc:
raise RepoDocsError(f"Gitea API je nedostupná: {exc}") from exc
+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.incidents import get_service_incidents
from ..db.jobs import create_job, get_jobs, has_active_deploy_job 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 ..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.deployments import render_status_pill
from ..routes.incidents import render_incident_history_rows from ..routes.incidents import render_incident_history_rows
from ..shell import run_command 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>' 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("/") @router.get("/")
def portal_home(user=Depends(require_user)): def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/apps", status_code=303) 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 "" 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="#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="#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="#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 if can_manage
else """ else ""
)
detail_tabs = f"""
<div class="detail-tabs" aria-label="Sekce detailu služby"> <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> <a class="btn btn-secondary" href="#historie"><i class="fa-solid fa-clock-rotate-left" aria-hidden="true"></i> Historie</a>
</div> </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 = ( metadata_card = (
f""" render_section(
<div class="card" id="metadata"> '<i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata',
<h2>Metadata</h2> f"""
<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">
<label>Název</label> <label>Název</label>
<input name="name" value="{name}" required> <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> <button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit metadata</button>
</div> </div>
</form> </form>
</div> """,
""" section_id="metadata",
)
if can_manage if can_manage
else "" else ""
) )
variables_card = ( variables_card = (
f""" render_section(
<div class="card" id="promenne"> '<i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné',
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</h2> f"""
<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">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Regenerovat .env z databáze</button> <button type="submit" class="btn-secondary"><i class="fa-solid fa-rotate" aria-hidden="true"></i> Regenerovat .env z databáze</button>
</form> </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> <button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat proměnnou</button>
</div> </div>
</form> </form>
</div> """,
""" section_id="promenne",
)
if can_manage if can_manage
else "" else ""
) )
ip_access_card = ( ip_access_card = (
f""" render_section(
<div class="card" id="ip-access"> '<i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access',
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2> f"""
<p class="muted"> <p class="muted">
Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody. 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. <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> <button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat pravidlo</button>
</div> </div>
</form> </form>
</div> """,
""" section_id="ip-access",
)
if can_manage if can_manage
else "" 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( return page(
"Detail slu\u017eby", "Detail slu\u017eby",
f""" f"""
@@ -979,107 +1136,92 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
{detail_tabs} {detail_tabs}
{docs_card}
{metadata_card} {metadata_card}
{variables_card} {variables_card}
{ip_access_card} {ip_access_card}
<div class="card"> {summary_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>
<div class="card"> {health_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>
<div class="card" id="historie"> {health_history_card}
<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>
<div class="card"> {incidents_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>
<div class="card"> {deployments_card}
<h2>Historie nasazení</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Čas</th>
<th>Spustil</th>
</tr>
{rows}
</table>
</div>
<div class="card"> {jobs_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>
""", """,
user=user, 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") @router.post("/apps/{app_id}/metadata")
def save_app_metadata( def save_app_metadata(
app_id: str, app_id: str,
+81
View File
@@ -126,6 +126,87 @@ document.addEventListener("click", (event) => {
} }
}); });
// Sbalitelné sekce detailu služby: odkaz s kotvou (#metadata) musí cílovou sekci i otevřít,
// jinak by prohlížeč skočil na zavřený <details> a uživatel by neviděl žádný obsah.
function openHashSection() {
let hash = window.location.hash.slice(1);
if (!hash) {
return;
}
try {
hash = decodeURIComponent(hash);
} catch (error) {
return;
}
const target = document.getElementById(hash);
if (!target) {
return;
}
let node = target;
while (node) {
if (node.tagName === "DETAILS") {
node.open = true;
}
node = node.parentElement;
}
target.scrollIntoView();
}
window.addEventListener("hashchange", openHashSection);
document.addEventListener("DOMContentLoaded", openHashSection);
// Obsah načítaný až při rozbalení (seznam .md souborů a jejich náhledy). Načte se jen jednou;
// při chybě se příznak vrátí, aby šlo zkusit znovu dalším rozbalením.
function loadLazyPanel(panel) {
if (panel.dataset.lazyLoaded === "1") {
return;
}
panel.dataset.lazyLoaded = "1";
const url = panel.dataset.lazySrc;
panel.innerHTML = '<p class="muted">Načítám obsah</p>';
fetch(url, { headers: { "X-Requested-With": "fetch" } })
.then((response) => {
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.text();
})
.then((text) => {
panel.innerHTML = text;
})
.catch((error) => {
panel.dataset.lazyLoaded = "";
const message = document.createElement("p");
message.className = "alert alert-danger";
message.textContent = "Obsah se nepodařilo načíst: " + (error.message || error);
panel.replaceChildren(message);
});
}
// Událost toggle nebublá, proto ji odchytáváme v capture fázi na dokumentu.
document.addEventListener(
"toggle",
(event) => {
const details = event.target;
if (typeof HTMLDetailsElement === "undefined" || !(details instanceof HTMLDetailsElement) || !details.open) {
return;
}
// Jen panely patřící přímo této sekci - vnořené soubory se načtou až po svém rozbalení.
for (const panel of details.querySelectorAll("[data-lazy-src]")) {
if (panel.closest("details") === details) {
loadLazyPanel(panel);
}
}
},
true
);
function setFormDisabled(form, disabled) { function setFormDisabled(form, disabled) {
for (const field of form.querySelectorAll("input, select, textarea, button")) { for (const field of form.querySelectorAll("input, select, textarea, button")) {
field.disabled = disabled; field.disabled = disabled;
+109
View File
@@ -679,6 +679,115 @@ textarea:disabled {
margin: 0 0 18px; margin: 0 0 18px;
} }
/* Sbalitelné sekce detailu služby (<details class="card card-section">). */
.card-section > summary.card-section-summary {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
list-style: none;
}
.card-section > summary.card-section-summary::-webkit-details-marker {
display: none;
}
.card-section > summary.card-section-summary h2 {
margin: 0;
flex: 1;
}
.card-section-chevron {
color: var(--secondary);
font-size: 14px;
transition: transform 0.15s ease;
}
.card-section[open] > summary .card-section-chevron {
transform: rotate(180deg);
}
.card-section-body {
margin-top: 18px;
}
/* Dokumentace: seznam *.md souborů s náhledem obsahu. */
.docs-subtitle {
margin: 18px 0 6px;
color: var(--secondary);
}
.doc-file {
border-top: 1px solid var(--border);
padding: 10px 2px;
}
.doc-file > summary {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
list-style: none;
}
.doc-file > summary::-webkit-details-marker {
display: none;
}
.doc-file > summary .fa-solid {
color: var(--primary);
}
.doc-file-size {
margin-left: auto;
font-size: 12px;
white-space: nowrap;
}
.doc-file-body {
margin: 10px 0 4px;
}
.md-body {
overflow-x: auto;
line-height: 1.55;
}
.md-body h1,
.md-body h2,
.md-body h3,
.md-body h4 {
color: var(--secondary);
margin: 18px 0 8px;
}
.md-body img {
max-width: 100%;
}
.md-body table {
width: auto;
}
.md-body code {
background: #eef6f9;
border-radius: 4px;
padding: 1px 5px;
font-family: Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
}
.md-body pre code {
background: transparent;
padding: 0;
color: inherit;
}
.md-raw {
font-size: 13px;
line-height: 1.5;
}
.add-variable-form { .add-variable-form {
margin-top: 18px; margin-top: 18px;
} }