diff --git a/app/repo_docs.py b/app/repo_docs.py new file mode 100644 index 0000000..57adef0 --- /dev/null +++ b/app/repo_docs.py @@ -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'
{rendered}
' + except RepoDocsError as exc: + logger.warning("Gitea markdown render selhal (%s), zobrazuji neformátovaný text: %s", repo, exc) + return f'
{html.escape(text)}
' + + +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 diff --git a/app/routes/apps.py b/app/routes/apps.py index 4a0a171..82de950 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -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'' +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""" +
+ +

{title}

+ +
+
+ {body} +
+
+ """ + + @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 = ( """ -
Metadata Proměnné Security / IP Access - Historie -
""" if can_manage - else """ + else "" + ) + detail_tabs = f"""
+ Dokumentace + {manage_tabs} Historie
""" + + # 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( + ' Dokumentace', + f""" +

+ + Swagger / OpenAPI + + Interaktivní dokumentace API běžící služby. +

+

Soubory *.md v repozitáři

+
+

Seznam souborů se načte po rozbalení sekce.

+
+ """, + section_id="dokumentace", ) metadata_card = ( - f""" -
-

Metadata

+ render_section( + ' Metadata', + f"""
@@ -874,16 +919,17 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
- - """ + """, + section_id="metadata", + ) if can_manage else "" ) variables_card = ( - f""" -
-

Proměnné

+ render_section( + ' Proměnné', + f"""
@@ -909,16 +955,17 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
- - """ + """, + section_id="promenne", + ) if can_manage else "" ) ip_access_card = ( - f""" -
-

Security / IP Access

+ render_section( + ' Security / IP Access', + f"""

Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody. WRITE = POST, PUT, PATCH, DELETE; ALL = všechny běžné metody včetně GET. @@ -956,12 +1003,122 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""

- - """ + """, + section_id="ip-access", + ) if can_manage else "" ) + summary_card = render_section( + ' Souhrn', + f""" + + + + + + + + + + + + + + + + + + + + + + +
ID{escaped_app_id}
N\u00e1zev{name}
Popis{description}
Vlastn\u00edk{owner}
Template{template_value}
Runtime{runtime}
Repository URL{repository_url}
Repository Name{repository_name}
Default Branch{default_branch}
Domain{domain}
Health URL{health_url}
Container Port{container_port}
Ve\u0159ejn\u00e1 slu\u017eba{"Ano" if is_public else "Ne"}
Aktivn\u00ed slu\u017eba{"Ano" if is_enabled else "Ne"}
Jazyk{language}
Verze{version}
Status{status}
Pam\u011b\u0165{memory}
CPU{cpus}
Upraveno{updated_at}
LogyZobrazit \u00falohy a logy
+ """, + section_id="souhrn", + ) + + health_card = render_section( + ' Zdrav\u00ed slu\u017eby', + f""" + + + + + +
Aktu\u00e1ln\u00ed status{health_status}
HTTP status{health_http_status}
Odezva{health_response_time}
Posledn\u00ed kontrola{health_checked_at}
+ """, + section_id="zdravi", + ) + + health_history_card = render_section( + ' Historie kontrol', + f""" + + + + + + + + + {health_rows} +
\u010casStatusHTTP statusOdezvaChyba
+ """, + section_id="historie", + ) + + incidents_card = render_section( + ' Incidenty slu\u017eby', + f""" + + + + + + + + + {render_incident_history_rows(incidents, include_service=False)} +
N\u00e1zevZa\u010d\u00e1tekKonecTrv\u00e1n\u00edStav
+ """, + section_id="incidenty", + ) + + deployments_card = render_section( + ' Historie nasazen\u00ed', + f""" + + + + + + + + {rows} +
IDStatus\u010casSpustil
+ """, + section_id="nasazeni", + ) + + jobs_card = render_section( + ' Historie \u00faloh', + f""" + + + + + + + + {job_rows} +
IDStatusTypVytvo\u0159eno
+ """, + 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} -
-

Souhrn

- - - - - - - - - - - - - - - - - - - - - - -
ID{escaped_app_id}
Název{name}
Popis{description}
Vlastník{owner}
Template{template_value}
Runtime{runtime}
Repository URL{repository_url}
Repository Name{repository_name}
Default Branch{default_branch}
Domain{domain}
Health URL{health_url}
Container Port{container_port}
Veřejná služba{"Ano" if is_public else "Ne"}
Aktivní služba{"Ano" if is_enabled else "Ne"}
Jazyk{language}
Verze{version}
Status{status}
Paměť{memory}
CPU{cpus}
Upraveno{updated_at}
LogyZobrazit úlohy a logy
-
+ {summary_card} -
-

Zdraví služby

- - - - - -
Aktuální status{health_status}
HTTP status{health_http_status}
Odezva{health_response_time}
Poslední kontrola{health_checked_at}
-
+ {health_card} -
-

Historie kontrol

- - - - - - - - - {health_rows} -
ČasStatusHTTP statusOdezvaChyba
-
+ {health_history_card} -
-

Incidenty služby

- - - - - - - - - {render_incident_history_rows(incidents, include_service=False)} -
NázevZačátekKonecTrváníStav
-
+ {incidents_card} -
-

Historie nasazení

- - - - - - - - {rows} -
IDStatusČasSpustil
-
+ {deployments_card} -
-

Historie úloh

- - - - - - - - {job_rows} -
IDStatusTypVytvořeno
-
+ {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'

Dokumentaci se nepodařilo načíst: {html.escape(str(exc))}

') + + if not files: + return HTMLResponse('

V repozitáři služby nejsou žádné soubory *.md.

') + + items = "" + for item in files: + doc_path = item["path"] + doc_query = urlencode({"path": doc_path}) + items += f""" +
+ + + {html.escape(doc_path)} + {render_doc_size(item["size"])} + +
+

Obsah se načte po rozbalení.

+
+
+ """ + + 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'

Soubor se nepodařilo načíst: {html.escape(str(exc))}

') + + return HTMLResponse(render_markdown(text, repo)) + + @router.post("/apps/{app_id}/metadata") def save_app_metadata( app_id: str, diff --git a/app/static/portal.js b/app/static/portal.js index 1548c95..22de0a5 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -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ý
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 = '

Načítám obsah

'; + + 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) { for (const field of form.querySelectorAll("input, select, textarea, button")) { field.disabled = disabled; diff --git a/app/static/styles.css b/app/static/styles.css index f623649..32f3c23 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -679,6 +679,115 @@ textarea:disabled { margin: 0 0 18px; } +/* Sbalitelné sekce detailu služby (
). */ +.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 { margin-top: 18px; }