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:
+171
-1
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user