first commit
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
#!/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}%) — {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()
|
||||
Reference in New Issue
Block a user