diff --git a/bullseye/status.py b/bullseye/status.py new file mode 100644 index 0000000..cfc31f9 --- /dev/null +++ b/bullseye/status.py @@ -0,0 +1,91 @@ +"""Render bullpen-doctor --json output as a self-contained HTML fragment.""" + +from html import escape + + +def render_status(doctor_json): + """Return a single HTML fragment string describing the doctor's report.""" + ts = doctor_json["ts"] + hosts = doctor_json["hosts"] + agents = doctor_json["agents"] + grinds = doctor_json["grinds"] + verdict = doctor_json["verdict"] + flags = doctor_json["flags"] + + parts = [] + + # Timestamp + parts.append(f'
{escape(str(ts))}
') + + # Hosts table + if hosts: + parts.append('') + for hostname in sorted(hosts): + info = hosts[hostname] + load = info["load"] + temp_c = info["temp_c"] + mem = info["mem"] + reachable = info["reachable"] + + # None-safe rendering + load_str = escape(str(load)) if load is not None else "-" + temp_str = escape(str(temp_c)) if temp_c is not None else "-" + mem_str = escape(str(mem)) if mem is not None else "-" + + # Hot styling + hot_style = ' style="background-color:#ffe0e0;font-weight:bold;"' if temp_c is not None and temp_c >= 80 else '' + + unreachable_text = ' unreachable' if not reachable else '' + + parts.append( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + parts.append('
{escape(hostname)}{load_str}{temp_str}{mem_str}{unreachable_text}
') + else: + parts.append('
No hosts
') + + # Agents + if agents: + parts.append('
Agents:
') + for agent in agents: + name = agent["agent"] + model = agent["model"] + age = agent["age"] + parts.append( + f'
' + f'{escape(name)}: {escape(model)} ({escape(age)})' + f'
' + ) + else: + parts.append('
No agents
') + + # Grinds + parts.append(f'
') + if grinds: + parts.append('Grinds:') + for g in grinds: + parts.append(f'
{escape(g["age"])}
') + else: + parts.append('No grinds') + parts.append('
') + + # Verdict + verdict_styles = { + "busy-healthy": ' style="color:green;font-weight:bold;"', + "idle": ' style="color:grey;font-weight:bold;"', + "needs-a-look": ' style="color:orange;font-weight:bold;"', + } + vs = verdict_styles.get(verdict, '') + parts.append( + f'
' + f'{escape(verdict)}' + f'
' + ) + + return ''.join(parts) diff --git a/tests/test_bullseye_status.py b/tests/test_bullseye_status.py new file mode 100644 index 0000000..f05ed74 --- /dev/null +++ b/tests/test_bullseye_status.py @@ -0,0 +1,367 @@ +# Executable spec for bullseye/status.py :: render_status(doctor_json) -> str +# +# Input: the dict emitted by `bullpen-doctor --json`: +# ts (epoch int); hosts (hostname -> {load: str|None, temp_c: int|None, +# mem: str|None, reachable: bool} — load/mem/temp_c are ALL null when the +# host is fully unreachable); agents (list of {agent, model, pid, age}); +# grinds (list +# of {pid, age}); verdict ("busy-healthy"|"idle"|"needs-a-look"); +# flags ({hot: list, long_jobs: int}). +# +# Contract (machine-checkable hooks the implementation MUST provide): +# * Output is a single self-contained HTML fragment string. Inline style="..." +# only — no , ": {"load": "0.10", "temp_c": 40, + "mem": "1/2MB &", "reachable": True}, + }, + "agents": [{"agent": "", + "model": "evil&co", "pid": "1", "age": "9s"}], + "grinds": [], + "verdict": "idle", + "flags": {"hot": [], "long_jobs": 0}, + } + +def one_host(temp_c): + """Single host whose only variable is temp_c (for the 80-degree boundary). + flags.hot mirrors doctor behaviour.""" + hot = ["vulcan"] if (temp_c is not None and temp_c >= 80) else [] + return { + "ts": 1784624757, + "hosts": {"vulcan": {"load": "0.50", "temp_c": temp_c, + "mem": "1000/2000MB", "reachable": True}}, + "agents": [], + "grinds": [], + "verdict": "idle", + "flags": {"hot": hot, "long_jobs": 0}, + } + +# ---------------------------------------------------------------- helpers + +MARKER = re.compile(r'data-(host|agent|verdict|grinds)\s*=\s*"([^"]*)"') + +def marked_chunks(html): + """Map (kind, value) -> chunk from that data-* marker to the next one.""" + ms = list(MARKER.finditer(html)) + out = {} + for i, m in enumerate(ms): + key = (m.group(1), m.group(2)) + assert key not in out, "duplicate marker %s=%r" % key + end = ms[i + 1].start() if i + 1 < len(ms) else len(html) + out[key] = html[m.start():end] + return out + +def host_chunk(html, hostname): + by_key = marked_chunks(html) + key = ("host", escape(hostname, quote=True)) + assert key in by_key, "no data-host marker for %r; markers: %s" \ + % (hostname, sorted(by_key)) + return by_key[key] + +def styles(chunk): + """Set of inline style attribute values in a chunk.""" + found = re.findall(r"style\s*=\s*(?:\"([^\"]*)\"|'([^']*)')", chunk) + return frozenset((a or b).strip() for a, b in found) + +# --- crude color classifier: green / grey / amber / other ----------------- + +NAMED_COLORS = { + "green": (0, 128, 0), "darkgreen": (0, 100, 0), + "forestgreen": (34, 139, 34), "seagreen": (46, 139, 87), + "limegreen": (50, 205, 50), "mediumseagreen": (60, 179, 113), + "grey": (128, 128, 128), "gray": (128, 128, 128), + "dimgrey": (105, 105, 105), "dimgray": (105, 105, 105), + "darkgrey": (169, 169, 169), "darkgray": (169, 169, 169), + "lightgrey": (211, 211, 211), "lightgray": (211, 211, 211), + "silver": (192, 192, 192), "slategray": (112, 128, 144), + "slategrey": (112, 128, 144), + "orange": (255, 165, 0), "darkorange": (255, 140, 0), + "goldenrod": (218, 165, 32), "gold": (255, 215, 0), +} + +def classify(r, g, b): + if max(r, g, b) - min(r, g, b) <= 32: + return "grey" + if g > r + 16 and g > b + 16: + return "green" + if r >= 140 and r >= g and g >= b + 40 and g >= 70: + return "amber" + return "other" + +def hues_in(chunk): + """Classes of every color literal found inside style attributes.""" + hues = set() + for style in styles(chunk): + s = style.lower() + for h in re.findall(r"#([0-9a-f]{6})\b", s): + hues.add(classify(*(int(h[i:i + 2], 16) for i in (0, 2, 4)))) + for h in re.findall(r"#([0-9a-f]{3})\b", s): + hues.add(classify(*(int(c * 2, 16) for c in h))) + for r_, g_, b_ in re.findall( + r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", s): + hues.add(classify(int(r_), int(g_), int(b_))) + for name, rgb in NAMED_COLORS.items(): + if re.search(r"\b%s\b" % name, s): + hues.add(classify(*rgb)) + return hues + +# ---------------------------------------------------------------- tests + +def test_returns_html_fragment_for_all_fixtures(): + for fx in (healthy(), degraded(), idle_empty(), hostile()): + out = render_status(fx) + assert isinstance(out, str) and out.strip() + assert "<" in out and ">" in out + +def test_host_row_shows_load_temp_mem(): + fx = healthy() + out = render_status(fx) + for name, vitals in fx["hosts"].items(): + chunk = host_chunk(out, name) + assert vitals["load"] in chunk, "%s: load missing" % name + assert str(vitals["temp_c"]) in chunk, "%s: temp missing" % name + assert vitals["mem"] in chunk, "%s: mem missing" % name + +def test_null_temp_renders_without_crash_or_none_leak(): + out = render_status(degraded()) # noether has temp_c=None + chunk = host_chunk(out, "noether") + assert "None" not in chunk, "str(None) leaked into host row" + assert "0.00" in chunk and "0/7820MB" in chunk, \ + "load/mem must still render when temp_c is null" + +def test_hot_temp_marker_switches_exactly_at_80(): + cool_lo = styles(host_chunk(render_status(one_host(45)), "vulcan")) + cool_hi = styles(host_chunk(render_status(one_host(79)), "vulcan")) + hot_edge = styles(host_chunk(render_status(one_host(80)), "vulcan")) + hot = styles(host_chunk(render_status(one_host(85)), "vulcan")) + assert hot_edge, "hot host row has no inline style at all" + assert cool_lo == cool_hi, \ + "styling must not vary between cool temps 45 and 79: %s vs %s" \ + % (sorted(cool_lo), sorted(cool_hi)) + assert hot_edge != cool_hi, "temp_c=80 must be styled as hot (>= boundary)" + assert hot != cool_hi, "temp_c=85 must be styled as hot" + +def test_unreachable_host_carries_marker(): + out = render_status(degraded()) + assert "unreachable" in host_chunk(out, "noether").lower() + assert "unreachable" not in host_chunk(out, "boltzmann").lower(), \ + "reachable host must not be flagged unreachable" + +def test_agents_show_name_model_and_age(): + for fx in (healthy(), degraded()): + out = render_status(fx) + by_key = marked_chunks(out) + for a in fx["agents"]: + key = ("agent", escape(a["agent"], quote=True)) + assert key in by_key, "no data-agent marker for %r" % a["agent"] + chunk = by_key[key] + assert a["model"] in chunk, "%s: model missing" % a["agent"] + assert a["age"] in chunk, "%s: age missing" % a["agent"] + +def test_grinds_count_and_ages_shown(): + fx = degraded() + out = render_status(fx) + assert out.count('data-grinds="2"') == 1, \ + "expected exactly one data-grinds=\"2\" marker" + for g in fx["grinds"]: + assert g["age"] in out, "grind age %r missing" % g["age"] + +def test_idle_empty_input_renders_empty_sections(): + out = render_status(idle_empty()) + by_key = marked_chunks(out) + kinds = [k for k, _ in by_key] + assert "host" not in kinds and "agent" not in kinds, \ + "empty input must not invent host/agent rows" + assert ("grinds", "0") in by_key, "data-grinds=\"0\" marker missing" + assert ("verdict", "idle") in by_key, "data-verdict marker missing" + +def test_verdict_distinct_color_signal_per_verdict(): + expected_hue = {"busy-healthy": "green", "idle": "grey", + "needs-a-look": "amber"} + signatures = {} + for verdict, hue in expected_hue.items(): + fx = healthy() + fx["verdict"] = verdict + out = render_status(fx) + by_key = marked_chunks(out) + key = ("verdict", verdict) + assert key in by_key, "no data-verdict marker for %r" % verdict + chunk = by_key[key] + sans_marker = chunk.replace('data-verdict="%s"' % verdict, "", 1) + assert verdict in sans_marker, \ + "verdict %r not visible as text" % verdict + sig = styles(chunk) + assert sig, "verdict %r element has no inline style" % verdict + assert hue in hues_in(chunk), \ + "verdict %r must signal %s; styles: %s" % (verdict, hue, sorted(sig)) + signatures[verdict] = sig + assert len(set(signatures.values())) == 3, \ + "verdict styles must be pairwise distinct, got: %s" % signatures + +def test_html_escaping_no_raw_passthrough(): + out = render_status(hostile()) + assert " passed through" + assert escape("") in out, \ + "escaped hostname payload missing" + assert escape("") in out, \ + "escaped agent-name payload missing" + assert "evil" not in out and escape("evil&co") in out, \ + "model string not escaped" + assert "&" not in out and "1/2MB &<x>" in out, \ + "mem string not escaped" + # markers must use the escaped value too (attribute context) + by_key = marked_chunks(out) + assert ("host", escape("", quote=True)) in by_key + assert ("agent", escape("", quote=True)) in by_key + +def test_no_external_resources_anywhere(): + for fx in (healthy(), degraded(), idle_empty(), hostile()): + out = render_status(fx).lower() + for banned in ("([^<>]*)<", chunk)] + dashes = [c for c in cells if c in ("-", "–", "—")] + assert len(dashes) >= 2, \ + "null load and null mem must each render as a dash cell " \ + "(same None-safe approach as temp_c); cell texts: %r" \ + % [c for c in cells if c] + assert "1.04" in host_chunk(out, "boltzmann"), \ + "reachable sibling host must still render its real load" + +def test_host_rows_wrapped_in_table_for_innerhtml(): + for fx in (healthy(), degraded(), hostile()): + out = render_status(fx) + first_tr = out.find("") + assert first_tr != -1 and last_tr_close != -1, \ + "host rows must be elements" + table_open = out.find("") + assert table_open != -1 and table_open < first_tr, \ + "' fragment " \ + "is dropped by innerHTML parsing in body context" + assert table_close > last_tr_close, \ + "'' must follow the last ''"