service detail change - md docs and toggable sections.
This commit is contained in:
@@ -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
|
||||||
+244
-102
@@ -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 = (
|
||||||
|
render_section(
|
||||||
|
'<i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata',
|
||||||
f"""
|
f"""
|
||||||
<div class="card" id="metadata">
|
|
||||||
<h2>Metadata</h2>
|
|
||||||
<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 = (
|
||||||
|
render_section(
|
||||||
|
'<i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné',
|
||||||
f"""
|
f"""
|
||||||
<div class="card" id="promenne">
|
|
||||||
<h2><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</h2>
|
|
||||||
<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 = (
|
||||||
|
render_section(
|
||||||
|
'<i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access',
|
||||||
f"""
|
f"""
|
||||||
<div class="card" id="ip-access">
|
|
||||||
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2>
|
|
||||||
<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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user