Files
2026-08-14 23:41:57 +08:00

180 lines
7.7 KiB
Python
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 a Claude-style light-theme graph.html dashboard from a .graph_state state file.
Usage:
render_graph_html.py [.graph_state] [graph.html]
Defaults to reading ./.graph_state and writing ./graph.html.
Called by the /graph skill at every checkpoint (initial plan + each fan-in barrier),
so opening graph.html in a browser (it self-refreshes) tracks execution live.
No third-party dependencies — stdlib only.
"""
import html
import json
import sys
from datetime import datetime
STATUS = {
"pending": ("Pending", "#8C8579", "#EFECE3"),
"in_progress": ("In Progress", "#CC785C", "#F7E9E2"),
"shipped": ("Shipped", "#3D7A5A", "#DFEEE4"),
"failed": ("Failed", "#B54A3E", "#F6DEDA"),
"blocked": ("Blocked", "#9A6C3A", "#F2E6D4"),
"skipped": ("Skipped", "#8C8579", "#EFECE3"),
}
def esc(s):
return html.escape(str(s if s is not None else ""))
def node_card(nid, n):
st = n.get("status", "pending")
label, fg, bg = STATUS.get(st, STATUS["pending"])
deps = n.get("deps") or []
deps_str = ", ".join(f"#{d}" for d in deps) if deps else "no deps"
meta = []
if n.get("pr"):
meta.append(f'PR #{esc(n["pr"])}')
if n.get("branch"):
meta.append(f'<code>{esc(n["branch"])}</code>')
if n.get("attempts"):
meta.append(f'attempt {esc(n["attempts"])}')
meta_html = " · ".join(meta)
err = f'<div class="err">{esc(n["error"])}</div>' if n.get("error") else ""
return f"""
<div class="node" style="border-left:4px solid {fg}">
<div class="node-top">
<span class="nid">#{esc(nid)}</span>
<span class="badge" style="color:{fg};background:{bg}">{label}</span>
</div>
<div class="title">{esc(n.get('title','(untitled)'))}</div>
<div class="deps">{esc(deps_str)}</div>
{f'<div class="meta">{meta_html}</div>' if meta_html else ''}
{err}
</div>"""
def mermaid(state):
lines = ["graph LR"]
nodes = state.get("nodes", {})
for nid, n in nodes.items():
t = n.get("title", "")
lines.append(f' n{nid}["#{nid} {t}"]')
for nid, n in nodes.items():
for d in (n.get("deps") or []):
lines.append(f" n{d} --> n{nid}")
# color by status
for st, (_, fg, bg) in STATUS.items():
ids = [f"n{nid}" for nid, n in nodes.items() if n.get("status") == st]
if ids:
lines.append(f" classDef {st} fill:{bg},stroke:{fg},color:#33312B;")
lines.append(f" class {','.join(ids)} {st};")
return "\n".join(lines)
def render(state):
nodes = state.get("nodes", {})
total = len(nodes)
counts = {k: 0 for k in STATUS}
for n in nodes.values():
counts[n.get("status", "pending")] = counts.get(n.get("status", "pending"), 0) + 1
shipped = counts.get("shipped", 0)
pct = int(shipped / total * 100) if total else 0
waves = state.get("waves", [])
cur = state.get("current_wave", 0)
legend = "".join(
f'<span class="lg"><i style="background:{bg};border-color:{fg}"></i>{label}</span>'
for label, fg, bg in STATUS.values()
)
wave_html = ""
for wi, wave in enumerate(waves):
state_cls = "cur" if wi == cur else ("done" if wi < cur else "future")
cards = "".join(node_card(str(nid), nodes.get(str(nid), {"title": f"#{nid}"})) for nid in wave)
wave_html += f"""
<section class="wave {state_cls}">
<h2>Wave {wi} <span class="wcount">×{len(wave)} parallel</span>
{'<span class="pill">running</span>' if wi == cur else ''}</h2>
<div class="nodes">{cards}</div>
</section>"""
stat = lambda k: f'<b style="color:{STATUS[k][1]}">{counts.get(k,0)}</b> {STATUS[k][0].lower()}'
stats = " · ".join(stat(k) for k in ["shipped", "in_progress", "failed", "blocked", "skipped", "pending"])
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="5">
<title>graph · {esc(state.get('task','execution'))}</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
:root {{ --paper:#F5F4EE; --card:#FFFFFF; --ink:#33312B; --muted:#8C8579;
--coral:#CC785C; --line:#E7E3D9; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--paper); color:var(--ink);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }}
.wrap {{ max-width:1080px; margin:0 auto; padding:32px 24px 64px; }}
header {{ border-bottom:1px solid var(--line); padding-bottom:20px; margin-bottom:24px; }}
h1 {{ font-size:22px; margin:0 0 4px; font-weight:650; }}
.sub {{ color:var(--muted); font-size:13px; }}
.bar {{ height:8px; background:var(--line); border-radius:99px; margin:16px 0 8px; overflow:hidden; }}
.bar>i {{ display:block; height:100%; width:{pct}%; background:var(--coral); border-radius:99px; }}
.stats {{ font-size:13px; color:var(--muted); }}
.legend {{ display:flex; gap:14px; flex-wrap:wrap; margin:14px 0 4px; font-size:12px; color:var(--muted); }}
.lg {{ display:inline-flex; align-items:center; gap:6px; }}
.lg i {{ width:12px; height:12px; border-radius:3px; border:1px solid; display:inline-block; }}
.diagram {{ background:var(--card); border:1px solid var(--line); border-radius:14px; padding:18px; margin:20px 0; overflow:auto; }}
.wave {{ margin:22px 0; }}
.wave h2 {{ font-size:15px; margin:0 0 12px; display:flex; align-items:center; gap:10px; }}
.wcount {{ font-weight:400; color:var(--muted); font-size:12px; }}
.pill {{ font-size:11px; color:#fff; background:var(--coral); padding:2px 9px; border-radius:99px; }}
.wave.future {{ opacity:.55; }}
.nodes {{ display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:12px; }}
.node {{ background:var(--card); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }}
.node-top {{ display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }}
.nid {{ font-weight:650; color:var(--muted); font-size:13px; }}
.badge {{ font-size:11px; padding:2px 8px; border-radius:99px; font-weight:600; }}
.title {{ font-weight:550; margin-bottom:6px; }}
.deps {{ font-size:12px; color:var(--muted); }}
.meta {{ font-size:12px; color:var(--muted); margin-top:6px; }}
.meta code, .node code {{ background:var(--paper); padding:1px 5px; border-radius:5px; font-size:11px; }}
.err {{ font-size:12px; color:#B54A3E; margin-top:6px; white-space:pre-wrap; }}
footer {{ margin-top:32px; color:var(--muted); font-size:12px; text-align:center; }}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>{esc(state.get('task','Task Graph Execution'))}</h1>
<div class="sub">{esc(state.get('repo',''))} · wave {cur} of {max(len(waves)-1,0)} · updated {esc(state.get('updated_at',''))}</div>
<div class="bar"><i></i></div>
<div class="stats">{shipped}/{total} shipped ({pct}%) &nbsp;—&nbsp; {stats}</div>
<div class="legend">{legend}</div>
</header>
<div class="diagram"><pre class="mermaid">{esc(mermaid(state))}</pre></div>
{wave_html}
<footer>Auto-refreshes every 5s · generated by /graph from <code>.graph_state</code></footer>
</div>
<script>mermaid.initialize({{ startOnLoad:true, theme:"neutral" }});</script>
</body>
</html>"""
def main():
src = sys.argv[1] if len(sys.argv) > 1 else ".graph_state"
dst = sys.argv[2] if len(sys.argv) > 2 else "graph.html"
with open(src, encoding="utf-8") as f:
state = json.load(f)
state.setdefault("updated_at", datetime.now().isoformat(timespec="seconds"))
with open(dst, "w", encoding="utf-8") as f:
f.write(render(state))
print(f"wrote {dst} from {src}")
if __name__ == "__main__":
main()