1083 lines
27 KiB
Markdown
1083 lines
27 KiB
Markdown
# Dashboard Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Add a real-time dashboard at `/` with summary stats, time-series charts (uPlot), framework breakdown, activity feed, and top tools — powered by new server-side aggregation endpoints plus the existing WebSocket stream.
|
|
|
|
**Architecture:** Two new Postgres query functions (`Summary`, `Timeseries`) exposed as REST endpoints. Frontend fetches historical data on load, then layers live WebSocket events on top. uPlot renders time-series charts; framework bars and top tools are styled HTML.
|
|
|
|
**Tech Stack:** Go (chi router, pgx), Postgres `date_bin`, uPlot (CDN), vanilla JS
|
|
|
|
---
|
|
|
|
### Task 1: Stats Query Functions
|
|
|
|
**Files:**
|
|
- Create: `internal/store/postgres/stats.go`
|
|
|
|
**Step 1: Create the summary query function**
|
|
|
|
```go
|
|
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
type FrameworkStats struct {
|
|
Runs int `json:"runs"`
|
|
Tools int `json:"tools"`
|
|
Errors int `json:"errors"`
|
|
}
|
|
|
|
type Summary struct {
|
|
ActiveSessions int `json:"active_sessions"`
|
|
RunsToday int `json:"runs_today"`
|
|
ToolCallsToday int `json:"tool_calls_today"`
|
|
ErrorsToday int `json:"errors_today"`
|
|
ByFramework map[string]FrameworkStats `json:"by_framework"`
|
|
}
|
|
|
|
func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
|
|
now := time.Now()
|
|
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
|
|
s := &Summary{ByFramework: make(map[string]FrameworkStats)}
|
|
|
|
// Active sessions: sessions that have a session.start but no session.end today
|
|
err := d.sql.QueryRowContext(ctx, `
|
|
SELECT COUNT(DISTINCT session_id) FROM events
|
|
WHERE session_id IS NOT NULL
|
|
AND ts >= $1
|
|
AND type = 'session.start'
|
|
AND session_id NOT IN (
|
|
SELECT DISTINCT session_id FROM events
|
|
WHERE session_id IS NOT NULL AND type = 'session.end' AND ts >= $1
|
|
)
|
|
`, midnight).Scan(&s.ActiveSessions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Aggregates by framework
|
|
rows, err := d.sql.QueryContext(ctx, `
|
|
SELECT
|
|
COALESCE(source_framework, 'unknown'),
|
|
COUNT(*) FILTER (WHERE type IN ('run.start', 'run.end')) / 2,
|
|
COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool'),
|
|
COUNT(*) FILTER (WHERE type = 'error')
|
|
FROM events
|
|
WHERE ts >= $1
|
|
GROUP BY source_framework
|
|
`, midnight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var fw string
|
|
var fs FrameworkStats
|
|
if err := rows.Scan(&fw, &fs.Runs, &fs.Tools, &fs.Errors); err != nil {
|
|
return nil, err
|
|
}
|
|
s.ByFramework[fw] = fs
|
|
s.RunsToday += fs.Runs
|
|
s.ToolCallsToday += fs.Tools
|
|
s.ErrorsToday += fs.Errors
|
|
}
|
|
|
|
return s, rows.Err()
|
|
}
|
|
```
|
|
|
|
**Step 2: Create the timeseries query function**
|
|
|
|
Add to the same file:
|
|
|
|
```go
|
|
type TimeseriesBucket struct {
|
|
TS time.Time `json:"ts"`
|
|
Runs int `json:"runs"`
|
|
Tools int `json:"tools"`
|
|
Errors int `json:"errors"`
|
|
}
|
|
|
|
type TimeseriesResult struct {
|
|
Window string `json:"window"`
|
|
Bucket string `json:"bucket"`
|
|
Series []TimeseriesBucket `json:"series"`
|
|
}
|
|
|
|
func bucketForWindow(window string) string {
|
|
switch window {
|
|
case "6h":
|
|
return "5 minutes"
|
|
case "24h":
|
|
return "15 minutes"
|
|
case "7d":
|
|
return "1 hour"
|
|
default:
|
|
return "1 minute"
|
|
}
|
|
}
|
|
|
|
func durationForWindow(window string) time.Duration {
|
|
switch window {
|
|
case "6h":
|
|
return 6 * time.Hour
|
|
case "24h":
|
|
return 24 * time.Hour
|
|
case "7d":
|
|
return 7 * 24 * time.Hour
|
|
default:
|
|
return time.Hour
|
|
}
|
|
}
|
|
|
|
func bucketLabelForWindow(window string) string {
|
|
switch window {
|
|
case "6h":
|
|
return "5m"
|
|
case "24h":
|
|
return "15m"
|
|
case "7d":
|
|
return "1h"
|
|
default:
|
|
return "1m"
|
|
}
|
|
}
|
|
|
|
func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResult, error) {
|
|
bucket := bucketForWindow(window)
|
|
dur := durationForWindow(window)
|
|
from := time.Now().Add(-dur)
|
|
|
|
rows, err := d.sql.QueryContext(ctx, `
|
|
SELECT
|
|
date_bin($1::interval, ts, '2000-01-01'::timestamptz) AS bucket_ts,
|
|
COUNT(*) FILTER (WHERE type IN ('run.start')) AS runs,
|
|
COUNT(*) FILTER (WHERE type = 'span.end' AND payload->'attributes'->>'span_kind' = 'tool') AS tools,
|
|
COUNT(*) FILTER (WHERE type = 'error') AS errors
|
|
FROM events
|
|
WHERE ts >= $2
|
|
GROUP BY bucket_ts
|
|
ORDER BY bucket_ts ASC
|
|
`, bucket, from)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := &TimeseriesResult{
|
|
Window: window,
|
|
Bucket: bucketLabelForWindow(window),
|
|
}
|
|
|
|
for rows.Next() {
|
|
var b TimeseriesBucket
|
|
if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors); err != nil {
|
|
return nil, err
|
|
}
|
|
result.Series = append(result.Series, b)
|
|
}
|
|
|
|
return result, rows.Err()
|
|
}
|
|
```
|
|
|
|
**Step 3: Verify it compiles**
|
|
|
|
Run: `cd /home/will/lab/agentmon && go build ./internal/store/postgres/`
|
|
Expected: no errors
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/store/postgres/stats.go
|
|
git commit -m "feat: add summary and timeseries stats queries"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Stats API Endpoints
|
|
|
|
**Files:**
|
|
- Modify: `cmd/query-api/main.go`
|
|
|
|
**Step 1: Add the summary endpoint**
|
|
|
|
After the existing `/v1/runs/{runID}` handler block (around line 210), add:
|
|
|
|
```go
|
|
r.Get("/v1/stats/summary", func(w http.ResponseWriter, r *http.Request) {
|
|
summary, err := db.GetSummary(r.Context())
|
|
if err != nil {
|
|
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, summary)
|
|
})
|
|
|
|
r.Get("/v1/stats/timeseries", func(w http.ResponseWriter, r *http.Request) {
|
|
window := r.URL.Query().Get("window")
|
|
switch window {
|
|
case "1h", "6h", "24h", "7d":
|
|
default:
|
|
window = "1h"
|
|
}
|
|
ts, err := db.GetTimeseries(r.Context(), window)
|
|
if err != nil {
|
|
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, ts)
|
|
})
|
|
```
|
|
|
|
**Step 2: Verify it compiles**
|
|
|
|
Run: `cd /home/will/lab/agentmon && go build ./cmd/query-api/`
|
|
Expected: no errors
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add cmd/query-api/main.go
|
|
git commit -m "feat: add stats summary and timeseries API endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Update HTML and Navigation
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/index.html`
|
|
|
|
**Step 1: Add uPlot CDN and update nav**
|
|
|
|
Update the `<head>` to add uPlot CSS before the app stylesheet:
|
|
|
|
```html
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.min.css">
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
```
|
|
|
|
Add uPlot JS before app.js:
|
|
|
|
```html
|
|
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.31/dist/uPlot.iife.min.js"></script>
|
|
<script src="/static/app.js"></script>
|
|
```
|
|
|
|
Update the header logo link and nav to include Dashboard:
|
|
|
|
```html
|
|
<div class="header-logo">
|
|
<h1><a href="/">agentmon<span class="logo-dot"></span></a></h1>
|
|
</div>
|
|
<nav><a href="/">Dashboard</a><a href="/sessions">Sessions</a><a href="/agents">Agents</a><a href="/openclaw">OpenClaw</a></nav>
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/index.html
|
|
git commit -m "feat: add uPlot CDN and dashboard nav link"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Dashboard CSS
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/style.css`
|
|
|
|
**Step 1: Add dashboard styles**
|
|
|
|
Append to end of `style.css`:
|
|
|
|
```css
|
|
/* ── Dashboard ────────────────────────────────────────────── */
|
|
.dashboard-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.dashboard-summary {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 500px) {
|
|
.dashboard-summary {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.summary-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.125rem 1.25rem;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.summary-card:hover {
|
|
border-color: rgba(34, 211, 238, 0.18);
|
|
}
|
|
|
|
.summary-card-label {
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.summary-card-value {
|
|
font-family: var(--font-display);
|
|
font-size: 2rem;
|
|
font-weight: 800;
|
|
color: var(--text-bright);
|
|
letter-spacing: -0.02em;
|
|
line-height: 1;
|
|
}
|
|
|
|
.summary-card-value.has-errors {
|
|
color: var(--error);
|
|
}
|
|
|
|
.summary-card-sub {
|
|
font-size: 0.72rem;
|
|
color: var(--text-dim);
|
|
margin-top: 0.35rem;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
/* ── Charts row ───────────────────────────────────────────── */
|
|
.charts-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 320px;
|
|
gap: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.charts-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.chart-panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
min-height: 280px;
|
|
}
|
|
|
|
.chart-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.chart-title {
|
|
font-family: var(--font-display);
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
color: var(--text-bright);
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
.window-selector {
|
|
display: flex;
|
|
gap: 0;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.window-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
padding: 0.3rem 0.65rem;
|
|
cursor: pointer;
|
|
letter-spacing: 0.04em;
|
|
transition: background 0.15s, color 0.15s;
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
|
|
.window-btn:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
.window-btn:hover {
|
|
color: var(--text-bright);
|
|
background: var(--surface-2);
|
|
}
|
|
|
|
.window-btn.active {
|
|
color: var(--accent);
|
|
background: var(--accent-dim);
|
|
}
|
|
|
|
.chart-container {
|
|
width: 100%;
|
|
min-height: 200px;
|
|
}
|
|
|
|
/* ── Framework bars ───────────────────────────────────────── */
|
|
.fw-bars {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
|
|
.fw-bar-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.fw-bar-label {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.fw-bar-name {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.78rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.fw-bar-count {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.fw-bar-track {
|
|
height: 6px;
|
|
background: var(--surface-2);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fw-bar-fill {
|
|
height: 100%;
|
|
border-radius: 3px;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
.fw-bar-fill.openclaw { background: var(--accent); }
|
|
.fw-bar-fill.claude-code { background: var(--success); }
|
|
.fw-bar-fill.opencode { background: var(--purple); }
|
|
.fw-bar-fill.unknown { background: var(--text-dim); }
|
|
|
|
/* ── Bottom panels ────────────────────────────────────────── */
|
|
.bottom-panels {
|
|
display: grid;
|
|
grid-template-columns: 1fr 320px;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.bottom-panels {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.feed-panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
max-height: 480px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.feed-panel .timeline-event {
|
|
padding: 0.625rem 0.875rem;
|
|
border-radius: var(--radius);
|
|
margin-bottom: 0.375rem;
|
|
border: 1px solid var(--border-soft);
|
|
background: transparent;
|
|
}
|
|
|
|
.tools-panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem;
|
|
}
|
|
|
|
/* uPlot theme overrides */
|
|
.uplot .u-legend { display: none; }
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/style.css
|
|
git commit -m "feat: add dashboard CSS styles"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Dashboard JavaScript
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js`
|
|
|
|
**Step 1: Update the router**
|
|
|
|
Change the route function so `/` renders the dashboard and `/sessions` renders sessions:
|
|
|
|
In the `route()` function, change:
|
|
```js
|
|
if (path === '/' || path === '/sessions') {
|
|
renderSessions();
|
|
```
|
|
to:
|
|
```js
|
|
if (path === '/') {
|
|
renderDashboard();
|
|
} else if (path === '/sessions') {
|
|
renderSessions();
|
|
```
|
|
|
|
**Step 2: Add dashboard state and cleanup**
|
|
|
|
Near the top of the IIFE, alongside the existing state variables, add:
|
|
|
|
```js
|
|
let dashboardState = null;
|
|
let dashboardUnsubscribe = null;
|
|
let dashboardChart = null;
|
|
```
|
|
|
|
In `cleanupLiveViews()`, add cleanup for the dashboard:
|
|
|
|
```js
|
|
if (dashboardUnsubscribe) {
|
|
dashboardUnsubscribe();
|
|
dashboardUnsubscribe = null;
|
|
}
|
|
if (dashboardChart) {
|
|
dashboardChart.destroy();
|
|
dashboardChart = null;
|
|
}
|
|
```
|
|
|
|
**Step 3: Add the renderDashboard function**
|
|
|
|
```js
|
|
async function renderDashboard() {
|
|
dashboardState = {
|
|
summary: null,
|
|
timeseries: null,
|
|
window: '1h',
|
|
recentEvents: [],
|
|
recentEventIDs: new Set(),
|
|
toolCounts: {},
|
|
};
|
|
|
|
app.innerHTML = `
|
|
<div class="page-header">
|
|
<h2>Dashboard <span class="live-indicator"><span class="live-dot"></span>Live</span></h2>
|
|
</div>
|
|
<div class="dashboard-summary">
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Active Sessions</div>
|
|
<div class="summary-card-value" id="dash-active">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Runs Today</div>
|
|
<div class="summary-card-value" id="dash-runs">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Tool Calls</div>
|
|
<div class="summary-card-value" id="dash-tools">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Errors</div>
|
|
<div class="summary-card-value" id="dash-errors">-</div>
|
|
</div>
|
|
</div>
|
|
<div class="vm-strip" id="dash-vm-strip"></div>
|
|
<div class="charts-row">
|
|
<div class="chart-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">Event Rate</span>
|
|
<div class="window-selector">
|
|
<button class="window-btn active" data-w="1h">1h</button>
|
|
<button class="window-btn" data-w="6h">6h</button>
|
|
<button class="window-btn" data-w="24h">24h</button>
|
|
<button class="window-btn" data-w="7d">7d</button>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container" id="dash-chart"></div>
|
|
</div>
|
|
<div class="chart-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">By Framework</span>
|
|
</div>
|
|
<div class="fw-bars" id="dash-fw-bars">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bottom-panels">
|
|
<div class="feed-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">Recent Activity</span>
|
|
</div>
|
|
<div class="timeline" id="dash-feed">
|
|
<p class="empty-state" style="padding:1rem">Loading...</p>
|
|
</div>
|
|
</div>
|
|
<div class="tools-panel">
|
|
<div class="chart-header">
|
|
<span class="chart-title">Top Tools</span>
|
|
</div>
|
|
<ul class="stat-list" id="dash-top-tools">
|
|
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Wire up window selector
|
|
document.querySelectorAll('.window-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.window-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
dashboardState.window = btn.dataset.w;
|
|
loadTimeseries();
|
|
});
|
|
});
|
|
|
|
// Render VM strip
|
|
renderAgentVMStrip_dash();
|
|
|
|
// Load initial data
|
|
try {
|
|
const [summaryData, tsData, recentData, snapshots] = await Promise.all([
|
|
api('/v1/stats/summary'),
|
|
api('/v1/stats/timeseries?window=1h'),
|
|
api('/v1/events?limit=20'),
|
|
api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })),
|
|
]);
|
|
|
|
if (!isCurrentPath('/')) return;
|
|
|
|
mergeOpenClawEvents(snapshots.events || []);
|
|
renderAgentVMStrip_dash();
|
|
|
|
dashboardState.summary = summaryData;
|
|
dashboardState.timeseries = tsData;
|
|
renderSummaryCards();
|
|
renderTimeseriesChart();
|
|
renderFrameworkBars();
|
|
|
|
// Seed recent events
|
|
const events = (recentData.events || []).slice().reverse();
|
|
for (const evt of events) {
|
|
const id = getRecordID(evt);
|
|
if (id && !dashboardState.recentEventIDs.has(id)) {
|
|
dashboardState.recentEventIDs.add(id);
|
|
dashboardState.recentEvents.push(evt);
|
|
tallyTool(evt);
|
|
}
|
|
}
|
|
renderDashFeed();
|
|
renderDashTopTools();
|
|
} catch (e) {
|
|
console.error('Dashboard load error:', e);
|
|
}
|
|
|
|
// Subscribe to WebSocket for live updates
|
|
dashboardUnsubscribe = subscribeWS(handleDashboardWS);
|
|
}
|
|
|
|
function renderAgentVMStrip_dash() {
|
|
const strip = document.getElementById('dash-vm-strip');
|
|
if (!strip) return;
|
|
const vms = getVMStatus();
|
|
strip.innerHTML = vms.map(vm => `
|
|
<div class="vm-pill ${vm.active ? 'active' : 'inactive'}">
|
|
<span class="vm-pill-dot"></span>
|
|
<span class="vm-pill-name">${escapeHTML(vm.name)}</span>
|
|
<span class="vm-pill-label">${vm.active ? 'online' : 'offline'}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function handleDashboardWS(msg) {
|
|
if (msg.type !== 'message') return;
|
|
|
|
const eventType = getEnvelopeType(msg.data);
|
|
|
|
if (eventType === 'openclaw.snapshot') {
|
|
mergeOpenClawEvents([msg.data]);
|
|
renderAgentVMStrip_dash();
|
|
return;
|
|
}
|
|
|
|
// Update summary counters
|
|
if (dashboardState.summary) {
|
|
if (eventType === 'run.start') dashboardState.summary.runs_today++;
|
|
if (eventType === 'error') dashboardState.summary.errors_today++;
|
|
if (eventType === 'span.end') {
|
|
const attrs = getEnvelopeAttributes(msg.data);
|
|
if (attrs.span_kind === 'tool') dashboardState.summary.tool_calls_today++;
|
|
}
|
|
renderSummaryCards();
|
|
}
|
|
|
|
// Update recent feed
|
|
const id = getRecordID(msg.data);
|
|
if (id && !dashboardState.recentEventIDs.has(id)) {
|
|
dashboardState.recentEventIDs.add(id);
|
|
dashboardState.recentEvents.push(msg.data);
|
|
tallyTool(msg.data);
|
|
|
|
// Cap at 50, display 20
|
|
while (dashboardState.recentEvents.length > 50) {
|
|
const removed = dashboardState.recentEvents.shift();
|
|
dashboardState.recentEventIDs.delete(getRecordID(removed));
|
|
}
|
|
|
|
renderDashFeed();
|
|
renderDashTopTools();
|
|
}
|
|
|
|
// Append to current timeseries bucket
|
|
if (dashboardState.timeseries && dashboardState.window === '1h') {
|
|
appendToCurrentBucket(msg.data);
|
|
}
|
|
}
|
|
|
|
function tallyTool(evt) {
|
|
const eventType = getEnvelopeType(evt);
|
|
if (eventType === 'span.end') {
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
if (attrs.span_kind === 'tool') {
|
|
const name = attrs.name || 'unknown';
|
|
dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderSummaryCards() {
|
|
const s = dashboardState.summary;
|
|
if (!s) return;
|
|
|
|
const el = (id, val) => {
|
|
const e = document.getElementById(id);
|
|
if (e) e.textContent = String(val);
|
|
};
|
|
|
|
el('dash-active', s.active_sessions);
|
|
el('dash-runs', s.runs_today);
|
|
el('dash-tools', s.tool_calls_today);
|
|
el('dash-errors', s.errors_today);
|
|
|
|
const errEl = document.getElementById('dash-errors');
|
|
if (errEl) {
|
|
errEl.classList.toggle('has-errors', s.errors_today > 0);
|
|
}
|
|
}
|
|
|
|
async function loadTimeseries() {
|
|
try {
|
|
const data = await api('/v1/stats/timeseries?window=' + dashboardState.window);
|
|
if (!isCurrentPath('/')) return;
|
|
dashboardState.timeseries = data;
|
|
renderTimeseriesChart();
|
|
} catch (e) {
|
|
console.error('Failed to load timeseries:', e);
|
|
}
|
|
}
|
|
|
|
function renderTimeseriesChart() {
|
|
const container = document.getElementById('dash-chart');
|
|
if (!container || !dashboardState.timeseries) return;
|
|
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts.series || ts.series.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:2rem">No data for this window</p>';
|
|
return;
|
|
}
|
|
|
|
// Destroy existing chart
|
|
if (dashboardChart) {
|
|
dashboardChart.destroy();
|
|
dashboardChart = null;
|
|
}
|
|
|
|
container.innerHTML = '';
|
|
|
|
const timestamps = ts.series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
|
|
const runs = ts.series.map(b => b.runs);
|
|
const tools = ts.series.map(b => b.tools);
|
|
const errors = ts.series.map(b => b.errors);
|
|
|
|
const width = container.clientWidth || 600;
|
|
const height = 200;
|
|
|
|
const opts = {
|
|
width,
|
|
height,
|
|
cursor: { show: true },
|
|
scales: {
|
|
x: { time: true },
|
|
y: { auto: true, min: 0 },
|
|
},
|
|
axes: [
|
|
{
|
|
stroke: '#4e6070',
|
|
grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 },
|
|
ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 },
|
|
font: '11px Fira Code',
|
|
},
|
|
{
|
|
stroke: '#4e6070',
|
|
grid: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 },
|
|
ticks: { stroke: 'rgba(28, 38, 55, 0.6)', width: 1 },
|
|
font: '11px Fira Code',
|
|
size: 50,
|
|
},
|
|
],
|
|
series: [
|
|
{},
|
|
{
|
|
label: 'Runs',
|
|
stroke: '#34d399',
|
|
width: 2,
|
|
fill: 'rgba(52, 211, 153, 0.08)',
|
|
},
|
|
{
|
|
label: 'Tools',
|
|
stroke: '#22d3ee',
|
|
width: 2,
|
|
fill: 'rgba(34, 211, 238, 0.08)',
|
|
},
|
|
{
|
|
label: 'Errors',
|
|
stroke: '#f87171',
|
|
width: 2,
|
|
fill: 'rgba(248, 113, 113, 0.08)',
|
|
},
|
|
],
|
|
};
|
|
|
|
dashboardChart = new uPlot(opts, [timestamps, runs, tools, errors], container);
|
|
}
|
|
|
|
function appendToCurrentBucket(evt) {
|
|
const ts = dashboardState.timeseries;
|
|
if (!ts || !ts.series || ts.series.length === 0) return;
|
|
|
|
const now = Math.floor(Date.now() / 60000) * 60000; // current minute
|
|
const last = ts.series[ts.series.length - 1];
|
|
const lastTs = new Date(last.ts).getTime();
|
|
|
|
let bucket;
|
|
if (Math.abs(now - lastTs) < 60000) {
|
|
bucket = last;
|
|
} else {
|
|
bucket = { ts: new Date(now).toISOString(), runs: 0, tools: 0, errors: 0 };
|
|
ts.series.push(bucket);
|
|
}
|
|
|
|
const eventType = getEnvelopeType(evt);
|
|
if (eventType === 'run.start') bucket.runs++;
|
|
if (eventType === 'error') bucket.errors++;
|
|
if (eventType === 'span.end') {
|
|
const attrs = getEnvelopeAttributes(evt);
|
|
if (attrs.span_kind === 'tool') bucket.tools++;
|
|
}
|
|
|
|
renderTimeseriesChart();
|
|
}
|
|
|
|
function renderFrameworkBars() {
|
|
const container = document.getElementById('dash-fw-bars');
|
|
if (!container || !dashboardState.summary) return;
|
|
|
|
const byFw = dashboardState.summary.by_framework || {};
|
|
const entries = Object.entries(byFw).sort((a, b) => {
|
|
const totalA = a[1].runs + a[1].tools + a[1].errors;
|
|
const totalB = b[1].runs + b[1].tools + b[1].errors;
|
|
return totalB - totalA;
|
|
});
|
|
|
|
if (entries.length === 0) {
|
|
container.innerHTML = '<p class="empty-state" style="padding:1rem">No framework data</p>';
|
|
return;
|
|
}
|
|
|
|
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
|
|
|
|
container.innerHTML = entries.map(([name, stats]) => {
|
|
const total = stats.runs + stats.tools + stats.errors;
|
|
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
|
|
const cssClass = name.replace(/[^a-z0-9-]/g, '-');
|
|
return `
|
|
<div class="fw-bar-row">
|
|
<div class="fw-bar-label">
|
|
<span class="fw-bar-name">${escapeHTML(name)}</span>
|
|
<span class="fw-bar-count">${total} events</span>
|
|
</div>
|
|
<div class="fw-bar-track">
|
|
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderDashFeed() {
|
|
const feed = document.getElementById('dash-feed');
|
|
if (!feed) return;
|
|
|
|
const recent = dashboardState.recentEvents.slice(-20).reverse();
|
|
if (recent.length === 0) {
|
|
feed.innerHTML = '<p class="empty-state" style="padding:1rem">Waiting for events...</p>';
|
|
return;
|
|
}
|
|
|
|
feed.innerHTML = recent.map(evt => {
|
|
const eventType = getEnvelopeType(evt);
|
|
const vmName = getVMName(evt);
|
|
const vmClass = getVMClassName(vmName);
|
|
const source = getEnvelopeSource(evt);
|
|
const framework = source.framework || '';
|
|
const tag = framework
|
|
? `<span class="timeline-vm-tag ${vmClass}">${escapeHTML(framework)}</span>`
|
|
: '';
|
|
|
|
return `
|
|
<div class="timeline-event">
|
|
<div class="timeline-event-header">
|
|
${getEventIcon(eventType)}
|
|
${tag}
|
|
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
|
|
<span class="timeline-event-time">${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}</span>
|
|
</div>
|
|
${getEventBody(evt)}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderDashTopTools() {
|
|
const list = document.getElementById('dash-top-tools');
|
|
if (!list) return;
|
|
|
|
const topTools = Object.entries(dashboardState.toolCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10);
|
|
|
|
if (topTools.length === 0) {
|
|
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = topTools.map(([name, count]) => `
|
|
<li>
|
|
<span class="stat-list-name">${escapeHTML(name)}</span>
|
|
<span class="stat-list-count">${count}</span>
|
|
</li>
|
|
`).join('');
|
|
}
|
|
```
|
|
|
|
**Step 4: Verify no JS syntax errors**
|
|
|
|
Open the browser dev tools, navigate to `/`, and verify no console errors.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js
|
|
git commit -m "feat: add real-time dashboard with charts, stats, and activity feed"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Resize handling and polish
|
|
|
|
**Files:**
|
|
- Modify: `cmd/web-ui/static/app.js`
|
|
|
|
**Step 1: Add resize observer for the chart**
|
|
|
|
Inside the `renderTimeseriesChart` function, after creating the uPlot instance, add:
|
|
|
|
```js
|
|
const ro = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
if (dashboardChart) {
|
|
dashboardChart.setSize({ width: entry.contentRect.width, height: 200 });
|
|
}
|
|
}
|
|
});
|
|
ro.observe(container);
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add cmd/web-ui/static/app.js
|
|
git commit -m "feat: add chart resize handling"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: End-to-end verification
|
|
|
|
**Step 1: Build all binaries**
|
|
|
|
Run: `cd /home/will/lab/agentmon && go build ./...`
|
|
Expected: no errors
|
|
|
|
**Step 2: Start the stack locally**
|
|
|
|
Run: `cd /home/will/lab/agentmon && ./start-all.sh`
|
|
Verify: all services start
|
|
|
|
**Step 3: Open the dashboard**
|
|
|
|
Navigate to `http://localhost:<web-ui-port>/`
|
|
Verify:
|
|
- Summary cards show numbers (may be 0 if no data)
|
|
- VM strip shows zap/orb/sun status
|
|
- Chart renders (empty is OK with no data)
|
|
- Framework bars section renders
|
|
- Activity feed and top tools sections render
|
|
- Time window buttons switch the chart
|
|
- WebSocket connects (check browser console for "WebSocket connected")
|
|
|
|
**Step 4: Final commit if any fixes needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: dashboard polish and fixes"
|
|
```
|