This repository has been archived on 2026-09-10. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
taler-monitoring/site-gen/stack_status_html.py
2026-09-04 19:17:52 +02:00

1050 lines
41 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
"""
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())