1050 lines
41 KiB
Python
Executable file
1050 lines
41 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""Render detached status HTML from stack-status.json (no probing)."""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import html
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
HERE = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(HERE))
|
||
import stack_status_lib as L # noqa: E402
|
||
|
||
# Human labels instead of colour names (llm-control style)
|
||
_STATUS_LABEL = {
|
||
"green": "OK",
|
||
"blue": "Present",
|
||
"yellow": "Soft",
|
||
"orange": "Degraded",
|
||
"red": "Down",
|
||
"black": "Long down",
|
||
"white": "Gone",
|
||
}
|
||
|
||
def _logo_svg(pkg: str, *, fill: str, text: str, size: str = "16") -> str:
|
||
return (
|
||
f'<svg class="logo" viewBox="0 0 64 64" aria-label="{_esc(pkg)}" title="{_esc(pkg)}">'
|
||
f'<rect width="64" height="64" rx="10" fill="{fill}"/>'
|
||
f'<text x="32" y="40" text-anchor="middle" fill="#fff" font-size="{size}" '
|
||
f'font-family="ui-sans-serif,system-ui,sans-serif" font-weight="700">{_esc(text)}</text></svg>'
|
||
)
|
||
|
||
|
||
_LOGO_SVG = {
|
||
"nginx": lambda: _logo_svg("nginx", fill="#009639", text="ngx", size="18"),
|
||
"apache": lambda: _logo_svg("apache", fill="#d22128", text="httpd", size="14"),
|
||
"caddy": lambda: _logo_svg("caddy", fill="#1f9ceb", text="caddy", size="14"),
|
||
"postfix": lambda: _logo_svg("postfix", fill="#f0a000", text="pfx", size="16"),
|
||
"mta": lambda: _logo_svg("mta", fill="#c08a00", text="mta", size="16"),
|
||
"taler": lambda: _logo_svg("taler", fill="#5a32a3", text="T", size="22"),
|
||
"taler-exchange": lambda: _logo_svg("taler-exchange", fill="#5a32a3", text="ex", size="16"),
|
||
"taler-merchant": lambda: _logo_svg("taler-merchant", fill="#7b4cc7", text="mer", size="14"),
|
||
"taler-bank": lambda: _logo_svg("taler-bank", fill="#3d2a80", text="bnk", size="14"),
|
||
"webui": lambda: _logo_svg("webui", fill="#0d9488", text="ui", size="16"),
|
||
"wallet": lambda: _logo_svg("wallet", fill="#2563eb", text="wal", size="14"),
|
||
}
|
||
|
||
CSS = """
|
||
:root {
|
||
--bg: #0f1419; --fg: #e7ecf1; --muted: #8b98a5; --card: #1a222c; --line: #243040;
|
||
--dot-green: #3ddc84; --dot-blue: #61afef; --dot-yellow: #e6c07b;
|
||
--dot-orange: #ff9f43; --dot-red: #ff5c5c; --dot-black: #2a2a2a; --ring-black: #9aa0a6;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
||
background: var(--bg); color: var(--fg); line-height: 1.3;
|
||
}
|
||
header {
|
||
padding: .7rem 1rem; border-bottom: 1px solid var(--line);
|
||
display: flex; flex-wrap: wrap; gap: .4rem 1rem; align-items: center;
|
||
}
|
||
header h1 { font-size: 1rem; margin: 0; font-weight: 650; }
|
||
header .meta { color: var(--muted); font-size: .8rem; }
|
||
header .score {
|
||
display: inline-flex; align-items: center; gap: .4rem;
|
||
font-size: .85rem; font-weight: 650;
|
||
border: 1px solid #314050; border-radius: 999px;
|
||
padding: .2rem .65rem; background: rgba(0,0,0,.2);
|
||
}
|
||
header .score .dot { width: .65rem; height: .65rem; }
|
||
.dot.white { background: #f5f7fa; box-shadow: 0 0 0 1px #9aa0a6; }
|
||
.score.light-green { border-color: rgba(61,220,132,.55); }
|
||
.score.light-blue { border-color: rgba(97,175,239,.55); }
|
||
.score.light-yellow { border-color: rgba(230,192,123,.55); }
|
||
.score.light-orange { border-color: rgba(255,159,67,.55); }
|
||
.score.light-red { border-color: rgba(255,92,92,.55); }
|
||
.score.light-black { border-color: rgba(154,160,166,.45); }
|
||
.score.light-white { border-color: rgba(245,247,250,.45); color: #c5ccd4; }
|
||
h3.sec {
|
||
margin: 1rem 1rem .35rem; font-size: .75rem; color: var(--muted);
|
||
text-transform: uppercase; letter-spacing: .04em; font-weight: 600;
|
||
}
|
||
.grid {
|
||
display: grid; gap: .7rem; padding: .55rem 1rem .85rem;
|
||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||
}
|
||
.card {
|
||
background: var(--card); border-radius: 10px; padding: .7rem .8rem;
|
||
border: 2px solid #314050; min-width: 0; cursor: pointer;
|
||
}
|
||
.card:focus-visible { outline: 2px solid var(--dot-blue); outline-offset: 2px; }
|
||
.card h2 { margin: 0; font-size: .9rem; font-weight: 650; overflow-wrap: anywhere; }
|
||
.card .sub {
|
||
color: var(--muted); font-size: .72rem; margin: .15rem 0 .45rem;
|
||
display: flex; flex-wrap: wrap; gap: .25rem .55rem; align-items: center;
|
||
}
|
||
.card .sub .bad { color: var(--dot-orange); }
|
||
.card .sub .crit { color: var(--dot-red); }
|
||
.card .sub .ok { color: var(--dot-green); }
|
||
.card .why {
|
||
font-size: .68rem; color: var(--muted); margin: 0 0 .35rem;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.rows { display: flex; flex-direction: column; gap: .15rem; }
|
||
.row {
|
||
display: flex; align-items: center; gap: .4rem;
|
||
font-size: .78rem; min-width: 0;
|
||
}
|
||
.row .role {
|
||
color: var(--muted); width: 3.6rem; flex: 0 0 auto;
|
||
font-size: .65rem; text-transform: uppercase;
|
||
}
|
||
.row .host {
|
||
flex: 1 1 auto; min-width: 0;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.dot {
|
||
width: .55rem; height: .55rem; border-radius: 50%; flex: 0 0 auto;
|
||
box-shadow: 0 0 0 1px rgba(255,255,255,.1);
|
||
}
|
||
.dot.green { background: var(--dot-green); }
|
||
.dot.blue { background: var(--dot-blue); }
|
||
.dot.yellow { background: var(--dot-yellow); }
|
||
.dot.orange { background: var(--dot-orange); }
|
||
.dot.red { background: var(--dot-red); }
|
||
.dot.black { background: var(--dot-black); box-shadow: 0 0 0 1px var(--ring-black); }
|
||
.tags {
|
||
display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem;
|
||
padding-top: .45rem; border-top: 1px solid var(--line);
|
||
align-items: center;
|
||
}
|
||
.tag {
|
||
font-size: .62rem; color: var(--muted);
|
||
border: 1px solid #314050; border-radius: 999px;
|
||
padding: .1rem .45rem; background: rgba(0,0,0,.15);
|
||
text-decoration: none;
|
||
}
|
||
a.tag:hover { filter: brightness(1.15); }
|
||
.tag.warn { color: var(--dot-orange); border-color: rgba(255,159,67,.45); }
|
||
.tag.crit { color: var(--dot-red); border-color: rgba(255,92,92,.45); }
|
||
.logo { width: 1.05rem; height: 1.05rem; vertical-align: middle; flex: 0 0 auto; }
|
||
.swrow {
|
||
display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .45rem;
|
||
}
|
||
.sw {
|
||
display: inline-flex; align-items: center; gap: .3rem;
|
||
font-size: .62rem; border-radius: 8px; padding: .15rem .4rem .15rem .25rem;
|
||
border: 1px solid #314050; background: rgba(0,0,0,.18); color: var(--muted);
|
||
max-width: 100%;
|
||
}
|
||
.sw .ver { color: var(--fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.sw.light-green { border-color: rgba(61,220,132,.55); }
|
||
.sw.light-blue { border-color: rgba(97,175,239,.5); }
|
||
.sw.light-yellow { border-color: rgba(230,192,123,.55); color: var(--dot-yellow); }
|
||
.sw.light-orange { border-color: rgba(255,159,67,.6); color: var(--dot-orange); }
|
||
.sw.light-red { border-color: rgba(255,92,92,.65); color: var(--dot-red); }
|
||
.card.ok-green { border-color: rgba(61,220,132,.5); }
|
||
.card.ok-blue { border-color: rgba(97,175,239,.5); }
|
||
.card.muted-green { border-color: rgba(61,220,132,.3); }
|
||
.card.muted-blue { border-color: rgba(97,175,239,.3); }
|
||
.card.muted-yellow { border-color: rgba(230,192,123,.4); }
|
||
.card.muted-orange { border-color: rgba(255,159,67,.45); }
|
||
.card.muted-red { border-color: rgba(255,92,92,.4); }
|
||
.card.muted-black { border-color: rgba(154,160,166,.35); }
|
||
.card.full-red { border-color: var(--dot-red); background: #3a1414; }
|
||
.card.full-black { border-color: var(--ring-black); background: #0a0a0a; }
|
||
.card.full-orange { border-color: var(--dot-orange); background: #3a2210; }
|
||
.stale { color: var(--dot-orange); }
|
||
footer { padding: .7rem 1rem 1.2rem; color: var(--muted); font-size: .7rem; }
|
||
.result-index {
|
||
margin: .4rem 1rem 1.2rem; padding: .75rem .9rem;
|
||
border: 1px solid var(--line); border-radius: 10px; background: #121820;
|
||
}
|
||
.result-index h3 {
|
||
margin: 0 0 .55rem; font-size: .78rem; color: var(--muted);
|
||
text-transform: uppercase; letter-spacing: .04em; font-weight: 600;
|
||
}
|
||
.result-index .ri-group { margin: 0 0 .65rem; }
|
||
.result-index .ri-group:last-child { margin-bottom: 0; }
|
||
.result-index .ri-hd {
|
||
display: flex; align-items: center; gap: .4rem;
|
||
font-size: .78rem; font-weight: 600; margin: 0 0 .3rem;
|
||
}
|
||
.result-index ul {
|
||
margin: 0; padding-left: 1.05rem; font-size: .72rem; color: var(--muted);
|
||
}
|
||
.result-index li { margin: .18rem 0; }
|
||
.result-index .ri-stack { color: var(--fg); font-weight: 500; }
|
||
.result-index .ri-cmd {
|
||
display: inline-block; margin-left: .35rem; padding: .05rem .35rem;
|
||
border-radius: 4px; border: 1px solid #314050; background: rgba(0,0,0,.25);
|
||
color: var(--dot-blue); font-family: ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: .68rem;
|
||
}
|
||
dialog {
|
||
border: 1px solid var(--line); border-radius: 12px; background: #121820; color: var(--fg);
|
||
padding: 0; max-width: min(520px, 94vw); width: 100%;
|
||
}
|
||
dialog::backdrop { background: rgba(0,0,0,.55); }
|
||
.dlg-hd {
|
||
display: flex; justify-content: space-between; gap: .5rem; align-items: start;
|
||
padding: .85rem 1rem; border-bottom: 1px solid var(--line);
|
||
}
|
||
.dlg-hd h2 { margin: 0; font-size: 1rem; }
|
||
.dlg-hd button {
|
||
background: transparent; border: 1px solid #314050; color: var(--fg);
|
||
border-radius: 8px; padding: .2rem .55rem; cursor: pointer;
|
||
}
|
||
.dlg-bd { padding: .85rem 1rem 1.1rem; font-size: .82rem; }
|
||
.dlg-bd .agg { margin: 0 0 .7rem; color: var(--muted); }
|
||
.dlg-bd ul { margin: 0; padding-left: 1.1rem; }
|
||
.dlg-bd li { margin: .25rem 0; }
|
||
.dlg-bd .cve {
|
||
margin-top: .8rem; padding-top: .7rem; border-top: 1px solid var(--line);
|
||
}
|
||
.dlg-bd a { color: var(--dot-blue); }
|
||
"""
|
||
|
||
|
||
def _esc(s: object) -> str:
|
||
return html.escape(str(s if s is not None else ""), quote=True)
|
||
|
||
|
||
def _label(light: object) -> str:
|
||
return _STATUS_LABEL.get(str(light or ""), str(light or "?"))
|
||
|
||
|
||
def _short_host(host: str) -> str:
|
||
h = host or ""
|
||
if h.upper() == "IBAN":
|
||
return "IBAN"
|
||
return h
|
||
|
||
|
||
def _osv_url(cve_id: str) -> str:
|
||
cid = (cve_id or "").strip()
|
||
if not cid:
|
||
return "https://osv.dev/"
|
||
return f"https://osv.dev/vulnerability/{cid}"
|
||
|
||
|
||
def _probe_command_for_endpoint(host: str, ep: dict) -> str:
|
||
"""Concrete outside-in probe (HTTP GET) for one endpoint row."""
|
||
if ep.get("skipped"):
|
||
path = ep.get("path") or ep.get("id") or "?"
|
||
return f"(skip) GET https://{host}{path}"
|
||
url = str(ep.get("url") or "").strip()
|
||
if not url:
|
||
path = ep.get("path") or ""
|
||
url = f"https://{host}{path}" if host and path else path or "?"
|
||
return f"GET {url}"
|
||
|
||
|
||
def _probe_command_for_member(m: dict) -> str:
|
||
role = str(m.get("role") or "member")
|
||
host = str(m.get("host") or "")
|
||
reason = str(m.get("reason") or "")
|
||
if role == "ssh" or reason.startswith("SSH-") or reason in ("ssh_probe", "timeout", "no_host"):
|
||
target = host or "?"
|
||
return f"TCP {target}:22 banner"
|
||
if host.upper() == "IBAN" or reason in ("ebics_iban", "ebics_no_bank"):
|
||
return f"(no HTTP) {role} · {reason or 'ebics'}"
|
||
if host:
|
||
return f"GET https://{host}/config"
|
||
return f"probe {role}"
|
||
|
||
|
||
def collect_result_index_rows(doc: dict) -> list[dict]:
|
||
"""Flatten probe/test rows across stacks/services/mail for the bottom index."""
|
||
rows: list[dict] = []
|
||
for section, key in (
|
||
("stack", "stacks"),
|
||
("service", "services"),
|
||
("mail", "mail"),
|
||
):
|
||
for st in doc.get(key) or []:
|
||
if not isinstance(st, dict):
|
||
continue
|
||
sid = str(st.get("id") or "?")
|
||
for m in st.get("members") or []:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
role = str(m.get("role") or "")
|
||
if role == "cve":
|
||
continue
|
||
host = str(m.get("host") or "")
|
||
endpoints = [e for e in (m.get("endpoints") or []) if isinstance(e, dict)]
|
||
if endpoints:
|
||
for ep in endpoints:
|
||
light = str(ep.get("light") or m.get("light") or "black")
|
||
eid = str(ep.get("id") or ep.get("path") or "ep")
|
||
reason = str(ep.get("reason") or "")
|
||
label = f"{role}/{eid}"
|
||
rows.append(
|
||
{
|
||
"section": section,
|
||
"stack": sid,
|
||
"role": role,
|
||
"host": host,
|
||
"id": eid,
|
||
"label": label,
|
||
"light": light,
|
||
"reason": reason,
|
||
"command": _probe_command_for_endpoint(host, ep),
|
||
}
|
||
)
|
||
else:
|
||
light = str(m.get("light") or "black")
|
||
rows.append(
|
||
{
|
||
"section": section,
|
||
"stack": sid,
|
||
"role": role,
|
||
"host": host,
|
||
"id": role or "member",
|
||
"label": role or "member",
|
||
"light": light,
|
||
"reason": str(m.get("reason") or ""),
|
||
"command": _probe_command_for_member(m),
|
||
}
|
||
)
|
||
net = st.get("network") if isinstance(st.get("network"), dict) else {}
|
||
ssh = net.get("ssh") if isinstance(net.get("ssh"), dict) else {}
|
||
if ssh.get("expected") and ssh.get("ok") is not None:
|
||
shown = str(ssh.get("host") or "")
|
||
# Prefer real candidates when not hidden; else keep blank host in cmd
|
||
hosts = list(ssh.get("candidates") or [])
|
||
cmd_host = shown or (hosts[0] if hosts else "")
|
||
rows.append(
|
||
{
|
||
"section": section,
|
||
"stack": sid,
|
||
"role": "ssh",
|
||
"host": shown,
|
||
"id": "ssh",
|
||
"label": "ssh",
|
||
"light": str(ssh.get("light") or ("green" if ssh.get("ok") else "orange")),
|
||
"reason": str(ssh.get("reason") or ""),
|
||
"command": f"TCP {cmd_host or '∅'}:22 banner",
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def _render_result_index(doc: dict, *, mode: str | None = None) -> str:
|
||
"""Optional bottom section: all tests grouped by light colour (+ optional command)."""
|
||
mode = (mode or getattr(L, "RESULT_INDEX_MODE", "off") or "off").lower()
|
||
if mode in ("0", "off", "false", "no", ""):
|
||
return ""
|
||
show_cmd = mode in ("cmd", "full", "commands", "2")
|
||
rows = collect_result_index_rows(doc)
|
||
if not rows:
|
||
return ""
|
||
order = ("green", "blue", "yellow", "orange", "red", "black", "white")
|
||
by: dict[str, list[dict]] = {}
|
||
for r in rows:
|
||
by.setdefault(str(r.get("light") or "black"), []).append(r)
|
||
groups = []
|
||
for light in order:
|
||
items = by.get(light) or []
|
||
if not items:
|
||
continue
|
||
lis = []
|
||
for r in items:
|
||
tip = r.get("reason") or _label(light)
|
||
cmd = ""
|
||
if show_cmd and r.get("command"):
|
||
cmd = f' <code class="ri-cmd" title="probe">{_esc(r["command"])}</code>'
|
||
host = r.get("host") or ""
|
||
host_bit = f" @ {_esc(host)}" if host else ""
|
||
lis.append(
|
||
"<li>"
|
||
f'<span class="ri-stack">{_esc(r.get("stack"))}</span> · '
|
||
f'{_esc(r.get("label"))}{host_bit}'
|
||
f' <span title="{_esc(tip)}">({_esc(_label(light))})</span>'
|
||
f"{cmd}"
|
||
"</li>"
|
||
)
|
||
groups.append(
|
||
f'<div class="ri-group" data-light="{_esc(light)}">'
|
||
f'<div class="ri-hd"><span class="dot {_esc(light)}"></span>'
|
||
f'{_esc(_label(light))} · {len(items)}</div>'
|
||
f'<ul>{"".join(lis)}</ul></div>'
|
||
)
|
||
title = "all probes by colour"
|
||
if show_cmd:
|
||
title += " · with command"
|
||
return (
|
||
f'<section class="result-index" id="result-index">'
|
||
f"<h3>{_esc(title)} ({len(rows)})</h3>"
|
||
f'{"".join(groups)}'
|
||
f"</section>"
|
||
)
|
||
|
||
|
||
def _agg_text(st: dict) -> str:
|
||
bd = st.get("breakdown") if isinstance(st.get("breakdown"), dict) else {}
|
||
by = bd.get("by_light") if isinstance(bd.get("by_light"), dict) else {}
|
||
if not by:
|
||
# fallback from members
|
||
by = {}
|
||
for m in st.get("members") or []:
|
||
if (m.get("reason") or "") in getattr(L, "_AGG_SKIP_REASONS", ()):
|
||
continue
|
||
Lgt = str(m.get("light") or "black")
|
||
by[Lgt] = by.get(Lgt, 0) + 1
|
||
if not by:
|
||
return ""
|
||
order = ("green", "blue", "yellow", "orange", "red", "black")
|
||
parts = []
|
||
for k in order:
|
||
n = int(by.get(k) or 0)
|
||
if n:
|
||
parts.append(f"{n}× {_label(k)}")
|
||
worst = st.get("light") or bd.get("worst") or ""
|
||
if not parts:
|
||
return ""
|
||
return f"{', '.join(parts)} → {_label(worst)}"
|
||
|
||
|
||
def _software_items(st: dict) -> list[dict]:
|
||
raw = st.get("software") if isinstance(st.get("software"), list) else []
|
||
if raw:
|
||
return [x for x in raw if isinstance(x, dict) and x.get("package")]
|
||
# Fallback: vulns.servers only
|
||
vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
|
||
out = []
|
||
for srv in vulns.get("servers") or []:
|
||
p = str((srv or {}).get("package") or "")
|
||
if not p:
|
||
continue
|
||
out.append(
|
||
{
|
||
"package": p,
|
||
"version": (srv or {}).get("version") or "",
|
||
"host": (srv or {}).get("host") or "",
|
||
"light": "green",
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _render_software_row(st: dict) -> str:
|
||
items = _software_items(st)
|
||
if not items:
|
||
return ""
|
||
# Collapse identical package+version (multi-host) into one chip.
|
||
merged: dict[str, dict] = {}
|
||
for it in items:
|
||
pkg = str(it.get("package") or "")
|
||
ver = str(it.get("version") or "")
|
||
key = f"{pkg}|{ver}"
|
||
if key not in merged:
|
||
merged[key] = {
|
||
"package": pkg,
|
||
"version": ver,
|
||
"label": it.get("label") or pkg,
|
||
"light": it.get("light") or "green",
|
||
"hosts": [],
|
||
"source": it.get("source") or "",
|
||
"currency_status": it.get("currency_status") or "",
|
||
"behind": it.get("behind") or "",
|
||
"published_upstream": it.get("published_upstream") or "",
|
||
"delta": it.get("delta"),
|
||
}
|
||
h = str(it.get("host") or "")
|
||
if h and h not in merged[key]["hosts"]:
|
||
merged[key]["hosts"].append(h)
|
||
# keep worst light
|
||
Lgt = str(it.get("light") or "green")
|
||
cur = str(merged[key]["light"] or "green")
|
||
if L.SEVERITY.get(Lgt, 0) > L.SEVERITY.get(cur, 0):
|
||
merged[key]["light"] = Lgt
|
||
if it.get("currency_status") == "behind":
|
||
merged[key]["currency_status"] = "behind"
|
||
merged[key]["behind"] = it.get("behind") or merged[key].get("behind")
|
||
merged[key]["published_upstream"] = (
|
||
it.get("published_upstream") or merged[key].get("published_upstream")
|
||
)
|
||
if it.get("delta") is not None:
|
||
merged[key]["delta"] = it.get("delta")
|
||
chips = []
|
||
for it in list(merged.values())[:8]:
|
||
pkg = str(it.get("package") or "")
|
||
ver = str(it.get("version") or "")
|
||
light = str(it.get("light") or "green")
|
||
hosts = it.get("hosts") or []
|
||
label = str(it.get("label") or pkg)
|
||
logo_fn = _LOGO_SVG.get(pkg)
|
||
logo = logo_fn() if callable(logo_fn) else _logo_svg(pkg, fill="#445566", text=(pkg[:3] or "?"), size="14")
|
||
cur = str(it.get("currency_status") or "")
|
||
behind = str(it.get("behind") or it.get("published_upstream") or "")
|
||
delta = it.get("delta")
|
||
ver_txt = ver or "?"
|
||
if cur == "behind" and behind:
|
||
lag = f" −{delta}" if isinstance(delta, int) and delta > 0 else ""
|
||
ver_txt = f"{ver_txt} → {behind}{lag}"
|
||
elif cur == "current" and ver:
|
||
ver_txt = f"{ver_txt} ✓"
|
||
tip = " · ".join(
|
||
x
|
||
for x in (
|
||
label,
|
||
ver,
|
||
f"pub {behind}" if behind else "",
|
||
cur,
|
||
", ".join(hosts[:4]),
|
||
it.get("source") or "",
|
||
)
|
||
if x
|
||
)
|
||
chips.append(
|
||
f'<span class="sw light-{_esc(light)}" title="{_esc(tip)}">'
|
||
f"{logo}"
|
||
f'<span class="ver">{_esc(ver_txt)}</span>'
|
||
f"</span>"
|
||
)
|
||
return f'<div class="swrow">{"".join(chips)}</div>'
|
||
|
||
|
||
def _card_dialog_payload(st: dict) -> dict:
|
||
"""JSON-serializable detail for the open dialog."""
|
||
members = []
|
||
for m in st.get("members") or []:
|
||
reason = m.get("reason") or ""
|
||
es = m.get("endpoints_summary") if isinstance(m.get("endpoints_summary"), dict) else {}
|
||
# If endpoints pulled the light up, surface worst endpoint id/reason.
|
||
if es and (m.get("light") or "") not in ("green", "blue", None, ""):
|
||
bad = []
|
||
for ep in m.get("endpoints") or []:
|
||
if ep.get("skipped"):
|
||
continue
|
||
el = ep.get("light") or "green"
|
||
if el in ("yellow", "orange", "red", "black"):
|
||
bad.append(f"{ep.get('id') or ep.get('path')}:{ep.get('reason') or el}")
|
||
if bad and reason in ("ok", "", None):
|
||
reason = ", ".join(bad[:4])
|
||
elif bad:
|
||
reason = f"{reason} · " + ", ".join(bad[:3])
|
||
members.append(
|
||
{
|
||
"role": m.get("role"),
|
||
"host": m.get("host"),
|
||
"light": m.get("light"),
|
||
"label": _label(m.get("light")),
|
||
"reason": reason,
|
||
"code": m.get("code"),
|
||
}
|
||
)
|
||
hits = []
|
||
vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
|
||
for h in vulns.get("hits") or []:
|
||
hits.append(
|
||
{
|
||
"id": h.get("id"),
|
||
"severity": h.get("severity"),
|
||
"package": h.get("package"),
|
||
"version": h.get("version"),
|
||
"host": h.get("host"),
|
||
"summary": h.get("summary") or "",
|
||
"url": _osv_url(str(h.get("id") or "")),
|
||
}
|
||
)
|
||
tls = st.get("tls") if isinstance(st.get("tls"), dict) else {}
|
||
ports = st.get("ports") if isinstance(st.get("ports"), dict) else {}
|
||
software = []
|
||
for it in _software_items(st)[:12]:
|
||
software.append(
|
||
{
|
||
"package": it.get("package"),
|
||
"label": it.get("label") or it.get("package"),
|
||
"version": it.get("version") or "",
|
||
"host": it.get("host") or "",
|
||
"light": it.get("light") or "green",
|
||
"source": it.get("source") or "",
|
||
"currency_status": it.get("currency_status") or "",
|
||
"published": it.get("published") or "",
|
||
"behind": it.get("behind") or "",
|
||
"delta": it.get("delta"),
|
||
}
|
||
)
|
||
return {
|
||
"id": st.get("id"),
|
||
"light": st.get("light"),
|
||
"label": _label(st.get("light")),
|
||
"agg": _agg_text(st),
|
||
"members": members,
|
||
"hits": hits,
|
||
"tls": tls,
|
||
"ports": ports.get("suspicious") or [],
|
||
"software": software,
|
||
"deliverability": st.get("deliverability")
|
||
if isinstance(st.get("deliverability"), dict)
|
||
else {},
|
||
"kind": st.get("kind"),
|
||
"label_soft": st.get("service_label") or "",
|
||
}
|
||
|
||
|
||
def _render_card(st: dict, *, section: str = "stack") -> str:
|
||
light = st.get("light") or "black"
|
||
intensity = st.get("intensity") or "muted"
|
||
klass = f"card {intensity}-{light}"
|
||
cur = st.get("currency") or ""
|
||
ci = st.get("currency_info") if isinstance(st.get("currency_info"), dict) else {}
|
||
symbol = ci.get("symbol") or ""
|
||
backend = st.get("bank_backend") or ""
|
||
service_label = st.get("service_label") or ""
|
||
|
||
bits = [x for x in (service_label, cur, symbol if symbol != cur else "", _label(light)) if x]
|
||
if backend == "ebics":
|
||
bits.insert(-1 if bits else 0, "IBAN")
|
||
elif backend == "x-taler-bank":
|
||
bits.insert(-1 if bits else 0, "x-taler-bank")
|
||
|
||
net = st.get("network") if isinstance(st.get("network"), dict) else {}
|
||
ssh = net.get("ssh") if isinstance(net.get("ssh"), dict) else {}
|
||
sub_extra = []
|
||
if net.get("zone"):
|
||
sub_extra.append(_esc(str(net.get("zone"))))
|
||
if ssh.get("expected"):
|
||
if ssh.get("ok") is True:
|
||
sub_extra.append('<span class="ok">ssh</span>')
|
||
elif ssh.get("ok") is False:
|
||
sub_extra.append('<span class="bad">ssh!</span>')
|
||
vulns = st.get("vulns") if isinstance(st.get("vulns"), dict) else {}
|
||
hits = vulns.get("hits") or []
|
||
if hits:
|
||
top = hits[0]
|
||
cls = "crit" if (vulns.get("worst") or "") == "CRITICAL" else "bad"
|
||
sub_extra.append(
|
||
f'<span class="{cls}" title="{_esc(top.get("id"))}">cve</span>'
|
||
)
|
||
|
||
why = _agg_text(st)
|
||
why_html = f'<div class="why" title="{_esc(why)}">{_esc(why)}</div>' if why else ""
|
||
|
||
rows = []
|
||
for m in st.get("members") or []:
|
||
role = m.get("role") or ""
|
||
if role == "cve":
|
||
continue
|
||
host = m.get("host") or ""
|
||
if not host and (m.get("reason") or "") in ("ebics_iban", "ebics_no_bank"):
|
||
host = "IBAN"
|
||
ml = m.get("light") or "black"
|
||
tip = m.get("reason") or _label(ml)
|
||
rows.append(
|
||
"<div class=\"row\">"
|
||
f'<span class="dot {_esc(ml)}" title="{_esc(tip)}"></span>'
|
||
f'<span class="role">{_esc(role)}</span>'
|
||
f'<span class="host" title="{_esc(host)} · {_esc(tip)}">{_esc(_short_host(host))}</span>'
|
||
"</div>"
|
||
)
|
||
|
||
tags = []
|
||
if st.get("commercial_prod"):
|
||
tags.append('<span class="tag">prod</span>')
|
||
if backend:
|
||
tags.append(f'<span class="tag">{_esc(backend)}</span>')
|
||
if net.get("zone"):
|
||
tags.append(f'<span class="tag">{_esc(net.get("zone"))}</span>')
|
||
if ssh.get("expected"):
|
||
if ssh.get("ok") is True:
|
||
tags.append('<span class="tag">ssh</span>')
|
||
elif ssh.get("ok") is False:
|
||
tags.append('<span class="tag warn">ssh</span>')
|
||
tls = st.get("tls") if isinstance(st.get("tls"), dict) else {}
|
||
if tls.get("days_left") is not None:
|
||
try:
|
||
days = int(tls["days_left"])
|
||
except (TypeError, ValueError):
|
||
days = None
|
||
if days is not None:
|
||
if days < 14:
|
||
tags.append(f'<span class="tag crit">cert {days}d</span>')
|
||
elif days < 30:
|
||
tags.append(f'<span class="tag warn">cert {days}d</span>')
|
||
else:
|
||
tags.append(f'<span class="tag">cert</span>')
|
||
if st.get("orphan"):
|
||
tags.append('<span class="tag warn">orphan?</span>')
|
||
deliver = st.get("deliverability") if isinstance(st.get("deliverability"), dict) else {}
|
||
if deliver:
|
||
dl = str(deliver.get("light") or "yellow")
|
||
reason = str(deliver.get("reason") or "deliver")
|
||
age = deliver.get("age_s")
|
||
age_s = ""
|
||
try:
|
||
if age is not None:
|
||
a = float(age)
|
||
age_s = f"{int(a // 3600)}h" if a >= 3600 else f"{int(a // 60)}m"
|
||
except (TypeError, ValueError):
|
||
age_s = ""
|
||
tip = " · ".join(x for x in (reason, deliver.get("rcpt") or "", age_s) if x)
|
||
cls = "tag"
|
||
if dl in ("orange", "red"):
|
||
cls = "tag warn" if dl == "orange" else "tag crit"
|
||
elif dl == "yellow" or deliver.get("stale"):
|
||
cls = "tag warn"
|
||
tags.append(f'<span class="{cls}" title="{_esc(tip)}">deliver</span>')
|
||
ports = st.get("ports") if isinstance(st.get("ports"), dict) else {}
|
||
n_sus = int(ports.get("n_suspicious") or 0)
|
||
if n_sus:
|
||
tip = ", ".join(
|
||
f"{s.get('label') or s.get('port')}"
|
||
+ (f"@{s.get('host')}" if s.get("host") else "")
|
||
for s in (ports.get("suspicious") or [])[:8]
|
||
)
|
||
tags.append(
|
||
f'<span class="tag {"crit" if (ports.get("light") or "") == "red" else "warn"}" '
|
||
f'title="{_esc(tip)}">ports:{n_sus}</span>'
|
||
)
|
||
for h in hits[:3]:
|
||
cid = str(h.get("id") or "cve")
|
||
cls = "tag crit" if (h.get("severity") or "") == "CRITICAL" else "tag warn"
|
||
tip = " · ".join(
|
||
x
|
||
for x in (
|
||
h.get("severity") or "",
|
||
f"{h.get('package')}/{h.get('version')}" if h.get("package") else "",
|
||
(h.get("summary") or "")[:80],
|
||
)
|
||
if x
|
||
)
|
||
tags.append(
|
||
f'<a class="{cls}" href="{_esc(_osv_url(cid))}" target="_blank" rel="noopener" '
|
||
f'title="{_esc(tip)}" onclick="event.stopPropagation()">{_esc(cid)}</a>'
|
||
)
|
||
stats = st.get("stats") if isinstance(st.get("stats"), dict) else {}
|
||
if stats.get("present"):
|
||
tags.append('<span class="tag">stats</span>')
|
||
denoms = (ci.get("denoms") or [])[:6]
|
||
if denoms:
|
||
tags.append(
|
||
f'<span class="tag" title="{_esc(" · ".join(str(x) for x in denoms))}">denoms</span>'
|
||
)
|
||
|
||
payload = _esc(json.dumps(_card_dialog_payload(st), ensure_ascii=False))
|
||
title = st.get("id") or "?"
|
||
sw_html = _render_software_row(st)
|
||
return (
|
||
f'<article class="{_esc(klass)}" tabindex="0" data-section="{_esc(section)}" '
|
||
f'data-detail="{payload}" onclick="openStack(this)" '
|
||
f'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){{event.preventDefault();openStack(this)}}">'
|
||
f"<h2>{_esc(title)}</h2>"
|
||
f'<div class="sub">{_esc(" · ".join(bits))}'
|
||
f'{(" · " + " ".join(sub_extra)) if sub_extra else ""}</div>'
|
||
f"{why_html}"
|
||
f'<div class="rows">{"".join(rows)}</div>'
|
||
f"{sw_html}"
|
||
f'<div class="tags">{"".join(tags)}</div>'
|
||
"</article>"
|
||
)
|
||
|
||
|
||
JS = """
|
||
function openStack(el) {
|
||
let raw = el.getAttribute('data-detail') || '{}';
|
||
let d;
|
||
try { d = JSON.parse(raw); } catch (e) { return; }
|
||
const dlg = document.getElementById('stack-dlg');
|
||
const title = document.getElementById('dlg-title');
|
||
const body = document.getElementById('dlg-body');
|
||
title.textContent = (d.id || '?') + ' · ' + (d.label || '');
|
||
let html = '';
|
||
if (d.agg) html += '<p class="agg">' + escapeHtml(d.agg) + '</p>';
|
||
html += '<ul>';
|
||
for (const m of (d.members || [])) {
|
||
if (m.role === 'cve') continue;
|
||
const tip = m.reason || m.label || '';
|
||
html += '<li><strong>' + escapeHtml(m.role || '') + '</strong> '
|
||
+ escapeHtml(m.host || '') + ' — ' + escapeHtml(m.label || '')
|
||
+ (tip ? ' <span style="color:#8b98a5">(' + escapeHtml(tip) + ')</span>' : '')
|
||
+ '</li>';
|
||
}
|
||
html += '</ul>';
|
||
if (d.tls && (d.tls.not_after || d.tls.days_left != null)) {
|
||
html += '<p class="agg">TLS · days_left=' + escapeHtml(d.tls.days_left)
|
||
+ (d.tls.not_after ? ' · until ' + escapeHtml(d.tls.not_after) : '')
|
||
+ '</p>';
|
||
if (d.tls.sans && d.tls.sans.length) {
|
||
html += '<p class="agg">SANs: ' + escapeHtml(d.tls.sans.join(', ')) + '</p>';
|
||
}
|
||
}
|
||
if (d.hits && d.hits.length) {
|
||
html += '<div class="cve"><strong>CVEs</strong><ul>';
|
||
for (const h of d.hits) {
|
||
html += '<li><a href="' + escapeHtml(h.url) + '" target="_blank" rel="noopener">'
|
||
+ escapeHtml(h.id || 'cve') + '</a> · ' + escapeHtml(h.severity || '')
|
||
+ ' · ' + escapeHtml((h.package || '') + '/' + (h.version || ''))
|
||
+ (h.summary ? '<br/><span style="color:#8b98a5">' + escapeHtml(h.summary) + '</span>' : '')
|
||
+ '</li>';
|
||
}
|
||
html += '</ul></div>';
|
||
}
|
||
if (d.software && d.software.length) {
|
||
html += '<div class="cve"><strong>Software</strong><ul>';
|
||
for (const s of d.software) {
|
||
html += '<li>' + escapeHtml(s.label || s.package)
|
||
+ (s.version ? ' · ' + escapeHtml(s.version) : '')
|
||
+ (s.currency_status === 'behind' && s.behind
|
||
? ' → ' + escapeHtml(s.behind) + (s.delta != null ? ' (−' + escapeHtml(s.delta) + ')' : '')
|
||
: '')
|
||
+ (s.currency_status ? ' · ' + escapeHtml(s.currency_status) : '')
|
||
+ (s.host ? ' @ ' + escapeHtml(s.host) : '')
|
||
+ '</li>';
|
||
}
|
||
html += '</ul></div>';
|
||
}
|
||
if (d.deliverability && (d.deliverability.reason || d.deliverability.light)) {
|
||
const deliv = d.deliverability;
|
||
html += '<div class="cve"><strong>Deliverability</strong><ul>';
|
||
html += '<li>' + escapeHtml(deliv.reason || '')
|
||
+ (deliv.rcpt ? ' · rcpt ' + escapeHtml(deliv.rcpt) : '')
|
||
+ (deliv.mx ? ' · mx ' + escapeHtml(deliv.mx) : '')
|
||
+ (deliv.age_s != null ? ' · age ' + escapeHtml(deliv.age_s) + 's' : '')
|
||
+ (deliv.stale ? ' · stale' : '')
|
||
+ (deliv.detail ? '<br/><span style="color:#8b98a5">' + escapeHtml(deliv.detail) : '')
|
||
+ '</li></ul></div>';
|
||
}
|
||
if (d.ports && d.ports.length) {
|
||
html += '<div class="cve"><strong>Suspicious ports</strong><ul>';
|
||
for (const p of d.ports) {
|
||
html += '<li>' + escapeHtml(p.label || p.port)
|
||
+ (p.host ? ' @ ' + escapeHtml(p.host) : '')
|
||
+ ' <span style="color:#8b98a5">(whitelist in ports-whitelist.conf)</span></li>';
|
||
}
|
||
html += '</ul></div>';
|
||
}
|
||
body.innerHTML = html;
|
||
if (typeof dlg.showModal === 'function') dlg.showModal();
|
||
else dlg.setAttribute('open', '');
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"');
|
||
}
|
||
"""
|
||
|
||
|
||
def render_html(doc: dict) -> str:
|
||
stacks = doc.get("stacks") or []
|
||
services = doc.get("services") or []
|
||
mail = doc.get("mail") or []
|
||
orphans = doc.get("orphans") or []
|
||
ports_doc = doc.get("ports") if isinstance(doc.get("ports"), dict) else {}
|
||
published = doc.get("published") if isinstance(doc.get("published"), dict) else {}
|
||
ts = doc.get("ts")
|
||
age = None
|
||
try:
|
||
age = time.time() - float(ts)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
stale = age is not None and age > 3 * float(doc.get("acute_down_s") or L.ACUTE_DOWN_S)
|
||
if age is None:
|
||
updated = "?"
|
||
elif age < 120:
|
||
updated = f"{int(age)}s"
|
||
elif age < 3600:
|
||
updated = f"{int(age // 60)}m"
|
||
else:
|
||
updated = f"{age / 3600:.1f}h"
|
||
try:
|
||
updated += " · " + time.strftime("%H:%M", time.localtime(float(ts)))
|
||
except (TypeError, ValueError, OSError):
|
||
pass
|
||
|
||
stack_cards = [_render_card(st, section="stack") for st in stacks]
|
||
# Firefly-plane (git / Mattermost / buildbot / gnunet) near mail; VeciGate edge; rest = company
|
||
def _zone(st: dict) -> str:
|
||
return str((st.get("network") or {}).get("zone") or st.get("zone") or "").lower()
|
||
|
||
firefly_services = [st for st in services if _zone(st) == "firefly"]
|
||
vecigate_services = [st for st in services if _zone(st) == "vecigate"]
|
||
other_services = [
|
||
st for st in services if _zone(st) not in ("firefly", "vecigate")
|
||
]
|
||
service_cards = [_render_card(st, section="service") for st in other_services]
|
||
firefly_cards = [_render_card(st, section="service") for st in firefly_services]
|
||
vecigate_cards = [_render_card(st, section="service") for st in vecigate_services]
|
||
mail_cards = [_render_card(st, section="mail") for st in mail]
|
||
|
||
score = doc.get("score") if isinstance(doc.get("score"), dict) else None
|
||
if not score:
|
||
score = L.compute_total_score(stacks, services, mail)
|
||
s_light = str(score.get("light") or "white")
|
||
s_green = int(score.get("green") or 0)
|
||
s_total = int(score.get("total") or 0)
|
||
s_pct = int(score.get("pct") or 0)
|
||
score_html = (
|
||
f'<div class="score light-{_esc(s_light)}" title="green/total → {_esc(_label(s_light))}">'
|
||
f'<span class="dot {_esc(s_light)}"></span>'
|
||
f'<span>{s_green}/{s_total} green</span>'
|
||
f'<span class="meta">({s_pct}%) · {_esc(_label(s_light))}</span>'
|
||
f"</div>"
|
||
)
|
||
|
||
orphan_html = ""
|
||
if orphans:
|
||
items = "".join(
|
||
f'<li>{_esc(o.get("host"))} · {_esc(o.get("reason") or "orphan")}</li>'
|
||
for o in orphans[:40]
|
||
)
|
||
orphan_html = f'<h3 class="sec">orphans</h3><ul style="color:var(--muted);font-size:.78rem;margin:.2rem 1rem 1rem">{items}</ul>'
|
||
|
||
ports_html = ""
|
||
sus_all = ports_doc.get("suspicious") or []
|
||
if ports_doc.get("enabled") and sus_all:
|
||
n_hard = int(ports_doc.get("n_hard") or 0)
|
||
items = "".join(
|
||
f'<li><span class="dot {_esc(s.get("light") or "yellow")}"></span> '
|
||
f'{_esc(s.get("label") or s.get("port"))}'
|
||
f'{(" @ " + _esc(s.get("host"))) if s.get("host") else ""}'
|
||
f' · {_esc(s.get("severity") or "soft")}</li>'
|
||
for s in sus_all[:60]
|
||
)
|
||
ports_html = (
|
||
f'<h3 class="sec">suspicious ports ({len(sus_all)}'
|
||
f'{f", {n_hard} hard" if n_hard else ""}) · whitelist: ports-whitelist.conf</h3>'
|
||
f'<ul style="color:var(--muted);font-size:.78rem;margin:.2rem 1rem 1rem;'
|
||
f'list-style:none;padding-left:0;display:flex;flex-direction:column;gap:.25rem">{items}</ul>'
|
||
)
|
||
|
||
sections = []
|
||
sections.append("<h3 class=\"sec\">taler stacks</h3>")
|
||
empty = '<p class="meta">No stacks.</p>'
|
||
sections.append(f'<main class="grid">{"".join(stack_cards) if stack_cards else empty}</main>')
|
||
if service_cards:
|
||
sections.append('<h3 class="sec">company / services</h3>')
|
||
sections.append(f'<main class="grid">{"".join(service_cards)}</main>')
|
||
if firefly_cards or mail_cards:
|
||
sections.append('<h3 class="sec">firefly plane · git / Mattermost / buildbot / mail</h3>')
|
||
if firefly_cards:
|
||
sections.append(f'<main class="grid">{"".join(firefly_cards)}</main>')
|
||
if mail_cards:
|
||
sections.append(f'<main class="grid">{"".join(mail_cards)}</main>')
|
||
if vecigate_cards:
|
||
sections.append('<h3 class="sec">VeciGate edge · WAN / tortuna companion ports</h3>')
|
||
sections.append(f'<main class="grid">{"".join(vecigate_cards)}</main>')
|
||
apps = published.get("apps") or []
|
||
apps_html = ""
|
||
if apps:
|
||
chips = []
|
||
for app in apps[:12]:
|
||
if not isinstance(app, dict):
|
||
continue
|
||
logo_key = str(app.get("logo") or app.get("kind") or "webui")
|
||
logo_fn = _LOGO_SVG.get(logo_key) or _LOGO_SVG.get("webui")
|
||
logo = logo_fn() if callable(logo_fn) else _logo_svg(logo_key, fill="#0d9488", text="app", size="14")
|
||
pub = str(app.get("published_upstream") or app.get("published") or "?")
|
||
light = str(app.get("light") or "green")
|
||
tip = " · ".join(
|
||
x
|
||
for x in (
|
||
app.get("label") or app.get("package"),
|
||
app.get("package"),
|
||
app.get("published"),
|
||
published.get("suite") or "",
|
||
app.get("source") or "deb.taler.net",
|
||
)
|
||
if x
|
||
)
|
||
name = str(app.get("label") or app.get("package") or "app")
|
||
chips.append(
|
||
f'<span class="sw light-{_esc(light)}" title="{_esc(tip)}">'
|
||
f"{logo}"
|
||
f'<span class="ver">{_esc(name)} {_esc(pub)}</span>'
|
||
f"</span>"
|
||
)
|
||
suite = published.get("suite") or ""
|
||
apps_html = (
|
||
f'<h3 class="sec">published apps / SPAs'
|
||
f'{(" · " + _esc(suite)) if suite else ""} · deb.taler.net</h3>'
|
||
f'<div class="swrow" style="padding:0 1rem 1rem">{"".join(chips)}</div>'
|
||
)
|
||
|
||
if apps_html:
|
||
sections.append(apps_html)
|
||
if ports_html:
|
||
sections.append(ports_html)
|
||
if orphan_html:
|
||
sections.append(orphan_html)
|
||
|
||
result_index_html = _render_result_index(doc)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<meta http-equiv="refresh" content="30"/>
|
||
<title>LLM Lightweight Monitoring · stacks</title>
|
||
<style>{CSS}</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>LLM · stacks</h1>
|
||
{score_html}
|
||
<div class="meta{' stale' if stale else ''}">updated {_esc(updated)} · Lightweight Monitoring</div>
|
||
</header>
|
||
{''.join(sections)}
|
||
{result_index_html}
|
||
<footer>LLM Lightweight Monitoring · OK · Present · Soft · Degraded · Down · Gone · click card for why</footer>
|
||
<dialog id="stack-dlg">
|
||
<div class="dlg-hd">
|
||
<h2 id="dlg-title">detail</h2>
|
||
<button type="button" onclick="document.getElementById('stack-dlg').close()">close</button>
|
||
</div>
|
||
<div class="dlg-bd" id="dlg-body"></div>
|
||
</dialog>
|
||
<script>{JS}</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="Render stack status HTML from JSON")
|
||
ap.add_argument("--status", type=Path, default=None)
|
||
ap.add_argument("--out", type=Path, required=True)
|
||
args = ap.parse_args()
|
||
if L.load_monitoring_env():
|
||
L.refresh_runtime_flags_from_env()
|
||
doc = L.read_status(args.status)
|
||
if not doc:
|
||
doc = {"schema": L.SCHEMA, "ts": time.time(), "stacks": [], "legend": L.LEGEND}
|
||
html_text = render_html(doc)
|
||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = args.out.with_name(f".{args.out.name}.tmp.{os.getpid()}")
|
||
try:
|
||
tmp.write_text(html_text, encoding="utf-8")
|
||
os.replace(tmp, args.out)
|
||
finally:
|
||
if tmp.exists():
|
||
try:
|
||
tmp.unlink()
|
||
except OSError:
|
||
pass
|
||
print(args.out)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|