feat: Jon Snow dashboard page at agents.nxm.co.za/jon-snow/

- _render_jon_page(): generates index.html on every request with
  all-time cost in ZAR, today's cost, token counts, last 3 tasks
  (title + project + Claude/Python badge), and per-intent breakdown
- Task summary now includes project name for display in the page
- ZAR_RATE env var (default 18.50) controls USD→ZAR conversion
- Page regenerated automatically after every interaction via _write_status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:30:30 +00:00
parent 83a933ea1a
commit 6cb29d94e6
+171 -1
View File
@@ -46,6 +46,7 @@ logger = logging.getLogger("jon-snow")
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites"))
JON_PUBLIC_URL = os.getenv("JON_PUBLIC_URL", "https://jon.nxm.co.za")
ZAR_RATE = float(os.getenv("ZAR_RATE", "18.50"))
app = FastAPI(title="Jon Snow", version="0.3.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@@ -98,6 +99,174 @@ class QueueActionRequest(BaseModel):
# --- Output helpers ---
def _render_jon_page() -> None:
"""Write /opt/sites/jon-snow/index.html with live cost and recent tasks."""
summary = get_token_summary()
total_usd = summary["all_time"]["cost_usd"]
today_usd = summary["today"]["cost_usd"]
total_zar = total_usd * ZAR_RATE
today_zar = today_usd * ZAR_RATE
runs_file = AGENT_OS_DIR / "logs" / "jon-snow" / "runs.jsonl"
runs = []
if runs_file.exists():
for line in runs_file.read_text().splitlines():
try:
runs.append(json.loads(line))
except Exception:
pass
# Last 3 task entries, newest first
task_runs = [r for r in reversed(runs) if r.get("result", "").startswith("[task]")][:3]
CLAUDE_INTENTS = {"task", "planning"}
task_rows = ""
for r in task_runs:
ts = r.get("timestamp", "")[:19].replace("T", " ")
result = r.get("result", "")
# "[task] Task created: TITLE → PROJECT"
body = result.removeprefix("[task] Task created: ").removeprefix("[task] ")
if "" in body:
title, project = body.split("", 1)
else:
title, project = body, ""
badge_cls = "badge-claude" if r.get("result", "").startswith("[task]") else "badge-python"
task_rows += (
f"<tr><td>{ts}</td><td>{title[:55]}</td>"
f"<td>{project[:30]}</td>"
f'<td><span class="badge badge-claude">Claude</span></td></tr>\n'
)
if not task_rows:
task_rows = '<tr><td colspan="4" class="muted">No tasks logged yet</td></tr>'
intent_rows = ""
for intent, v in summary["by_intent"].items():
zar = v["cost_usd"] * ZAR_RATE
label = "Claude" if intent in CLAUDE_INTENTS or "extract" in intent else "Python"
badge = "badge-claude" if label == "Claude" else "badge-python"
intent_rows += (
f"<tr><td>{intent}</td>"
f'<td><span class="badge {badge}">{label}</span></td>'
f"<td>{v['calls']}</td>"
f"<td>{v['in']:,} / {v['out']:,}</td>"
f"<td>R{zar:.4f}</td></tr>\n"
)
if not intent_rows:
intent_rows = '<tr><td colspan="5" class="muted">No API calls yet</td></tr>'
updated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jon Snow — NxM Agents</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {{
--bg:#0d1117; --surface:#161b22; --border:#30363d; --dim:#21262d;
--text:#e6edf3; --muted:#8b949e; --accent:#58a6ff;
}}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:'Inter',-apple-system,sans-serif;background:var(--bg);color:var(--text)}}
.site-nav{{display:flex;align-items:center;gap:.5rem;padding:.75rem 2rem;
border-bottom:1px solid var(--border);font-size:.9rem}}
.nav-brand{{color:var(--accent);text-decoration:none;font-weight:600}}
.nav-sep{{color:var(--muted)}}
.nav-title{{color:var(--text);font-weight:500}}
.nav-right{{margin-left:auto}}
.nav-back{{color:var(--muted);text-decoration:none;font-size:.85rem}}
.main{{max-width:1100px;margin:0 auto;padding:2.5rem 2rem}}
h1{{font-size:1.5rem;font-weight:600;color:var(--accent);margin-bottom:.25rem}}
.subtitle{{color:var(--muted);font-size:.9rem;margin-bottom:2rem}}
.stats{{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1rem;margin-bottom:2rem}}
.card{{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1.25rem}}
.card-label{{font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem}}
.card-value{{font-size:1.75rem;font-weight:600;font-variant-numeric:tabular-nums;color:var(--text)}}
.card-value.hi{{color:var(--accent)}}
.card-sub{{font-size:.78rem;color:var(--muted);margin-top:.2rem}}
h2{{font-size:.95rem;font-weight:600;color:var(--text);margin:2rem 0 .75rem;
text-transform:uppercase;letter-spacing:.05em}}
.section{{background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:1rem}}
table{{width:100%;border-collapse:collapse;font-size:.875rem}}
th{{text-align:left;padding:.5rem .75rem;color:var(--muted);font-size:.75rem;
text-transform:uppercase;letter-spacing:.05em;border-bottom:1px solid var(--border)}}
td{{padding:.6rem .75rem;border-bottom:1px solid var(--dim);color:var(--text)}}
tr:last-child td{{border-bottom:none}}
.badge{{display:inline-block;font-size:.72rem;padding:.15rem .5rem;border-radius:4px;font-weight:500}}
.badge-claude{{background:rgba(88,166,255,.12);color:#58a6ff;border:1px solid rgba(88,166,255,.2)}}
.badge-python{{background:rgba(63,185,80,.12);color:#3fb950;border:1px solid rgba(63,185,80,.2)}}
.muted{{color:var(--muted)}}
.updated{{font-size:.75rem;color:var(--muted);margin-top:2rem;text-align:right}}
</style>
</head>
<body>
<nav class="site-nav">
<a class="nav-brand" href="/">◈ NxM</a>
<span class="nav-sep">·</span>
<span class="nav-title">Jon Snow</span>
<span class="nav-right"><a class="nav-back" href="/">← home</a></span>
</nav>
<main class="main">
<h1>Jon Snow</h1>
<p class="subtitle">Orchestrator · Chief of Staff · port 8900</p>
<div class="stats">
<div class="card">
<div class="card-label">All-time API cost</div>
<div class="card-value hi">R{total_zar:.2f}</div>
<div class="card-sub">${total_usd:.6f} USD · {summary['all_time']['calls']} LLM calls</div>
</div>
<div class="card">
<div class="card-label">Today</div>
<div class="card-value">R{today_zar:.2f}</div>
<div class="card-sub">${today_usd:.6f} USD · {summary['today']['calls']} calls today</div>
</div>
<div class="card">
<div class="card-label">Tokens in / out</div>
<div class="card-value">{summary['all_time']['tokens_in']:,}</div>
<div class="card-sub">{summary['all_time']['tokens_out']:,} output · claude-sonnet-4-6</div>
</div>
<div class="card">
<div class="card-label">Rate</div>
<div class="card-value">R{ZAR_RATE:.2f}</div>
<div class="card-sub">per USD · update ZAR_RATE env var</div>
</div>
</div>
<h2>Last 3 Tasks</h2>
<div class="section">
<table>
<thead><tr><th>Time (UTC)</th><th>Task</th><th>Project</th><th>Extraction</th></tr></thead>
<tbody>{task_rows}</tbody>
</table>
</div>
<h2>API Usage by Intent</h2>
<div class="section">
<table>
<thead><tr><th>Intent</th><th>Path</th><th>Calls</th><th>Tokens in / out</th><th>Cost (ZAR)</th></tr></thead>
<tbody>{intent_rows}</tbody>
</table>
</div>
<p class="updated">Updated {updated}</p>
</main>
</body>
</html>"""
out_dir = SITES_DIR / "jon-snow"
out_dir.mkdir(parents=True, exist_ok=True)
try:
(out_dir / "index.html").write_text(html)
except Exception as e:
logger.warning(f"page render failed: {e}")
def _write_status(intent: str, summary: str, status: str = "success") -> None:
log_dir = AGENT_OS_DIR / "logs" / "jon-snow"
log_dir.mkdir(parents=True, exist_ok=True)
@@ -123,6 +292,7 @@ def _write_status(intent: str, summary: str, status: str = "success") -> None:
(out_dir / "last-output.md").write_text(
f"# Jon Snow — Last Response\n\n**{payload['timestamp']}**\n\nIntent: `{intent}`\n\n{summary[:500]}\n"
)
_render_jon_page()
# --- SSE streaming helpers ---
@@ -529,7 +699,7 @@ async def chat_completions(req: ChatRequest):
f"**{issue['title']}** \n"
f"Project: *{issue['project']}* | #{issue['sequence_id']}"
)
summary = f"Task created: {issue['title']}"
summary = f"Task created: {issue['title']}{issue['project']}"
except Exception as e:
response_text = f"Couldn't log to Plane ({e}). Task noted locally: {user_message[:100]}"
summary = f"Plane error: {e}"