/** * @file Sidebar provider for Claude Code Agent Monitor VSCode extension * Renders a rich, real-time WebviewView showing backend status, live agent * health, usage analytics, recent sessions, and quick navigation. * * Replaces the previous flat TreeDataProvider with a styled WebviewView that * pushes JSON snapshots from background polling and receives action messages * (open dashboard, open session, refresh, browser, clear history) back from * the webview UI. * * @author Nguyễn Ngọc Trí Vĩ */ const vscode = require("vscode"); const http = require("http"); const POLL_INTERVAL_MS = 5000; const SPARK_HISTORY = 20; class DashboardWebviewProvider { constructor(context, output) { this.context = context; this.output = output; this.view = null; this.status = "Offline"; this.data = {}; this.history = { sessions: [], agents: [], tokens: [], cost: [], }; this._pollHandle = null; this._fetching = null; } log(msg) { if (this.output) { try { this.output.appendLine("[" + new Date().toISOString() + "] " + msg); } catch (_) {} } } resolveWebviewView(view) { this.view = view; view.webview.options = { enableScripts: true, localResourceRoots: [], }; view.webview.html = this.getHtml(); view.webview.onDidReceiveMessage((msg) => this.handleMessage(msg)); view.onDidChangeVisibility(() => { if (this.view && this.view.visible) this.refresh(); }); view.onDidDispose(() => { this.view = null; if (this._pollHandle) clearInterval(this._pollHandle); this._pollHandle = null; }); this.refresh(); if (!this._pollHandle) { this._pollHandle = setInterval(() => this.refresh(), POLL_INTERVAL_MS); } } refresh() { return this.fetchAll() .then(() => { try { this.pushSnapshot(); } catch (e) { this.log("pushSnapshot threw: " + e.message); } }) .catch((e) => this.log("refresh threw: " + e.message)); } handleMessage(msg) { if (!msg || !msg.command) return; switch (msg.command) { case "openDashboard": vscode.commands.executeCommand("claude-code-agent-monitor.openDashboard", msg.target || ""); break; case "openInBrowser": vscode.commands.executeCommand("claude-code-agent-monitor.openInBrowser"); break; case "refresh": vscode.commands.executeCommand("claude-code-agent-monitor.refreshStatus"); break; case "clearHistory": vscode.commands.executeCommand("claude-code-agent-monitor.clearHistory"); break; case "ready": this.log("webview READY"); this.refresh(); break; case "log": this.log("[webview] " + msg.text); break; case "error": this.log("[webview ERROR] " + msg.text); break; } } pushSnapshot() { if (!this.view) { this.log("pushSnapshot skipped: view is null"); return; } const d = this.data || {}; const t = (d.analytics && d.analytics.tokens) || {}; const totalTokens = (t.total_input || 0) + (t.total_output || 0) + (t.total_cache_read || 0) + (t.total_cache_write || 0); const snapshot = { status: this.status, port: d.port || null, stats: { ws: (d.stats && d.stats.ws_connections) || 0, sessions: (d.stats && d.stats.total_sessions) || 0, events: (d.stats && d.stats.total_events) || 0, agents_total: (d.stats && d.stats.agents_by_status && Object.values(d.stats.agents_by_status).reduce((a, b) => a + b, 0)) || 0, agents_by_status: (d.stats && d.stats.agents_by_status) || {}, }, analytics: { tokens: { input: t.total_input || 0, output: t.total_output || 0, cache_read: t.total_cache_read || 0, cache_write: t.total_cache_write || 0, total: totalTokens, }, cost: (d.analytics && d.analytics.total_cost) || 0, subagents: (d.analytics && d.analytics.total_subagents) || 0, }, sessions: (Array.isArray(d.sessions) ? d.sessions : []).slice(0, 12).map((s) => ({ id: s.id, name: s.name || s.id.substring(0, 8), status: s.status || "unknown", model: s.model || "unknown", started_at: s.started_at, })), history: this.history, ts: Date.now(), }; this.view.webview.postMessage({ type: "snapshot", payload: snapshot }); } pushHistory(snapshot) { const cap = (arr, v) => { arr.push(v); if (arr.length > SPARK_HISTORY) arr.shift(); }; cap(this.history.sessions, snapshot.stats.sessions); cap(this.history.agents, snapshot.stats.agents_total); cap(this.history.tokens, snapshot.analytics.tokens.total); cap(this.history.cost, snapshot.analytics.cost); } async fetchAll() { if (this._fetching) return this._fetching; this._fetching = (async () => { const ports = [4820, 5173]; let foundActive = false; for (const p of ports) { const up = await this.ping(p); this.log("ping " + p + " => " + up); if (up) { this.status = "Online"; this.data.port = p; foundActive = true; try { this.data.stats = await this.f(4820, "/api/stats"); this.data.analytics = await this.f(4820, "/api/analytics"); const sess = await this.f(4820, "/api/sessions?limit=12"); this.data.sessions = Array.isArray(sess) ? sess : (sess && (sess.sessions || sess.rows || sess.data)) || []; if (!Array.isArray(this.data.sessions)) this.data.sessions = []; this.log("fetched stats+analytics+sessions ok"); } catch (e) { this.log("fetch error: " + (e && e.message ? e.message : e)); } break; } } if (!foundActive) { this.status = "Offline"; this.data = {}; } this.log("fetchAll done, status=" + this.status); })(); try { await this._fetching; } finally { this._fetching = null; } // Update sparkline history off the freshest snapshot const t = (this.data.analytics && this.data.analytics.tokens) || {}; const totalTokens = (t.total_input || 0) + (t.total_output || 0) + (t.total_cache_read || 0) + (t.total_cache_write || 0); this.pushHistory({ stats: { sessions: (this.data.stats && this.data.stats.total_sessions) || 0, agents_total: (this.data.stats && this.data.stats.agents_by_status && Object.values(this.data.stats.agents_by_status).reduce((a, b) => a + b, 0)) || 0, }, analytics: { tokens: { total: totalTokens }, cost: (this.data.analytics && this.data.analytics.total_cost) || 0, }, }); } ping(p) { return new Promise((resolve) => { let done = false; const finish = (v) => { if (done) return; done = true; try { req.destroy(); } catch (_) {} resolve(v); }; const req = http.get( { hostname: "127.0.0.1", port: p, path: p === 4820 ? "/api/health" : "/", timeout: 800 }, (res) => { res.resume(); finish(true); } ); req.on("error", () => finish(false)); req.on("timeout", () => finish(false)); }); } f(p, path) { return new Promise((resolve, reject) => { let done = false; const finish = (fn, v) => { if (done) return; done = true; try { req.destroy(); } catch (_) {} fn(v); }; const req = http.get({ hostname: "127.0.0.1", port: p, path, timeout: 1500 }, (r) => { let d = ""; r.on("data", (c) => (d += c)); r.on("end", () => { try { finish(resolve, JSON.parse(d)); } catch (e) { finish(reject, e); } }); }); req.on("error", (e) => finish(reject, e)); req.on("timeout", () => finish(reject, new Error("timeout"))); }); } getHtml() { const nonce = makeNonce(); return `
Claude Code Monitor
Connecting…
Offline
Loading…
Polling backend for live data.
`; } } function makeNonce() { let s = ""; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 32; i++) s += chars[Math.floor(Math.random() * chars.length)]; return s; } module.exports = { DashboardWebviewProvider };