Compare commits

...

10 Commits

Author SHA1 Message Date
William Valentin ebc944702f chore: drop retired orb and sun VMs
Only the zap VM remains in the fleet. Remove orb/sun from the README
architecture/config docs, the getVMClassName allowlist, and their
.timeline-vm-tag color styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:38:04 -07:00
William Valentin 69eb87ebc9 feat(web-ui): improve Agents page legibility and scannability
Targeted UI/UX polish on the Agents page, keeping the existing dark
aesthetic and both Overview/Live view modes:

- Add a readable --text-mute token (dark + light) and apply it to the
  summary chips, lane meta, and idle/offline status, which previously
  used the near-invisible --text-dim.
- Event feed: demote the generic "Span Started/Completed" label to a
  quiet mono category tag and promote the tool name, with a left-edge
  accent by event kind (run/span/error/session). Scoped to
  #agents-content so other pages' feeds are unaffected.
- Active-op pills: add a per-kind left accent bar (tool/subagent/run).
- Lane sparkline: raise opacity and add a gradient so it actually reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:35:33 -07:00
William Valentin 478c7529a7 feat(hooks): emit per-run token usage and duration on run.end
The stats layer reads usage/duration only from run.end, but neither
framework populated them, so tokens/cost/avg-duration were always 0.

- hermes: accumulate token usage across each run's api-result calls in
  session state and attach the summed usage plus a computed duration_ms
  (from a stored runStartedAt) onto run.end. metric.snapshot emission is
  unchanged, so there is no double counting.
- claude-code: store runStartedAt and use it as a duration_ms fallback at
  all run.end sites. Usage is unavailable from CC hook inputs.

Live verification: a real hermes run now reports duration_ms and
total_tokens on run.end; dashboard tokens_today/avg_duration_ms, both
previously 0, now populate. cost_today stays 0 (no provider emits cost
through the hooks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:16:23 -07:00
William Valentin 5014d89258 feat(metrics): surface tool-span latency in stats and dashboard
Tool spans already carry duration_ms and status, but the metrics layer
only counted them. Expose that data:

- GetTopTools now returns avg/p95 duration and error count per tool.
- Timeseries buckets gain tool_avg_ms / tool_p95_ms (filtered
  percentile_cont over tool spans).
- Dashboard Top Tools shows avg latency per tool; the Latency panel,
  previously always empty (it read run-level duration that is never
  emitted), now plots real tool-span latency (min/avg/p95).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:16:23 -07:00
William Valentin c44e7fe72e refactor(web-ui): extract shared component primitives
Introduce components.js with barTrack, barRow, barRankList, metricPill,
metricStrip, and chartHeader helpers. Migrate dashboard.js and usage.js
to use these primitives, replacing 13 families of duplicated CSS
(stat-list, fw-bar, token-bar, metric-pill, chart-insight, chart-header,
usage-chart-total, etc.) with a unified .am-* namespace. Net: -256 lines.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 12:21:48 -07:00
William Valentin 8753c0c9d5 feat(web-ui): better stats and ergonomics
Usage page: add 7-day trend chart (activity/tokens/cost tabs),
framework breakdown panel with per-framework run/tool/error counts
and proportional bars, and 7d aggregate pills above the chart.

Dashboard: add avg cost/run metric pill to the metrics strip.

Run detail: extract and display prompt preview from the first agent
span's payload above the spans table.

Bug fixes: stat-list bars now render correctly (flex-direction:column),
right-panel-tab active background uses correct accent color, missing
framework colors added for hermes/codex/gemini/copilot. Dead code
renderSessionRow removed from sessions.js. Hardcoded font-family
replaced with CSS variable in metric-pill-value and token-stat-value.
Usage page cleanup() wired into router teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 16:49:05 -07:00
William Valentin 1b01f0b0cd chore(compose): pin postgres patch image 2026-05-20 22:19:58 -07:00
William Valentin 27d40ce28f feat(hooks): add Hermes telemetry handler 2026-05-20 17:35:56 -07:00
William Valentin 78376bdd83 feat(query): include session totals and stable framework names 2026-05-20 17:35:56 -07:00
William Valentin db73eca6fd chore(infra): pin nats image digest 2026-05-20 17:35:56 -07:00
24 changed files with 3454 additions and 549 deletions
+2
View File
@@ -5,5 +5,7 @@
/web-ui /web-ui
/swarm-monitor /swarm-monitor
/event-processor
/openclaw-monitor
/hooks/*/node_modules/ /hooks/*/node_modules/
/build/ /build/
+16 -2
View File
@@ -7,7 +7,7 @@ Telemetry and monitoring system for AI agent activity across [OpenClaw](https://
``` ```
┌──────────────────────────┐ ┌──────────────────────────┐
│ OpenClaw VMs │ │ OpenClaw VMs │
│ (zap, orb, sun) │ (zap)
│ │ │ │
│ hooks/agentmon/ │ │ hooks/agentmon/ │
│ → handler.ts │ │ → handler.ts │
@@ -187,7 +187,7 @@ The hook is deployed to each VM at `~/.openclaw/hooks/agentmon/`. Two environmen
```bash ```bash
AGENTMON_INGEST_URL=http://192.168.122.1:8080 AGENTMON_INGEST_URL=http://192.168.122.1:8080
AGENTMON_VM_NAME=zap # or orb, sun AGENTMON_VM_NAME=zap
``` ```
Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`. Deployment is automated via Ansible — see the [swarm ansible playbook](https://gitea-http.taildb3494.ts.net/will/swarm) `playbooks/customize.yml`.
@@ -218,6 +218,20 @@ The `hooks/gemini/` directory contains a TypeScript handler for Gemini CLI telem
Sample Gemini hook configuration lives in [hooks/gemini/hooks.json](/home/will/lab/agentmon/hooks/gemini/hooks.json). Install the handler from that directory so the `agentmon-gemini-handler` binary is available, then point Gemini CLI at the sample hook config and set `AGENTMON_INGEST_URL` to your ingest gateway. Sample Gemini hook configuration lives in [hooks/gemini/hooks.json](/home/will/lab/agentmon/hooks/gemini/hooks.json). Install the handler from that directory so the `agentmon-gemini-handler` binary is available, then point Gemini CLI at the sample hook config and set `AGENTMON_INGEST_URL` to your ingest gateway.
## Hermes Hook
The `hooks/hermes/` directory contains a TypeScript handler for Hermes Agent shell-hook telemetry. The current integration maps Hermes hook events into agentmon's session/run/span model:
- `on_session_start` maps to `session.start`
- `pre_llm_call` maps to `run.start`
- `post_llm_call` maps to `run.end`
- `pre_tool_call` maps to `span.start`
- `post_tool_call` maps to `span.end`
- `post_api_request` maps usage payloads to `metric.snapshot`
- `on_session_finalize` maps to `session.end`
Sample Hermes hook configuration lives in [hooks/hermes/hooks.yaml](/home/will/lab/agentmon/hooks/hermes/hooks.yaml). Install the handler from that directory so the `agentmon-hermes-handler` binary is available, then merge the sample `hooks:` block into `~/.hermes/config.yaml` and set `AGENTMON_INGEST_URL` to your ingest gateway.
## Go SDK ## Go SDK
Emit events from Go applications: Emit events from Go applications:
+9
View File
@@ -207,6 +207,15 @@ func main() {
if nextCursor != nil { if nextCursor != nil {
resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano) resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano)
} }
// Include total count on the first page (no cursor) so the UI can show "X of Y"
if f.Cursor == nil {
total, err := db.CountSessions(r.Context(), f)
if err == nil {
resp["total"] = total
}
}
httpx.WriteJSON(w, http.StatusOK, resp) httpx.WriteJSON(w, http.StatusOK, resp)
}) })
+102
View File
@@ -0,0 +1,102 @@
// ── components.js — shared UI primitives ─────────────────
//
// One-stop helpers for the repeating bar / pill / chart-header
// patterns across pages. Renderers return HTML strings — callers
// inject via innerHTML and wire events on the resulting DOM.
import { escapeHTML } from './utils.js';
// ── Bar primitives ───────────────────────────────────────
// barTrack — just the percentage bar (no label/count head).
// value: numeric value to display
// max: ceiling for percentage; 0/falsy means 0%
// fwClass: optional framework slug (openclaw, claude-code, hermes, …)
// — applied as fw-<slug> on the fill for color
// size: 'xs' | 'sm' | 'md' | 'lg' (default 'md')
// modifier: extra class on the fill (e.g. 'model', 'input', 'output')
export function barTrack({ value = 0, max = 0, fwClass = '', size = 'md', modifier = '' } = {}) {
const pct = max > 0 ? Math.min(100, (value / max) * 100).toFixed(1) : '0';
const fillClasses = ['am-bar-fill'];
if (fwClass) fillClasses.push('fw-' + fwClass);
if (modifier) fillClasses.push(modifier);
return `<div class="am-bar-track am-bar-track--${escapeHTML(size)}"><div class="${fillClasses.join(' ')}" style="width:${pct}%"></div></div>`;
}
// barRow — head (name + count) plus track.
// countDisplay overrides the rendered count text (e.g. "238" vs "238 events").
export function barRow({ name = '', count = 0, countDisplay, max = 0, fwClass = '', size = 'sm', modifier = '' } = {}) {
const display = countDisplay != null ? countDisplay : count;
return `
<div class="am-bar-row">
<div class="am-bar-row-head">
<span class="am-bar-row-name">${escapeHTML(String(name))}</span>
<span class="am-bar-row-count">${escapeHTML(String(display))}</span>
</div>
${barTrack({ value: count, max, fwClass, size, modifier })}
</div>`;
}
// barRankList — ranked list of barRows wrapped in <ul class="am-bar-list">.
// items: array of arbitrary objects
// mapItem: fn(item) → { name, count, countDisplay?, fwClass?, modifier? }
// maxOverride: ceiling override; default = max of items' counts
// size: bar height (default 'xs' for ranked lists)
// emptyText: shown when items is empty
export function barRankList(items, { mapItem, maxOverride, size = 'xs', emptyText = 'No data' } = {}) {
if (!items || items.length === 0) {
return `<p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">${escapeHTML(emptyText)}</p>`;
}
const mapped = items.map(item => (mapItem ? mapItem(item) : { name: item.name, count: item.count }));
const max = maxOverride != null ? maxOverride : Math.max(1, ...mapped.map(m => Number(m.count) || 0));
const rows = mapped.map(m => `<li>${barRow({ ...m, max, size })}</li>`).join('');
return `<ul class="am-bar-list">${rows}</ul>`;
}
// ── Pill primitives ──────────────────────────────────────
// metricPill — small label + bold value, optional meta line.
// value: pre-escaped HTML when valueHTML=true, else escaped as text
// valueId: id attribute (for animateCounter targets)
// variant: 'insight' | 'total' | 'range' | '' (default — standalone metric pill)
// alert: adds .alert to the pill (turns value red)
export function metricPill({ label, value = '-', valueId = '', valueHTML = false, meta = '', variant = '', alert = false } = {}) {
const classes = ['am-pill'];
if (variant) classes.push('am-pill--' + variant);
if (alert) classes.push('alert');
const idAttr = valueId ? ` id="${escapeHTML(valueId)}"` : '';
const rendered = valueHTML ? value : escapeHTML(String(value));
const metaLine = meta ? `<span class="am-pill-meta">${escapeHTML(String(meta))}</span>` : '';
return `
<div class="${classes.join(' ')}">
<span class="am-pill-label">${escapeHTML(String(label))}</span>
<strong class="am-pill-value"${idAttr}>${rendered}</strong>
${metaLine}
</div>`;
}
// metricStrip — flex/grid container around a list of pill specs.
// variant: '' | 'insights' (4-up grid) | 'totals' (tight inline pills)
export function metricStrip(pills, { variant = '', className = '' } = {}) {
const classes = ['am-pill-strip'];
if (variant) classes.push('am-pill-strip--' + variant);
if (className) classes.push(className);
return `<div class="${classes.join(' ')}">${pills.map(p => metricPill(p)).join('')}</div>`;
}
// ── Chart header ─────────────────────────────────────────
// chartHeader — title + optional subtitle on the left, raw controls HTML on the right.
// controls is passed through unchanged (legend/buttons/tabs vary by chart).
export function chartHeader({ title = '', subtitle = '', controls = '' } = {}) {
const subtitleHTML = subtitle ? `<span class="am-chart-subtitle">${escapeHTML(subtitle)}</span>` : '';
const controlsHTML = controls ? `<div class="am-chart-controls">${controls}</div>` : '';
return `
<div class="am-chart-header">
<div class="am-chart-title-group">
<span class="am-chart-title">${escapeHTML(title)}</span>
${subtitleHTML}
</div>
${controlsHTML}
</div>`;
}
+12 -3
View File
@@ -46,6 +46,14 @@ let _agentsRenderTimer = null;
// ── Private helpers ────────────────────────────────────── // ── Private helpers ──────────────────────────────────────
function eventKindClass(eventType) {
if (eventType === 'run.start' || eventType === 'run.end') return 'evt-run';
if (eventType === 'span.start' || eventType === 'span.end') return 'evt-span';
if (eventType === 'error') return 'evt-error';
if (eventType === 'session.start' || eventType === 'session.end') return 'evt-session';
return 'evt-other';
}
function ensureAgentBucket(evt) { function ensureAgentBucket(evt) {
const identity = getAgentIdentity(evt); const identity = getAgentIdentity(evt);
if (!identity.key) return null; if (!identity.key) return null;
@@ -258,7 +266,8 @@ function renderAgentLanes() {
const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => { const opsHTML = ops.length > 0 ? `<div class="active-ops">${ops.map(op => {
const elapsed = Math.floor((Date.now() - op.startedAt) / 1000); const elapsed = Math.floor((Date.now() - op.startedAt) / 1000);
const stale = elapsed > 300; const stale = elapsed > 300;
const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent' : ''; const kindClass = op.kind === 'agent' || op.subType === 'subagent' ? ' subagent'
: op.kind === 'run' ? ' run' : '';
return ` return `
<div class="active-op${stale ? ' stale' : ''}${kindClass}"> <div class="active-op${stale ? ' stale' : ''}${kindClass}">
<span class="active-op-dot"></span> <span class="active-op-dot"></span>
@@ -276,7 +285,7 @@ function renderAgentLanes() {
const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : ''; const expandHTML = details ? '<button class="timeline-expand-hint" type="button">details</button>' : '';
return ` return `
<div class="timeline-event"> <div class="timeline-event ${eventKindClass(eventType)}">
<div class="timeline-event-header"> <div class="timeline-event-header">
${getEventIcon(eventType)} ${getEventIcon(eventType)}
<span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span> <span class="timeline-event-type">${escapeHTML(getEventLabel(eventType))}</span>
@@ -675,7 +684,7 @@ function renderAgentsLive() {
</div> </div>
<div class="live-run-events"> <div class="live-run-events">
${group.events.map(evt => ` ${group.events.map(evt => `
<div class="timeline-event live-event"> <div class="timeline-event live-event ${eventKindClass(getEnvelopeType(evt))}">
<div class="timeline-event-header"> <div class="timeline-event-header">
${getEventIcon(getEnvelopeType(evt))} ${getEventIcon(getEnvelopeType(evt))}
<span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span> <span class="timeline-event-type">${escapeHTML(getEventLabel(getEnvelopeType(evt)))}</span>
+90 -149
View File
@@ -39,6 +39,13 @@ import {
import { clearErrorBadge } from '../palette.js'; import { clearErrorBadge } from '../palette.js';
import { app, navigate, isRouteCurrent } from '../router.js'; import { app, navigate, isRouteCurrent } from '../router.js';
import { api } from '../api.js'; import { api } from '../api.js';
import {
barRankList,
barRow,
metricPill,
metricStrip,
chartHeader,
} from '../components.js';
// uPlot is loaded as a global IIFE; access via window.uPlot // uPlot is loaded as a global IIFE; access via window.uPlot
/* global uPlot */ /* global uPlot */
@@ -182,7 +189,13 @@ function renderSummaryCards() {
const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0); const totalOps = (s.runs_today || 0) + (s.tool_calls_today || 0);
const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0; const rate = totalOps > 0 ? ((s.errors_today || 0) / totalOps * 100) : 0;
animateCounter('dash-error-rate', rate.toFixed(1) + '%'); animateCounter('dash-error-rate', rate.toFixed(1) + '%');
errorRateEl.classList.toggle('alert', rate > 5); const pill = errorRateEl.closest('.am-pill');
if (pill) pill.classList.toggle('alert', rate > 5);
}
if (document.getElementById('dash-cost-per-run')) {
const avgCost = (s.runs_today || 0) > 0 ? (s.cost_today || 0) / s.runs_today : 0;
animateCounter('dash-cost-per-run', avgCost ? formatCost(avgCost) : '$0.0000');
} }
} }
@@ -282,12 +295,12 @@ function renderDashboardChartInsights() {
} }
const peakBucket = dashboardState.timeseries.series[stats.peakIndex]; const peakBucket = dashboardState.timeseries.series[stats.peakIndex];
container.innerHTML = ` container.innerHTML = metricStrip([
<div class="chart-insight-pill"><span class="chart-insight-label">window total</span><strong>${escapeHTML(formatCount(stats.totalEvents))}</strong></div> { label: 'window total', value: formatCount(stats.totalEvents), variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">peak bucket</span><strong>${escapeHTML(formatCount(stats.peakTotal))}</strong><span class="chart-insight-meta">${escapeHTML(formatBucketLabel(peakBucket.ts))}</span></div> { label: 'peak bucket', value: formatCount(stats.peakTotal), meta: formatBucketLabel(peakBucket.ts), variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">mix</span><strong>${escapeHTML(formatCount(stats.totalRuns))}r / ${escapeHTML(formatCount(stats.totalTools))}t / ${escapeHTML(formatCount(stats.totalErrors))}e</strong></div> { label: 'mix', value: `${formatCount(stats.totalRuns)}r / ${formatCount(stats.totalTools)}t / ${formatCount(stats.totalErrors)}e`, variant: 'insight' },
<div class="chart-insight-pill"><span class="chart-insight-label">bucket</span><strong>${escapeHTML(dashboardState.timeseries.bucket || '-')}</strong><span class="chart-insight-meta">${escapeHTML(String(stats.bucketCount))} points</span></div> { label: 'bucket', value: dashboardState.timeseries.bucket || '-', meta: stats.bucketCount + ' points', variant: 'insight' },
`; ], { variant: 'insights' });
} }
function renderDashboardChartHover(idx) { function renderDashboardChartHover(idx) {
@@ -475,6 +488,10 @@ function tallyTool(evt) {
if (attrs.span_kind === 'tool') { if (attrs.span_kind === 'tool') {
const name = attrs.name || 'unknown'; const name = attrs.name || 'unknown';
dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1; dashboardState.toolCounts[name] = (dashboardState.toolCounts[name] || 0) + 1;
const dur = Number(getEnvelopePayload(evt).duration_ms) || 0;
if (dur > 0) {
dashboardState.toolDurations[name] = (dashboardState.toolDurations[name] || 0) + dur;
}
} }
} }
} }
@@ -584,23 +601,11 @@ function renderTokenPanel() {
<div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div> <div class="token-stat-value">${escapeHTML(formatTokenCount(totalTokens))}</div>
</div> </div>
<div class="token-io-bars"> <div class="token-io-bars">
<div class="token-bar-row"> ${barRow({ name: 'Input', count: inputTokens, countDisplay: formatTokenCount(inputTokens), max: maxIO, modifier: 'input', size: 'md' })}
<span class="token-bar-label">Input</span> ${barRow({ name: 'Output', count: outputTokens, countDisplay: formatTokenCount(outputTokens), max: maxIO, modifier: 'output', size: 'md' })}
<div class="token-bar-track">
<div class="token-bar-fill input" style="width:${(inputTokens / maxIO * 100).toFixed(1)}%"></div>
</div>
<span class="token-bar-count">${escapeHTML(formatTokenCount(inputTokens))}</span>
</div>
<div class="token-bar-row">
<span class="token-bar-label">Output</span>
<div class="token-bar-track">
<div class="token-bar-fill output" style="width:${(outputTokens / maxIO * 100).toFixed(1)}%"></div>
</div>
<span class="token-bar-count">${escapeHTML(formatTokenCount(outputTokens))}</span>
</div>
</div> </div>
<div class="token-cost-display"> <div class="token-cost-display">
<span class="token-bar-label">Est. cost today</span> <span class="am-pill-label">Est. cost today</span>
<strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong> <strong>${escapeHTML(totalCost ? formatCost(totalCost) : '$0.0000')}</strong>
</div> </div>
</div> </div>
@@ -617,42 +622,34 @@ function renderLatencyPanel() {
return; return;
} }
const durSeries = ts.series.map(b => b.avg_duration_ms || 0).filter(v => v > 0); const latencyBuckets = ts.series.filter(b => (b.tool_avg_ms || 0) > 0);
if (durSeries.length === 0) { if (latencyBuckets.length === 0) {
container.innerHTML = '<p class="empty-state" style="padding:1rem">No run latency recorded yet</p>'; container.innerHTML = '<p class="empty-state" style="padding:1rem">No tool latency recorded yet</p>';
return; return;
} }
const durSeries = latencyBuckets.map(b => b.tool_avg_ms || 0);
const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length; const avg = durSeries.reduce((a, b) => a + b, 0) / durSeries.length;
const min = Math.min(...durSeries); const min = Math.min(...durSeries);
const max = Math.max(...durSeries); const p95 = Math.max(...latencyBuckets.map(b => b.tool_p95_ms || 0));
const maxBar = max || 1; const maxBar = Math.max(...durSeries) || 1;
container.innerHTML = ` container.innerHTML = `
<div class="latency-panel"> <div class="latency-panel">
<div class="latency-range"> <div class="latency-range">
<div class="latency-range-item"> ${metricPill({ label: 'Min', value: formatDuration(min), variant: 'range' })}
<span class="latency-range-label">Min</span> ${metricPill({ label: 'Avg', value: formatDuration(avg), variant: 'range' })}
<span class="latency-range-val">${escapeHTML(formatDuration(min))}</span> ${metricPill({ label: 'P95', value: formatDuration(p95), variant: 'range' })}
</div>
<div class="latency-range-item">
<span class="latency-range-label">Avg</span>
<span class="latency-range-val">${escapeHTML(formatDuration(avg))}</span>
</div>
<div class="latency-range-item">
<span class="latency-range-label">Max</span>
<span class="latency-range-val">${escapeHTML(formatDuration(max))}</span>
</div>
</div> </div>
<div class="latency-mini-bars"> <div class="latency-mini-bars">
${durSeries.map((v, i) => { ${latencyBuckets.map(b => {
const v = b.tool_avg_ms || 0;
const pct = (v / maxBar * 100).toFixed(1); const pct = (v / maxBar * 100).toFixed(1);
const label = ts.series.filter(b => b.avg_duration_ms > 0)[i]; const title = formatBucketLabel(b.ts) + ': ' + formatDuration(v);
const title = label ? formatBucketLabel(label.ts) + ': ' + formatDuration(v) : formatDuration(v);
return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`; return `<div class="latency-mini-bar" style="height:${pct}%" title="${escapeHTML(title)}"></div>`;
}).join('')} }).join('')}
</div> </div>
<div class="latency-range-label" style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim)">Avg run duration per bucket (${escapeHTML(ts.bucket || '-')})</div> <div class="am-pill-label" style="margin-top:0.5rem">Avg tool latency per bucket (${escapeHTML(ts.bucket || '-')})</div>
</div> </div>
`; `;
} }
@@ -675,21 +672,17 @@ function renderFrameworkBars() {
const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors)); const maxTotal = Math.max(...entries.map(([, s]) => s.runs + s.tools + s.errors));
container.innerHTML = '<div class="fw-bars">' + entries.map(([name, stats]) => { container.innerHTML = '<div style="display:flex;flex-direction:column;gap:0.75rem;margin-top:0.25rem">' + entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors; const total = stats.runs + stats.tools + stats.errors;
const pct = maxTotal > 0 ? (total / maxTotal * 100) : 0;
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-'); const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
return ` return barRow({
<div class="fw-bar-row"> name,
<div class="fw-bar-label"> count: total,
<span class="fw-bar-name">${escapeHTML(name)}</span> countDisplay: total + ' events',
<span class="fw-bar-count">${total} events</span> max: maxTotal,
</div> fwClass: cssClass,
<div class="fw-bar-track"> size: 'lg',
<div class="fw-bar-fill ${escapeHTML(cssClass)}" style="width:${pct}%"></div> });
</div>
</div>
`;
}).join('') + '</div>'; }).join('') + '</div>';
} }
@@ -758,61 +751,28 @@ function renderDashFeed() {
function renderDashTopTools() { function renderDashTopTools() {
const list = document.getElementById('dash-top-tools'); const list = document.getElementById('dash-top-tools');
if (!list) return; if (!list) return;
const topTools = Object.entries(dashboardState.toolCounts) const topTools = Object.entries(dashboardState.toolCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10); .slice(0, 10)
.map(([name, count]) => {
if (topTools.length === 0) { const durSum = dashboardState.toolDurations[name] || 0;
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No tool data yet</li>'; const avg = count > 0 ? durSum / count : 0;
return; const countDisplay = avg > 0
} ? `${formatCount(count)} · ${formatDuration(avg)}`
: formatCount(count);
const maxCount = topTools[0]?.[1] || 1; return { name, count, countDisplay };
list.innerHTML = topTools.map(([name, count]) => { });
const pct = (count / maxCount * 100).toFixed(1); list.innerHTML = barRankList(topTools, { emptyText: 'No tool data yet' });
return `
<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(name)}</span>
<span class="stat-list-count">${count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill" style="width:${pct}%"></div>
</div>
</li>
`;
}).join('');
} }
function renderDashTopModels() { function renderDashTopModels() {
const list = document.getElementById('dash-top-models'); const list = document.getElementById('dash-top-models');
if (!list) return; if (!list) return;
const topModels = Object.entries(dashboardState.modelCounts) const topModels = Object.entries(dashboardState.modelCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10); .slice(0, 10)
.map(([name, count]) => ({ name, count, modifier: 'model' }));
if (topModels.length === 0) { list.innerHTML = barRankList(topModels, { emptyText: 'No model data yet' });
list.innerHTML = '<li style="color:var(--text-dim);font-size:0.8rem">No model data yet</li>';
return;
}
const maxCount = topModels[0]?.[1] || 1;
list.innerHTML = topModels.map(([name, count]) => {
const pct = (count / maxCount * 100).toFixed(1);
return `
<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(name)}</span>
<span class="stat-list-count">${count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>
`;
}).join('');
} }
// ── Exports ────────────────────────────────────────────── // ── Exports ──────────────────────────────────────────────
@@ -828,6 +788,7 @@ export async function renderDashboard(routeToken) {
recentEvents: [], recentEvents: [],
recentEventIDs: new Set(), recentEventIDs: new Set(),
toolCounts: {}, toolCounts: {},
toolDurations: {},
modelCounts: {}, modelCounts: {},
rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework', rightPanelMode: localStorage.getItem('agentmon:dash:right-panel') || 'framework',
}; };
@@ -858,34 +819,21 @@ export async function renderDashboard(routeToken) {
<div class="summary-card-sub" id="dash-errors-sub">&nbsp;</div> <div class="summary-card-sub" id="dash-errors-sub">&nbsp;</div>
</div> </div>
</div> </div>
<div class="metrics-strip"> <div style="margin-bottom:1.25rem">${metricStrip([
<div class="metric-pill"> { label: 'Tokens today', valueId: 'dash-tokens-today' },
<span class="metric-pill-label">Tokens today</span> { label: 'Cost today', valueId: 'dash-cost-today' },
<span class="metric-pill-value" id="dash-tokens-today">-</span> { label: 'Avg run duration', valueId: 'dash-avg-duration' },
</div> { label: 'Error rate', valueId: 'dash-error-rate' },
<div class="metric-pill"> { label: 'Cost / run', valueId: 'dash-cost-per-run' },
<span class="metric-pill-label">Cost today</span> ])}</div>
<span class="metric-pill-value" id="dash-cost-today">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Avg run duration</span>
<span class="metric-pill-value" id="dash-avg-duration">-</span>
</div>
<div class="metric-pill">
<span class="metric-pill-label">Error rate</span>
<span class="metric-pill-value metric-pill-alert" id="dash-error-rate">-</span>
</div>
</div>
<div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div> <div class="section-title" style="margin-bottom:0.75rem">Infrastructure</div>
<div class="vm-strip" id="dash-vm-strip"></div> <div class="vm-strip" id="dash-vm-strip"></div>
<div class="charts-row"> <div class="charts-row">
<div class="chart-panel"> <div class="chart-panel">
<div class="chart-header"> ${chartHeader({
<div class="chart-title-group"> title: 'Event Rate',
<span class="chart-title">Event Rate</span> subtitle: 'Runs, tool spans, and errors over time',
<span class="chart-subtitle">Runs, tool spans, and errors over time</span> controls: `
</div>
<div class="chart-header-controls">
<div class="chart-legend"> <div class="chart-legend">
<span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span> <span class="chart-legend-item"><span class="chart-legend-dot total"></span>total</span>
<span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span> <span class="chart-legend-item"><span class="chart-legend-dot" style="background:#34d399"></span>runs</span>
@@ -901,21 +849,21 @@ export async function renderDashboard(routeToken) {
<button class="window-btn" data-w="6h">6h</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="24h">24h</button>
<button class="window-btn" data-w="7d">7d</button> <button class="window-btn" data-w="7d">7d</button>
</div> </div>`,
</div> })}
</div> <div id="dash-chart-insights" style="margin-bottom:0.9rem"></div>
<div class="chart-insights" id="dash-chart-insights"></div>
<div class="chart-container" id="dash-chart"></div> <div class="chart-container" id="dash-chart"></div>
<div class="chart-hover-panel" id="dash-chart-hover"></div> <div class="chart-hover-panel" id="dash-chart-hover"></div>
</div> </div>
<div class="chart-panel right-panel"> <div class="chart-panel right-panel">
<div class="chart-header"> ${chartHeader({
<div class="right-panel-tabs" id="dash-right-tabs"> controls: `
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button> <div class="right-panel-tabs" id="dash-right-tabs">
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'framework' ? 'active' : ''}" data-panel="framework">Framework</button>
<button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'tokens' ? 'active' : ''}" data-panel="tokens">Tokens</button>
</div> <button class="right-panel-tab ${dashboardState.rightPanelMode === 'latency' ? 'active' : ''}" data-panel="latency">Latency</button>
</div> </div>`,
})}
<div class="right-panel-body" id="dash-right-panel"> <div class="right-panel-body" id="dash-right-panel">
<p class="empty-state" style="padding:1rem">Loading...</p> <p class="empty-state" style="padding:1rem">Loading...</p>
</div> </div>
@@ -923,28 +871,20 @@ export async function renderDashboard(routeToken) {
</div> </div>
<div class="bottom-panels"> <div class="bottom-panels">
<div class="feed-panel"> <div class="feed-panel">
<div class="chart-header"> ${chartHeader({ title: 'Recent Activity' })}
<span class="chart-title">Recent Activity</span>
</div>
<div class="timeline" id="dash-feed"> <div class="timeline" id="dash-feed">
<p class="empty-state" style="padding:1rem">Loading...</p> <p class="empty-state" style="padding:1rem">Loading...</p>
</div> </div>
</div> </div>
<div class="tools-panel"> <div class="tools-panel">
<div class="chart-header"> ${chartHeader({ title: 'Top Usage' })}
<span class="chart-title">Top Usage</span>
</div>
<div class="usage-rank-group"> <div class="usage-rank-group">
<div class="usage-rank-header">Tools</div> <div class="usage-rank-header">Tools</div>
<ul class="stat-list" id="dash-top-tools"> <div id="dash-top-tools"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
</ul>
</div> </div>
<div class="usage-rank-group"> <div class="usage-rank-group">
<div class="usage-rank-header">Models</div> <div class="usage-rank-header">Models</div>
<ul class="stat-list" id="dash-top-models"> <div id="dash-top-models"><p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">Loading...</p></div>
<li style="color:var(--text-dim);font-size:0.8rem">Loading...</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@@ -1028,6 +968,7 @@ export async function renderDashboard(routeToken) {
for (const t of (topToolsData.tools || [])) { for (const t of (topToolsData.tools || [])) {
dashboardState.toolCounts[t.name] = t.count; dashboardState.toolCounts[t.name] = t.count;
dashboardState.toolDurations[t.name] = (t.avg_ms || 0) * (t.count || 0);
} }
for (const m of (topModelsData.models || [])) { for (const m of (topModelsData.models || [])) {
dashboardState.modelCounts[m.name] = m.count; dashboardState.modelCounts[m.name] = m.count;
@@ -19,6 +19,15 @@ export function cleanup() {
runLiveOps = {}; runLiveOps = {};
} }
function extractPromptPreview(spans) {
for (const sp of spans) {
const inner = (sp.payload || {}).payload || {};
if (inner.prompt_preview) return inner.prompt_preview;
if (inner.message_preview) return inner.message_preview;
}
return null;
}
function renderSpanPayload(sp) { function renderSpanPayload(sp) {
const outer = sp.payload || {}; const outer = sp.payload || {};
const inner = outer.payload || {}; const inner = outer.payload || {};
@@ -361,6 +370,7 @@ export async function renderRun(runID, routeToken) {
? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) ? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
: 'ongoing'; : 'ongoing';
const runUsage = extractRunUsage(spans); const runUsage = extractRunUsage(spans);
const promptPreview = extractPromptPreview(spans);
app.innerHTML = ` app.innerHTML = `
<a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a> <a href="/sessions/${escapeHTML(r.session_id)}" class="back-link">&larr; Back to Session</a>
@@ -382,6 +392,11 @@ export async function renderRun(runID, routeToken) {
</div> </div>
</div> </div>
${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''} ${!r.ended_at ? '<div class="run-live-ops" id="run-live-ops"></div>' : ''}
${promptPreview ? `
<div class="prompt-preview-section">
<div class="prompt-preview-label">Prompt</div>
<pre class="prompt-preview-text">${escapeHTML(promptPreview)}</pre>
</div>` : ''}
<div class="section-title"> <div class="section-title">
Spans <span class="count" id="run-detail-span-count">${spans.length}</span> Spans <span class="count" id="run-detail-span-count">${spans.length}</span>
${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''} ${!r.ended_at ? '<span class="live-indicator" style="margin-left:0.5rem"><span class="live-dot"></span>Live</span>' : ''}
@@ -227,27 +227,6 @@ function refreshSessionsTable() {
updatePaginationInfo(); updatePaginationInfo();
} }
// Dead code: renderSessionRow is never called but preserved for fidelity
function renderSessionRow(s) { // eslint-disable-line no-unused-vars
const fw = s.framework || 'unknown';
const fwClass = fw.replace(/[^a-z0-9-]/g, '-');
const active = isSessionActive(s);
const dotState = sessionDotState(s);
const dotTitle = dotState === 'active'
? 'Currently active session'
: (active ? 'Open session' : 'Session ended');
const errorCell = (s._errorCount || 0) > 0
? `<span class="error-count-badge">${s._errorCount}</span>`
: '<span style="color:var(--text-dim)">—</span>';
return `
<td class="id-cell" title="${escapeHTML(s.session_id)}">${escapeHTML(s.session_id.substring(0, 12))}${renderCopyButton(s.session_id)}</td>
<td><span class="fw-dot ${escapeHTML(fwClass)} ${dotState}" title="${dotTitle}"></span>${escapeHTML(fw)}</td>
<td>${escapeHTML(s.host || '-')}</td>
<td>${s.run_count}</td>
<td title="${escapeHTML(s.started_at)}">${escapeHTML(relativeTime(s.started_at))}</td>
<td>${errorCell}</td>
`;
}
function updateSessionTimers() { function updateSessionTimers() {
const tbody = document.getElementById('sessions-body'); const tbody = document.getElementById('sessions-body');
+209 -62
View File
@@ -1,6 +1,128 @@
import { app, isRouteCurrent } from '../router.js'; import { app, isRouteCurrent } from '../router.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { escapeHTML, formatTokenCount, formatCost } from '../utils.js'; import { escapeHTML, formatTokenCount, formatCost } from '../utils.js';
import { barTrack, barRankList, metricStrip, chartHeader } from '../components.js';
/* global uPlot */
let usageChart = null;
let usageChartMode = 'activity';
let usageResizeObserver = null;
let _usageSeries = [];
export function cleanup() {
if (usageChart) { usageChart.destroy(); usageChart = null; }
if (usageResizeObserver) { usageResizeObserver.disconnect(); usageResizeObserver = null; }
_usageSeries = [];
}
function buildChartData(series, mode) {
if (!series || series.length === 0) return null;
const ts = series.map(b => Math.floor(new Date(b.ts).getTime() / 1000));
if (mode === 'tokens') {
return [ts, series.map(b => b.input_tokens || 0), series.map(b => b.output_tokens || 0)];
}
if (mode === 'cost') {
return [ts, series.map(b => b.cost || 0)];
}
return [ts, series.map(b => b.runs || 0), series.map(b => b.tools || 0), series.map(b => b.errors || 0)];
}
function renderChart(series, mode) {
const container = document.getElementById('usage-chart');
if (!container) return;
if (usageChart) { usageChart.destroy(); usageChart = null; }
container.innerHTML = '';
const data = buildChartData(series, mode);
if (!data) {
container.innerHTML = '<p class="empty-state" style="padding:1.5rem">No data for this period</p>';
return;
}
const width = container.clientWidth || 580;
const height = 160;
const axisStyle = {
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',
};
let seriesDef;
if (mode === 'tokens') {
seriesDef = [
{},
{ label: 'Input', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Output', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
];
} else if (mode === 'cost') {
seriesDef = [
{},
{ label: 'Cost', stroke: '#fbbf24', width: 1.75, fill: 'rgba(251,191,36,0.1)', points: { show: false } },
];
} else {
seriesDef = [
{},
{ label: 'Runs', stroke: '#34d399', width: 1.5, fill: 'rgba(52,211,153,0.08)', points: { show: false } },
{ label: 'Tools', stroke: '#22d3ee', width: 1.5, fill: 'rgba(34,211,238,0.08)', points: { show: false } },
{ label: 'Errors', stroke: '#f87171', width: 1.5, fill: 'rgba(248,113,113,0.08)', points: { show: false } },
];
}
usageChart = new window.uPlot({
width, height,
cursor: { show: true },
scales: { x: { time: true }, y: { auto: true, min: 0 } },
axes: [{ ...axisStyle }, { ...axisStyle, size: 52 }],
series: seriesDef,
}, data, container);
if (usageResizeObserver) usageResizeObserver.disconnect();
usageResizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
if (usageChart) usageChart.setSize({ width: entry.contentRect.width, height });
}
});
usageResizeObserver.observe(container);
}
function renderFrameworkBreakdown(byFw) {
const el = document.getElementById('usage-fw-breakdown');
if (!el) return;
const entries = Object.entries(byFw || {}).sort((a, b) => {
return (b[1].runs + b[1].tools + b[1].errors) - (a[1].runs + a[1].tools + a[1].errors);
});
if (entries.length === 0) {
el.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), 1);
el.innerHTML = entries.map(([name, stats]) => {
const total = stats.runs + stats.tools + stats.errors;
const cssClass = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const active = (stats.active_sessions || 0) > 0;
return `
<div class="usage-fw-row">
<div class="usage-fw-name">
<span class="fw-dot ${escapeHTML(cssClass)} ${active ? 'active' : 'ended'}"></span>
<span>${escapeHTML(name)}</span>
${active ? `<span class="usage-fw-active-badge">${stats.active_sessions} live</span>` : ''}
</div>
<div class="usage-fw-stats">
<span class="usage-fw-stat"><span class="usage-fw-stat-label">runs</span>${stats.runs || 0}</span>
<span class="usage-fw-stat"><span class="usage-fw-stat-label">tools</span>${stats.tools || 0}</span>
${(stats.errors || 0) > 0 ? `<span class="usage-fw-stat error"><span class="usage-fw-stat-label">err</span>${stats.errors}</span>` : ''}
</div>
${barTrack({ value: total, max: maxTotal, fwClass: cssClass, size: 'sm' })}
</div>`;
}).join('');
}
export async function renderUsage(routeToken) { export async function renderUsage(routeToken) {
app.innerHTML = ` app.innerHTML = `
@@ -18,90 +140,115 @@ export async function renderUsage(routeToken) {
const tools = toolsData.tools || []; const tools = toolsData.tools || [];
const models = modelsData.models || []; const models = modelsData.models || [];
const series = tsData.series || [];
// Aggregate 7d totals from timeseries
const totals7d = series.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, cost: 0 });
const s = summary || {}; const s = summary || {};
_usageSeries = tsData.series || [];
const t = _usageSeries.reduce((acc, b) => {
acc.runs += b.runs || 0;
acc.tools += b.tools || 0;
acc.errors += b.errors || 0;
acc.tokens += b.tokens || 0;
acc.itok += b.input_tokens || 0;
acc.otok += b.output_tokens || 0;
acc.cost += b.cost || 0;
return acc;
}, { runs: 0, tools: 0, errors: 0, tokens: 0, itok: 0, otok: 0, cost: 0 });
const maxModel = models[0]?.count || 1;
const maxTool = tools[0]?.count || 1;
const content = document.getElementById('usage-content'); const content = document.getElementById('usage-content');
if (!content) return; if (!content) return;
content.innerHTML = ` content.innerHTML = `
<div class="usage-summary-tiles"> <div class="usage-summary-tiles">
<div class="meta-tile"><div class="meta-tile-label">Active Sessions</div><div class="meta-tile-value">${s.active_sessions || 0}</div></div> <div class="meta-tile">
<div class="meta-tile"><div class="meta-tile-label">Runs Today</div><div class="meta-tile-value">${s.runs_today || 0}</div></div> <div class="meta-tile-label">Active Sessions</div>
<div class="meta-tile"><div class="meta-tile-label">Tool Calls Today</div><div class="meta-tile-value">${s.tool_calls_today || 0}</div></div> <div class="meta-tile-value">${s.active_sessions || 0}</div>
<div class="meta-tile"><div class="meta-tile-label">Errors Today</div><div class="meta-tile-value">${s.errors_today || 0}</div></div> </div>
<div class="meta-tile"><div class="meta-tile-label">Tokens Today</div><div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div></div> <div class="meta-tile">
<div class="meta-tile"><div class="meta-tile-label">Cost Today</div><div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div></div> <div class="meta-tile-label">Runs Today</div>
<div class="meta-tile-value">${s.runs_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tool Calls Today</div>
<div class="meta-tile-value">${s.tool_calls_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Errors Today</div>
<div class="meta-tile-value${(s.errors_today || 0) > 0 ? ' has-errors' : ''}">${s.errors_today || 0}</div>
</div>
<div class="meta-tile">
<div class="meta-tile-label">Tokens Today</div>
<div class="meta-tile-value">${formatTokenCount(s.tokens_today || 0)}</div>
${(s.tokens_today || 0) > 0 ? `<div class="meta-tile-sub">${formatTokenCount(s.tokens_today * 0.7 || 0)} in · ${formatTokenCount(s.tokens_today * 0.3 || 0)} out</div>` : ''}
</div>
<div class="meta-tile">
<div class="meta-tile-label">Cost Today</div>
<div class="meta-tile-value">${formatCost(s.cost_today || 0)}</div>
${(s.runs_today || 0) > 0 ? `<div class="meta-tile-sub">${formatCost((s.cost_today || 0) / s.runs_today)}/run</div>` : ''}
</div>
</div> </div>
<div class="usage-section-row"> <div class="usage-section-row">
<div class="usage-panel"> <div class="usage-panel usage-chart-panel">
<div class="section-title">7-Day Totals</div> ${chartHeader({
<div class="usage-7d-tiles"> title: '7-Day Trend',
<div class="usage-7d-tile"><span class="usage-7d-label">Runs</span><strong>${totals7d.runs}</strong></div> controls: `
<div class="usage-7d-tile"><span class="usage-7d-label">Tool Calls</span><strong>${totals7d.tools}</strong></div> <div class="usage-chart-tabs" id="usage-chart-tabs">
<div class="usage-7d-tile"><span class="usage-7d-label">Errors</span><strong>${totals7d.errors}</strong></div> <button class="usage-chart-tab active" data-mode="activity">Activity</button>
<div class="usage-7d-tile"><span class="usage-7d-label">Tokens</span><strong>${formatTokenCount(totals7d.tokens)}</strong></div> <button class="usage-chart-tab" data-mode="tokens">Tokens</button>
<div class="usage-7d-tile"><span class="usage-7d-label">Est. Cost</span><strong>${formatCost(totals7d.cost)}</strong></div> <button class="usage-chart-tab" data-mode="cost">Cost</button>
</div>`,
})}
${metricStrip([
{ label: 'runs', value: String(t.runs), variant: 'total' },
{ label: 'tools', value: String(t.tools), variant: 'total' },
{ label: 'errors', value: String(t.errors), variant: 'total', alert: t.errors > 0 },
{ label: 'tokens', value: formatTokenCount(t.tokens), variant: 'total' },
{ label: 'cost', value: formatCost(t.cost), variant: 'total' },
])}
<div id="usage-chart"></div>
</div>
<div class="usage-panel usage-fw-panel">
<div class="section-title" style="margin-bottom:0.75rem">
Frameworks
<span class="count">today</span>
</div> </div>
<div id="usage-fw-breakdown"></div>
</div> </div>
</div> </div>
<div class="usage-section-row"> <div class="usage-section-row">
<div class="usage-panel"> <div class="usage-panel">
<div class="section-title">Top Models <span class="count">${models.length}</span></div> <div class="section-title">Top Models <span class="count">${models.length}</span></div>
${models.length === 0 ? '<p class="empty-state">No model data yet</p>' : ` ${barRankList(models, {
<ul class="stat-list" id="usage-models-list"> mapItem: m => ({ name: m.name, count: m.count, modifier: 'model' }),
${(() => { maxOverride: maxModel,
const max = models[0]?.count || 1; emptyText: 'No model data',
return models.map(m => { })}
const pct = (m.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(m.name)}</span>
<span class="stat-list-count">${m.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill model" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
</ul>`}
</div> </div>
<div class="usage-panel"> <div class="usage-panel">
<div class="section-title">Top Tools <span class="count">${tools.length}</span></div> <div class="section-title">Top Tools <span class="count">${tools.length}</span></div>
${tools.length === 0 ? '<p class="empty-state">No tool data yet</p>' : ` ${barRankList(tools, {
<ul class="stat-list" id="usage-tools-list"> mapItem: x => ({ name: x.name, count: x.count }),
${(() => { maxOverride: maxTool,
const max = tools[0]?.count || 1; emptyText: 'No tool data',
return tools.map(t => { })}
const pct = (t.count / max * 100).toFixed(1);
return `<li>
<div class="stat-list-header">
<span class="stat-list-name">${escapeHTML(t.name)}</span>
<span class="stat-list-count">${t.count}</span>
</div>
<div class="stat-list-bar-track">
<div class="stat-list-bar-fill tool" style="width:${pct}%"></div>
</div>
</li>`;
}).join('');
})()}
</ul>`}
</div> </div>
</div> </div>
`; `;
renderChart(_usageSeries, usageChartMode);
renderFrameworkBreakdown(s.by_framework);
document.querySelectorAll('.usage-chart-tab').forEach(btn => {
btn.addEventListener('click', () => {
if (usageChartMode === btn.dataset.mode) return;
usageChartMode = btn.dataset.mode;
document.querySelectorAll('.usage-chart-tab').forEach(b => b.classList.toggle('active', b === btn));
renderChart(_usageSeries, usageChartMode);
});
});
} }
+2 -1
View File
@@ -13,7 +13,7 @@ import { renderRun, cleanup as cleanupRunDetail } from './pages/run-
import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js'; import { renderAgents, cleanup as cleanupAgents } from './pages/agents.js';
import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js'; import { renderInfrastructure, cleanup as cleanupInfra } from './pages/infrastructure.js';
import { renderSettings } from './pages/settings.js'; import { renderSettings } from './pages/settings.js';
import { renderUsage } from './pages/usage.js'; import { renderUsage, cleanup as cleanupUsage } from './pages/usage.js';
// Exported so all page modules can write into it without querying the DOM each time // Exported so all page modules can write into it without querying the DOM each time
export const app = document.getElementById('app'); export const app = document.getElementById('app');
@@ -31,6 +31,7 @@ export function cleanupLiveViews() {
cleanupSessionDetail(); cleanupSessionDetail();
cleanupRunDetail(); cleanupRunDetail();
cleanupDashboard(); cleanupDashboard();
cleanupUsage();
} }
export function route() { export function route() {
+1 -1
View File
@@ -204,7 +204,7 @@ export function getVMName(evt) {
export function getVMClassName(vmName) { export function getVMClassName(vmName) {
const normalized = String(vmName || 'unknown').toLowerCase(); const normalized = String(vmName || 'unknown').toLowerCase();
return ['zap', 'orb', 'sun'].includes(normalized) ? normalized : 'unknown'; return ['zap'].includes(normalized) ? normalized : 'unknown';
} }
export function getEventIcon(eventType) { export function getEventIcon(eventType) {
+351 -289
View File
@@ -12,6 +12,7 @@
--text: #b8c5d4; --text: #b8c5d4;
--text-dim: #465a6e; --text-dim: #465a6e;
--text-mute: #6b7f94;
--text-bright: #e4edf5; --text-bright: #e4edf5;
--accent: #22d3ee; --accent: #22d3ee;
@@ -1276,18 +1277,6 @@ tr.expandable:hover .expand-icon::before {
border: 1px solid rgba(34, 211, 238, 0.2); border: 1px solid rgba(34, 211, 238, 0.2);
} }
.timeline-vm-tag.orb {
background: rgba(167, 139, 250, 0.12);
color: var(--purple);
border: 1px solid rgba(167, 139, 250, 0.2);
}
.timeline-vm-tag.sun {
background: rgba(251, 191, 36, 0.12);
color: var(--warning);
border: 1px solid rgba(251, 191, 36, 0.2);
}
.timeline-vm-tag.unknown { .timeline-vm-tag.unknown {
background: var(--surface-2); background: var(--surface-2);
color: var(--text-dim); color: var(--text-dim);
@@ -1416,57 +1405,214 @@ tr.expandable:hover .expand-icon::before {
margin-top: 0.1rem; margin-top: 0.1rem;
} }
.stat-list { /* ============================================================
Shared primitives (am-*): bars, pills, chart headers.
See modules/components.js for the renderers.
============================================================ */
/* ── Bars ─────────────────────────────────────────────── */
.am-bar-list {
list-style: none; list-style: none;
display: flex;
flex-direction: column;
} }
.stat-list li { .am-bar-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.35rem 0; padding: 0.35rem 0;
border-bottom: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft);
font-size: 0.8rem; font-size: 0.8rem;
} }
.stat-list li:last-child { .am-bar-list li:last-child { border-bottom: none; }
border-bottom: none;
.am-bar-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
} }
.stat-list-name { .am-bar-row-head {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text);
}
.stat-list-count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
background: var(--surface-2);
padding: 0.1rem 0.4rem;
border-radius: 4px;
}
.stat-list-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.stat-list-bar-track { .am-bar-row-name {
height: 3px; font-family: var(--font-mono);
background: var(--surface-2); font-size: 0.78rem;
border-radius: 2px; color: var(--text);
margin-top: 0.3rem;
overflow: hidden;
} }
.stat-list-bar-fill { .am-bar-row-count {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
.am-bar-track {
background: var(--surface-2);
border-radius: 3px;
overflow: hidden;
height: 6px;
}
.am-bar-track--xs { height: 3px; border-radius: 2px; }
.am-bar-track--sm { height: 4px; border-radius: 2px; }
.am-bar-track--md { height: 6px; border-radius: 3px; }
.am-bar-track--lg { height: 8px; border-radius: 4px; }
.am-bar-fill {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
border-radius: 2px; transition: width 0.4s ease;
transition: width 0.3s ease; }
/* Framework / category color modifiers (applied to .am-bar-fill) */
.am-bar-fill.fw-openclaw { background: var(--accent); }
.am-bar-fill.fw-claude-code { background: var(--success); }
.am-bar-fill.fw-opencode { background: var(--purple); }
.am-bar-fill.fw-hermes { background: var(--warning); }
.am-bar-fill.fw-codex { background: #60a5fa; }
.am-bar-fill.fw-gemini { background: #f97316; }
.am-bar-fill.fw-copilot { background: #2dd4bf; }
.am-bar-fill.fw-unknown { background: var(--text-dim); }
.am-bar-fill.model { background: var(--success); }
.am-bar-fill.input { background: var(--accent); }
.am-bar-fill.output { background: var(--purple); }
/* ── Pills ────────────────────────────────────────────── */
.am-pill-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.am-pill-strip--insights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 900px) {
.am-pill-strip--insights { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.am-pill-strip--insights { grid-template-columns: 1fr; }
}
.am-pill {
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.85rem;
min-width: 130px;
flex: 1;
}
.am-pill-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
font-family: var(--font-mono);
}
.am-pill-value {
font-size: 1.05rem;
font-weight: 600;
font-family: var(--font-mono);
color: var(--text-bright);
}
.am-pill-meta {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.am-pill.alert .am-pill-value { color: var(--error); }
/* Variant: chart insight (taller, gradient background) */
.am-pill--insight {
min-height: 62px;
border-color: var(--border-soft);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0));
padding: 0.75rem 0.85rem;
gap: 0.25rem;
}
.am-pill--insight .am-pill-value { font-size: 0.95rem; }
/* Variant: tight inline total (e.g. usage chart totals) */
.am-pill--total {
flex-direction: row;
align-items: baseline;
gap: 0.3rem;
background: var(--surface-2);
border-color: var(--border-soft);
border-radius: var(--radius);
padding: 0.22rem 0.55rem;
min-width: auto;
flex: 0 0 auto;
}
.am-pill--total .am-pill-label { font-size: 0.6rem; }
.am-pill--total .am-pill-value { font-size: 0.8rem; font-weight: normal; }
/* Variant: latency-range item (centered, borderless) */
.am-pill--range {
flex: 0 0 auto;
min-width: auto;
align-items: center;
gap: 0.1rem;
background: transparent;
border: none;
padding: 0;
}
.am-pill--range .am-pill-label { font-size: 0.68rem; }
.am-pill--range .am-pill-value { font-size: 1rem; font-weight: 600; color: var(--text); }
/* ── Chart headers ────────────────────────────────────── */
.am-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.am-chart-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.am-chart-title {
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 0.01em;
}
.am-chart-subtitle {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.02em;
}
.am-chart-controls {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
justify-content: flex-end;
} }
.event-icon { .event-icon {
@@ -1593,7 +1739,7 @@ tr.expandable:hover .expand-icon::before {
100% { transform: scale(1); } 100% { transform: scale(1); }
} }
.metric-pill-value.bumped { .am-pill-value.bumped {
animation: counterBump 400ms ease; animation: counterBump 400ms ease;
} }
@@ -1636,44 +1782,6 @@ tr.expandable:hover .expand-icon::before {
min-height: 280px; min-height: 280px;
} }
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.chart-title {
font-family: var(--font-display);
font-size: 0.88rem;
font-weight: 700;
color: var(--text-bright);
letter-spacing: 0.01em;
}
.chart-title-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.chart-subtitle {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
letter-spacing: 0.02em;
}
.chart-header-controls {
display: flex;
align-items: center;
gap: 0.85rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.window-selector { .window-selector {
display: flex; display: flex;
gap: 0; gap: 0;
@@ -1747,50 +1855,6 @@ tr.expandable:hover .expand-icon::before {
background: rgba(52, 211, 153, 0.12); background: rgba(52, 211, 153, 0.12);
} }
.chart-insights {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.9rem;
}
@media (max-width: 900px) {
.chart-insights {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.chart-insights {
grid-template-columns: 1fr;
}
}
.chart-insight-pill {
min-height: 62px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0));
padding: 0.75rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.chart-insight-label,
.chart-insight-meta {
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-dim);
letter-spacing: 0.03em;
text-transform: uppercase;
}
.chart-insight-pill strong {
font-size: 0.95rem;
color: var(--text-bright);
}
.chart-container { .chart-container {
width: 100%; width: 100%;
min-height: 200px; min-height: 200px;
@@ -1870,55 +1934,6 @@ tr.expandable:hover .expand-icon::before {
.chart-hover-metric.errors strong { color: #f87171; } .chart-hover-metric.errors strong { color: #f87171; }
.chart-hover-metric.delta strong { color: var(--accent); } .chart-hover-metric.delta strong { color: var(--accent); }
.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: 8px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
}
.fw-bar-fill {
height: 100%;
border-radius: 4px;
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; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 320px;
@@ -2014,10 +2029,6 @@ tr.expandable:hover .expand-icon::before {
box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2); box-shadow: 0 0 0 1px rgba(248, 250, 252, 0.2);
} }
.stat-list-bar-fill.model {
background: var(--success);
}
/* ── Framework dots ───────────────────────────────────────── */ /* ── Framework dots ───────────────────────────────────────── */
.fw-dot { .fw-dot {
display: inline-block; display: inline-block;
@@ -2034,6 +2045,10 @@ tr.expandable:hover .expand-icon::before {
.fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); } .fw-dot.openclaw { background: var(--accent); --fw-glow: var(--accent); }
.fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); } .fw-dot.claude-code { background: var(--success); --fw-glow: var(--success); }
.fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); } .fw-dot.opencode { background: var(--purple); --fw-glow: var(--purple); }
.fw-dot.hermes { background: var(--warning); --fw-glow: var(--warning); }
.fw-dot.codex { background: #60a5fa; --fw-glow: #60a5fa; }
.fw-dot.gemini { background: #f97316; --fw-glow: #f97316; }
.fw-dot.copilot { background: #2dd4bf; --fw-glow: #2dd4bf; }
.fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); } .fw-dot.unknown { background: var(--text-dim); --fw-glow: var(--text-dim); }
.fw-dot.ended { opacity: 0.3; } .fw-dot.ended { opacity: 0.3; }
.fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; } .fw-dot.active { box-shadow: 0 0 6px var(--fw-glow); animation: fwPulse 2s ease-in-out infinite; }
@@ -2121,6 +2136,7 @@ tr.expandable:hover .expand-icon::before {
--text: #3d4a5c; --text: #3d4a5c;
--text-dim: #8b9ab0; --text-dim: #8b9ab0;
--text-mute: #5c6b80;
--text-bright: #1a2332; --text-bright: #1a2332;
--accent: #0891b2; --accent: #0891b2;
@@ -2244,7 +2260,8 @@ tr.expandable:hover .expand-icon::before {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text-dim); color: var(--text-mute);
letter-spacing: 0.02em;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -2307,7 +2324,7 @@ tr.expandable:hover .expand-icon::before {
margin-top: 0.2rem; margin-top: 0.2rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.68rem; font-size: 0.68rem;
color: var(--text-dim); color: var(--text-mute);
} }
/* ── Agent Lane Sparklines ────────────────────────────────── */ /* ── Agent Lane Sparklines ────────────────────────────────── */
@@ -2321,10 +2338,10 @@ tr.expandable:hover .expand-icon::before {
.agent-lane-sparkline-bar { .agent-lane-sparkline-bar {
flex: 1; flex: 1;
background: var(--accent); background: linear-gradient(to top, var(--accent), var(--accent-glow));
border-radius: 1px 1px 0 0; border-radius: 1px 1px 0 0;
opacity: 0.5; opacity: 0.8;
min-height: 1px; min-height: 2px;
} }
.agent-lane-dot { .agent-lane-dot {
@@ -2348,7 +2365,7 @@ tr.expandable:hover .expand-icon::before {
.agent-lane-status { .agent-lane-status {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: var(--text-dim); color: var(--text-mute);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
@@ -2373,6 +2390,7 @@ tr.expandable:hover .expand-icon::before {
padding: 0.35rem 0.625rem; padding: 0.35rem 0.625rem;
background: var(--accent-dim); background: var(--accent-dim);
border: 1px solid rgba(34, 211, 238, 0.15); border: 1px solid rgba(34, 211, 238, 0.15);
border-left: 2px solid var(--accent);
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.75rem; font-size: 0.75rem;
animation: fadeUp 0.2s ease both; animation: fadeUp 0.2s ease both;
@@ -2380,11 +2398,17 @@ tr.expandable:hover .expand-icon::before {
[data-theme="light"] .active-op { [data-theme="light"] .active-op {
border-color: rgba(8, 145, 178, 0.2); border-color: rgba(8, 145, 178, 0.2);
border-left-color: var(--accent);
}
.active-op.run {
border-left-color: var(--accent);
} }
.active-op.subagent { .active-op.subagent {
background: rgba(167, 139, 250, 0.12); background: rgba(167, 139, 250, 0.12);
border-color: rgba(167, 139, 250, 0.22); border-color: rgba(167, 139, 250, 0.22);
border-left-color: var(--purple);
} }
.active-op.stale { .active-op.stale {
@@ -2456,6 +2480,7 @@ tr.expandable:hover .expand-icon::before {
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
border-left: 2px solid var(--border);
background: transparent; background: transparent;
font-size: 0.82rem; font-size: 0.82rem;
} }
@@ -2468,6 +2493,29 @@ tr.expandable:hover .expand-icon::before {
font-size: 0.6rem; font-size: 0.6rem;
} }
/* ── Event-kind accents + hierarchy (scoped to Agents page) ── */
#agents-content .timeline-event.evt-run { border-left-color: var(--accent); }
#agents-content .timeline-event.evt-error { border-left-color: var(--error); }
#agents-content .timeline-event.evt-session { border-left-color: var(--success); }
#agents-content .timeline-event.evt-span { border-left-color: rgba(34, 211, 238, 0.4); }
/* Demote the generic type label to a quiet category tag; let the
tool name / preview body carry the line. */
#agents-content .timeline-event-type {
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-mute);
}
#agents-content .timeline-event-body.tool-name,
#agents-content .timeline-event-body.subagent-name {
font-size: 0.82rem;
font-weight: 500;
}
.agent-lane-events .empty-state { .agent-lane-events .empty-state {
padding: 2rem 1rem; padding: 2rem 1rem;
font-size: 0.78rem; font-size: 0.78rem;
@@ -3115,44 +3163,6 @@ tr.clickable.active-session td:first-child {
margin: 0; margin: 0;
} }
/* ── Metrics strip ────────────────────────────────────────── */
.metrics-strip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.metric-pill {
display: flex;
flex-direction: column;
gap: 0.15rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.85rem;
min-width: 130px;
flex: 1;
}
.metric-pill-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.metric-pill-value {
font-size: 1.05rem;
font-weight: 600;
font-family: 'Fira Code', monospace;
color: var(--text);
}
.metric-pill-alert.alert {
color: var(--error);
}
/* ── Right panel tabs ─────────────────────────────────────── */ /* ── Right panel tabs ─────────────────────────────────────── */
.right-panel-tabs { .right-panel-tabs {
display: flex; display: flex;
@@ -3179,7 +3189,7 @@ tr.clickable.active-session td:first-child {
.right-panel-tab.active { .right-panel-tab.active {
color: var(--accent); color: var(--accent);
border-color: var(--accent); border-color: var(--accent);
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08); background: var(--accent-dim);
} }
.right-panel-body { .right-panel-body {
@@ -3211,7 +3221,7 @@ tr.clickable.active-session td:first-child {
.token-stat-value { .token-stat-value {
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
font-family: 'Fira Code', monospace; font-family: var(--font-mono);
color: var(--text); color: var(--text);
line-height: 1.1; line-height: 1.1;
} }
@@ -3222,47 +3232,6 @@ tr.clickable.active-session td:first-child {
gap: 0.5rem; gap: 0.5rem;
} }
.token-bar-row {
display: grid;
grid-template-columns: 3.5rem 1fr 3rem;
align-items: center;
gap: 0.5rem;
}
.token-bar-label {
font-size: 0.72rem;
color: var(--text-dim);
text-align: right;
}
.token-bar-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.token-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.token-bar-fill.input {
background: var(--accent);
}
.token-bar-fill.output {
background: var(--purple, #a78bfa);
}
.token-bar-count {
font-size: 0.72rem;
font-family: 'Fira Code', monospace;
color: var(--text-muted, var(--text-dim));
text-align: right;
}
.token-cost-display { .token-cost-display {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -3291,27 +3260,6 @@ tr.clickable.active-session td:first-child {
justify-content: space-between; justify-content: space-between;
} }
.latency-range-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.latency-range-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
.latency-range-val {
font-size: 1rem;
font-weight: 600;
font-family: 'Fira Code', monospace;
color: var(--text);
}
.latency-mini-bars { .latency-mini-bars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -3633,7 +3581,7 @@ tr.clickable.active-session td:first-child {
display: none; display: none;
} }
.chart-header-controls { .am-chart-controls {
justify-content: flex-start; justify-content: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -3932,3 +3880,117 @@ tr.clickable:focus-visible {
outline-offset: 2px; outline-offset: 2px;
border-radius: var(--radius); border-radius: var(--radius);
} }
/* ── Usage Page: Chart Panel ─────────────────────────────── */
.usage-chart-panel { flex: 2 1 400px; }
.usage-fw-panel { flex: 1 1 240px; }
.usage-chart-tabs {
display: flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.usage-chart-tab {
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--text-dim);
font-family: var(--font-body);
font-size: 0.72rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.usage-chart-tab:last-child { border-right: none; }
.usage-chart-tab:hover { color: var(--text-bright); background: var(--surface-2); }
.usage-chart-tab.active { color: var(--accent); background: var(--accent-dim); }
/* ── Usage Page: Framework Breakdown ─────────────────────── */
.usage-fw-row {
display: flex;
flex-direction: column;
gap: 0.28rem;
padding: 0.65rem 0;
border-bottom: 1px solid var(--border-soft);
}
.usage-fw-row:last-child { border-bottom: none; }
.usage-fw-name {
display: flex;
align-items: center;
gap: 0.45rem;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text);
}
.usage-fw-active-badge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--success);
background: rgba(52, 211, 153, 0.1);
border: 1px solid rgba(52, 211, 153, 0.2);
border-radius: 999px;
padding: 0.1rem 0.45rem;
}
.usage-fw-stats {
display: flex;
gap: 0.75rem;
font-family: var(--font-mono);
font-size: 0.72rem;
}
.usage-fw-stat {
display: flex;
gap: 0.28rem;
align-items: baseline;
color: var(--text);
}
.usage-fw-stat.error { color: var(--error); }
.usage-fw-stat-label {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-dim);
}
/* ── Run Detail: Prompt Preview ──────────────────────────── */
.prompt-preview-section {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--purple);
border-radius: var(--radius-lg);
padding: 0.875rem 1.125rem;
margin-bottom: 1.5rem;
}
.prompt-preview-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.prompt-preview-text {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--code-text);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.65;
margin: 0;
max-height: 180px;
overflow-y: auto;
}
/* ── meta-tile: sub-line ─────────────────────────────────── */
.meta-tile-value.has-errors { color: var(--error); }
+2 -2
View File
@@ -1,6 +1,6 @@
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16.13
container_name: agentmon-db container_name: agentmon-db
environment: environment:
POSTGRES_PASSWORD: pass POSTGRES_PASSWORD: pass
@@ -22,7 +22,7 @@ services:
restart: unless-stopped restart: unless-stopped
nats: nats:
image: nats:latest image: nats@sha256:7971c76fcd4057c090faf5bc7673199ffe0ae586704518e9a469f156155b4e47
container_name: agentmon-nats container_name: agentmon-nats
ports: ports:
- "4222:4222" - "4222:4222"
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -172,6 +172,7 @@ async function readStdin() {
var INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080"; var INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080";
var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code"; var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "claude-code";
var HOST = process.env.AGENTMON_HOST || hostname(); var HOST = process.env.AGENTMON_HOST || hostname();
var ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === "1";
var { enqueue, flush } = createTransport(INGEST_URL); var { enqueue, flush } = createTransport(INGEST_URL);
var STATE_DIR = join(homedir(), ".agentmon-state"); var STATE_DIR = join(homedir(), ".agentmon-state");
function ensureStateDir() { function ensureStateDir() {
@@ -231,6 +232,15 @@ function isNonPersistentClaudeLaunch() {
({ cmd }) => cmd.includes("/claude") && cmd.includes("--no-session-persistence") ({ cmd }) => cmd.includes("/claude") && cmd.includes("--no-session-persistence")
); );
} }
function hasClaudeProcessAncestor() {
return getProcessTree().some(({ cmd }) => {
if (!cmd)
return false;
if (cmd.includes("agentmon-handler") || cmd.includes("/hooks/claude-code/handler"))
return false;
return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes("@anthropic-ai/claude-code");
});
}
var activeRuns = /* @__PURE__ */ new Map(); var activeRuns = /* @__PURE__ */ new Map();
var activeSpans = /* @__PURE__ */ new Map(); var activeSpans = /* @__PURE__ */ new Map();
var activeSubagents = /* @__PURE__ */ new Map(); var activeSubagents = /* @__PURE__ */ new Map();
@@ -319,9 +329,13 @@ async function handleSessionStart(input) {
console.error("[agentmon] ignoring claude-code startup from --no-session-persistence launch"); console.error("[agentmon] ignoring claude-code startup from --no-session-persistence launch");
return; return;
} }
if (pickString(input.source) === "startup" && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
console.error("[agentmon] ignoring claude-code startup session");
return;
}
const runId = randomUUID2(); const runId = randomUUID2();
activeRuns.set(sessionKey, runId); activeRuns.set(sessionKey, runId);
saveState(sessionKey, { runId, spans: {} }); saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
attributes: { attributes: {
@@ -348,7 +362,7 @@ async function handleSessionEnd(input) {
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
const usage = getUsage(input); const usage = getUsage(input);
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms); const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0);
if (runId) { if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId, runId,
@@ -384,14 +398,23 @@ async function handlePromptSubmit(input) {
runId, runId,
payload: { payload: {
status: "success", status: "success",
duration_ms: pickNumber(input.elapsed_ms, input.duration_ms) duration_ms: pickNumber(input.elapsed_ms, input.duration_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0)
}
}));
}
if (!runId && sessionKey) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
attributes: {
cwd: pickString(input.cwd),
transcript_path: pickString(input.transcript_path),
source: pickString(input.source)
} }
})); }));
} }
const newRunId = randomUUID2(); const newRunId = randomUUID2();
if (sessionKey) { if (sessionKey) {
activeRuns.set(sessionKey, newRunId); activeRuns.set(sessionKey, newRunId);
saveState(sessionKey, { runId: newRunId, spans: {} }); saveState(sessionKey, { runId: newRunId, runStartedAt: Date.now(), spans: {} });
} }
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
runId: newRunId, runId: newRunId,
@@ -590,10 +613,10 @@ async function handleNotification(input) {
const notificationType = pickString(input.notification_type, input.type); const notificationType = pickString(input.notification_type, input.type);
const usage = getUsage(input); const usage = getUsage(input);
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
if (notificationType === "Done" || notificationType === "success") { if (notificationType === "Done" || notificationType === "success") {
const state = sessionKey ? loadState(sessionKey) : { spans: {} }; const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0; const runId = sessionKey ? activeRuns.get(sessionKey) || state.runId : void 0;
const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0);
if (runId) { if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId, runId,
+29 -5
View File
@@ -17,12 +17,14 @@ import {
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080'; const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'claude-code'; const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'claude-code';
const HOST = process.env.AGENTMON_HOST || hostname(); const HOST = process.env.AGENTMON_HOST || hostname();
const ALLOW_STARTUP_SESSIONS = process.env.AGENTMON_CLAUDE_ALLOW_STARTUP === '1';
const { enqueue, flush } = createTransport(INGEST_URL); const { enqueue, flush } = createTransport(INGEST_URL);
// ── Persisted state (survives between hook subprocess invocations) ────────── // ── Persisted state (survives between hook subprocess invocations) ──────────
interface SessionState { interface SessionState {
runId?: string; runId?: string;
runStartedAt?: number; // epoch ms when the current run began
spans: { [key: string]: string }; // key = sessionKey:toolName, value = spanId spans: { [key: string]: string }; // key = sessionKey:toolName, value = spanId
spanStartTimes?: { [spanId: string]: number }; // spanId -> epoch ms spanStartTimes?: { [spanId: string]: number }; // spanId -> epoch ms
subagent?: { name: string; spanId: string }; subagent?: { name: string; spanId: string };
@@ -89,6 +91,14 @@ function isNonPersistentClaudeLaunch() {
cmd.includes('/claude') && cmd.includes('--no-session-persistence') cmd.includes('/claude') && cmd.includes('--no-session-persistence')
); );
} }
function hasClaudeProcessAncestor() {
return getProcessTree().some(({ cmd }) => {
if (!cmd) return false;
if (cmd.includes('agentmon-handler') || cmd.includes('/hooks/claude-code/handler')) return false;
return /(^|[/\s])claude(\s|$)/.test(cmd) || cmd.includes('@anthropic-ai/claude-code');
});
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const activeRuns = new Map<string, string>(); const activeRuns = new Map<string, string>();
@@ -182,10 +192,14 @@ async function handleSessionStart(input: Dict) {
console.error('[agentmon] ignoring claude-code startup from --no-session-persistence launch'); console.error('[agentmon] ignoring claude-code startup from --no-session-persistence launch');
return; return;
} }
if (pickString(input.source) === 'startup' && (!ALLOW_STARTUP_SESSIONS || !hasClaudeProcessAncestor())) {
console.error('[agentmon] ignoring claude-code startup session');
return;
}
const runId = randomUUID(); const runId = randomUUID();
activeRuns.set(sessionKey, runId); activeRuns.set(sessionKey, runId);
saveState(sessionKey, { runId, spans: {} }); saveState(sessionKey, { runId, runStartedAt: Date.now(), spans: {} });
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
@@ -217,7 +231,7 @@ async function handleSessionEnd(input: Dict) {
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined; const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
const usage = getUsage(input); const usage = getUsage(input);
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms); const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined);
if (runId) { if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
@@ -257,7 +271,17 @@ async function handlePromptSubmit(input: Dict) {
runId, runId,
payload: { payload: {
status: 'success', status: 'success',
duration_ms: pickNumber(input.elapsed_ms, input.duration_ms), duration_ms: pickNumber(input.elapsed_ms, input.duration_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined),
},
}));
}
if (!runId && sessionKey) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
attributes: {
cwd: pickString(input.cwd),
transcript_path: pickString(input.transcript_path),
source: pickString(input.source),
}, },
})); }));
} }
@@ -265,7 +289,7 @@ async function handlePromptSubmit(input: Dict) {
const newRunId = randomUUID(); const newRunId = randomUUID();
if (sessionKey) { if (sessionKey) {
activeRuns.set(sessionKey, newRunId); activeRuns.set(sessionKey, newRunId);
saveState(sessionKey, { runId: newRunId, spans: {} }); saveState(sessionKey, { runId: newRunId, runStartedAt: Date.now(), spans: {} });
} }
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, {
@@ -485,11 +509,11 @@ async function handleNotification(input: Dict) {
const notificationType = pickString(input.notification_type, input.type); const notificationType = pickString(input.notification_type, input.type);
const usage = getUsage(input); const usage = getUsage(input);
const contextWindow = getContextWindow(input); const contextWindow = getContextWindow(input);
const duration = pickNumber(input.duration_ms, input.elapsed_ms);
if (notificationType === 'Done' || notificationType === 'success') { if (notificationType === 'Done' || notificationType === 'success') {
const state = sessionKey ? loadState(sessionKey) : { spans: {} }; const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined; const runId = sessionKey ? (activeRuns.get(sessionKey) || state.runId) : undefined;
const duration = pickNumber(input.duration_ms, input.elapsed_ms) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined);
if (runId) { if (runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, { enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env node
// handler.ts
import { randomUUID as randomUUID2 } from "node:crypto";
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { homedir, hostname } from "node:os";
import { join } from "node:path";
// ../shared/lib.ts
import { randomUUID } from "node:crypto";
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function pickString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim() !== "") {
return value;
}
}
return void 0;
}
function pickNumber(...values) {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return void 0;
}
function truncate(value, limit) {
if (value === void 0 || value === null) {
return void 0;
}
const text = typeof value === "string" ? value : safeJSONStringify(value);
if (!text) {
return void 0;
}
if (text.length <= limit) {
return text;
}
return text.slice(0, limit) + "...";
}
function safeJSONStringify(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function buildEnvelope(framework, host, type, sessionKey, opts = {}) {
const correlation = {};
if (sessionKey) {
correlation.session_id = sessionKey;
}
if (opts.runId) {
correlation.run_id = opts.runId;
}
if (opts.spanId) {
correlation.span_id = opts.spanId;
}
if (opts.parentSpanId) {
correlation.parent_span_id = opts.parentSpanId;
}
const envelope = {
schema: { name: "agentmon.event", version: 1 },
event: {
id: randomUUID(),
type,
ts: (/* @__PURE__ */ new Date()).toISOString(),
source: {
framework,
client_id: host,
host
}
}
};
if (Object.keys(correlation).length > 0) {
envelope.correlation = correlation;
}
if (opts.attributes && Object.keys(opts.attributes).length > 0) {
envelope.attributes = opts.attributes;
}
if (opts.payload && Object.keys(opts.payload).length > 0) {
envelope.payload = opts.payload;
}
return envelope;
}
function createTransport(ingestUrl, opts) {
const batchSize = opts?.batchSize ?? 10;
const flushMs = opts?.flushMs ?? 2e3;
const fetchTimeoutMs = opts?.fetchTimeoutMs ?? 500;
let buffer = [];
let flushTimer = null;
let isFlushing = false;
async function postBatch(batch) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
await fetch(`${ingestUrl}/v1/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
signal: controller.signal
});
} finally {
clearTimeout(timeout);
}
}
function scheduleFlush() {
if (!flushTimer) {
flushTimer = setTimeout(() => {
void flush2();
}, flushMs);
}
}
async function flush2() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (isFlushing || buffer.length === 0) {
return;
}
isFlushing = true;
const batch = buffer.splice(0, batchSize);
try {
await postBatch(batch);
} catch {
console.debug(`[agentmon] failed to flush ${batch.length} events`);
} finally {
isFlushing = false;
if (buffer.length > 0) {
if (buffer.length >= batchSize) {
void flush2();
} else {
scheduleFlush();
}
}
}
}
function enqueue2(event) {
buffer.push(event);
if (buffer.length >= batchSize) {
void flush2();
} else {
scheduleFlush();
}
}
return { enqueue: enqueue2, flush: flush2 };
}
async function readStdin() {
return new Promise((resolve) => {
let data = "";
let done = false;
const timer = setTimeout(() => finish(data), 100);
const finish = (value) => {
if (done)
return;
done = true;
clearTimeout(timer);
resolve(value);
};
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => finish(data));
process.stdin.on("error", () => finish(""));
});
}
// handler.ts
var INGEST_URL = process.env.AGENTMON_INGEST_URL || "http://localhost:8080";
var FRAMEWORK = process.env.AGENTMON_FRAMEWORK || "hermes";
var HOST = process.env.AGENTMON_HOST || hostname();
var { enqueue, flush } = createTransport(INGEST_URL);
var USAGE_TOKEN_FIELDS = [
"input_tokens",
"output_tokens",
"total_tokens",
"cache_read_tokens",
"cache_write_tokens",
"reasoning_tokens",
"total_cost"
];
function accumulateUsage(into, usage) {
if (!usage) {
return into;
}
for (const key of USAGE_TOKEN_FIELDS) {
const v = pickNumber(usage[key]);
if (v !== void 0) {
into[key] = (pickNumber(into[key]) ?? 0) + v;
}
}
return into;
}
function runUsagePayload(state) {
return state.runUsage && Object.keys(state.runUsage).length > 0 ? state.runUsage : void 0;
}
var STATE_DIR = join(homedir(), ".agentmon-state", "hermes");
function ensureStateDir() {
try {
mkdirSync(STATE_DIR, { recursive: true });
} catch {
}
}
function loadState(sessionKey) {
try {
const raw = readFileSync(join(STATE_DIR, sessionKey + ".json"), "utf8");
const state = JSON.parse(raw);
return { spans: {}, ...state };
} catch {
return { spans: {} };
}
}
function saveState(sessionKey, state) {
if (!sessionKey) {
return;
}
ensureStateDir();
try {
writeFileSync(join(STATE_DIR, sessionKey + ".json"), JSON.stringify(state), "utf8");
} catch {
}
}
function clearState(sessionKey) {
if (!sessionKey) {
return;
}
try {
unlinkSync(join(STATE_DIR, sessionKey + ".json"));
} catch {
}
}
function getExtra(input) {
return isRecord(input.extra) ? input.extra : {};
}
function getSessionKey(input) {
const extra = getExtra(input);
return pickString(
input.session_id,
input.sessionId,
input.sessionID,
input.session,
extra.session_id,
extra.sessionId,
extra.sessionID,
extra.parent_session_id,
extra.task_id
);
}
function getToolCallId(input) {
const extra = getExtra(input);
return pickString(input.tool_call_id, extra.tool_call_id, extra.call_id, extra.id);
}
function getToolName(input) {
const extra = getExtra(input);
return pickString(input.tool_name, input.tool, input.name, extra.tool_name, extra.tool, extra.name) || "unknown";
}
function getUsage(input) {
const extra = getExtra(input);
const usage = isRecord(input.usage) ? input.usage : isRecord(extra.usage) ? extra.usage : void 0;
if (!usage)
return void 0;
const result = {};
if (usage.input_tokens !== void 0)
result.input_tokens = usage.input_tokens;
if (usage.prompt_tokens !== void 0)
result.input_tokens = usage.prompt_tokens;
if (usage.output_tokens !== void 0)
result.output_tokens = usage.output_tokens;
if (usage.completion_tokens !== void 0)
result.output_tokens = usage.completion_tokens;
if (usage.cache_read_tokens !== void 0)
result.cache_read_tokens = usage.cache_read_tokens;
if (usage.cache_write_tokens !== void 0)
result.cache_write_tokens = usage.cache_write_tokens;
if (usage.cache_creation_tokens !== void 0)
result.cache_write_tokens = usage.cache_creation_tokens;
if (usage.reasoning_tokens !== void 0)
result.reasoning_tokens = usage.reasoning_tokens;
if (usage.total_tokens !== void 0)
result.total_tokens = usage.total_tokens;
if (usage.total_cost !== void 0)
result.total_cost = usage.total_cost;
if (usage.cost !== void 0)
result.total_cost = usage.cost;
return Object.keys(result).length > 0 ? result : void 0;
}
function getModel(input) {
const extra = getExtra(input);
return pickString(input.model, extra.model, extra.response_model);
}
function getPlatform(input) {
const extra = getExtra(input);
return pickString(input.platform, extra.platform);
}
function getDuration(input) {
const extra = getExtra(input);
return pickNumber(input.duration_ms, input.elapsed_ms, extra.duration_ms, extra.api_duration, extra.elapsed_ms);
}
function ensureSessionStarted(sessionKey, input, state) {
if (!sessionKey || state.sessionStarted) {
return;
}
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
attributes: {
synthetic: true,
recovered_from: "hermes-hook",
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input)
}
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
async function handleSessionStart(input) {
const sessionKey = getSessionKey(input) || randomUUID2();
const state = loadState(sessionKey);
if (!state.sessionStarted) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.start", sessionKey, {
attributes: {
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input)
}
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
await flush();
}
async function handleRunStart(input) {
const sessionKey = getSessionKey(input) || randomUUID2();
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId: state.runId,
payload: {
status: "success",
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : void 0,
...runUsagePayload(state) && { usage: runUsagePayload(state) }
}
}));
}
const runId = randomUUID2();
state.runId = runId;
state.runStartedAt = Date.now();
state.runUsage = {};
saveState(sessionKey, state);
const extra = getExtra(input);
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.start", sessionKey, {
runId,
attributes: {
is_first_turn: extra.is_first_turn,
platform: getPlatform(input),
model: getModel(input)
},
payload: {
prompt_preview: truncate(extra.user_message ?? input.user_message, 200)
}
}));
await flush();
}
async function handleRunEnd(input) {
const sessionKey = getSessionKey(input);
if (!sessionKey) {
await flush();
return;
}
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId: state.runId,
payload: {
status: "success",
duration_ms: getDuration(input) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : void 0),
model: getModel(input),
response_preview: truncate(getExtra(input).assistant_response, 500),
...runUsagePayload(state) && { usage: runUsagePayload(state) }
}
}));
state.runId = void 0;
state.runStartedAt = void 0;
state.runUsage = {};
saveState(sessionKey, state);
}
await flush();
}
async function handleToolStart(input) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
ensureSessionStarted(sessionKey, input, state);
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = randomUUID2();
state.spans[spanKey] = spanId;
saveState(sessionKey, state);
enqueue(buildEnvelope(FRAMEWORK, HOST, "span.start", sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: "tool",
name: toolName,
tool_call_id: toolCallId
},
payload: {
input: truncate(input.tool_input ?? getExtra(input).args, 500)
}
}));
await flush();
}
async function handleToolEnd(input) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = state.spans[spanKey];
const extra = getExtra(input);
const result = extra.result ?? input.result;
const resultRecord = isRecord(result) ? result : {};
const success = extra.error === void 0 && resultRecord.error === void 0;
enqueue(buildEnvelope(FRAMEWORK, HOST, "span.end", sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: "tool",
name: toolName,
tool_call_id: toolCallId
},
payload: {
status: success ? "success" : "error",
result_preview: truncate(resultRecord.output ?? resultRecord.error ?? extra.error ?? result, 500),
duration_ms: getDuration(input)
}
}));
delete state.spans[spanKey];
saveState(sessionKey, state);
await flush();
}
async function handleAPIResult(input) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const usage = getUsage(input);
if (!usage) {
await flush();
return;
}
if (sessionKey) {
state.runUsage = accumulateUsage(state.runUsage || {}, usage);
saveState(sessionKey, state);
}
enqueue(buildEnvelope(FRAMEWORK, HOST, "metric.snapshot", sessionKey, {
runId: state.runId,
payload: {
metrics: {
usage,
model: getModel(input),
provider: pickString(getExtra(input).provider),
duration_ms: getDuration(input)
}
}
}));
await flush();
}
async function handleSessionEnd(input) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, "run.end", sessionKey, {
runId: state.runId,
payload: {
status: input.interrupted || getExtra(input).interrupted ? "interrupted" : "success",
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : void 0,
model: getModel(input),
...runUsagePayload(state) && { usage: runUsagePayload(state) }
}
}));
}
enqueue(buildEnvelope(FRAMEWORK, HOST, "session.end", sessionKey, {
payload: {
model: getModel(input),
completed: getExtra(input).completed,
interrupted: getExtra(input).interrupted
}
}));
clearState(sessionKey);
await flush();
}
var handler = async () => {
const hookType = process.argv[2] || "unknown";
let input = {};
try {
const stdin = await readStdin();
if (stdin) {
input = JSON.parse(stdin);
}
} catch {
input = {};
}
try {
switch (hookType) {
case "session-start":
case "start":
await handleSessionStart(input);
break;
case "run-start":
case "pre-llm":
await handleRunStart(input);
break;
case "run-end":
case "post-llm":
await handleRunEnd(input);
break;
case "tool-start":
await handleToolStart(input);
break;
case "tool-end":
await handleToolEnd(input);
break;
case "api-result":
case "post-api":
await handleAPIResult(input);
break;
case "session-end":
case "stop":
await handleSessionEnd(input);
break;
default:
console.debug(`[agentmon] unknown hermes hook type: ${hookType}`);
}
} catch (err) {
console.debug("[agentmon] hermes handler error:", err);
}
};
handler();
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto';
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join } from 'node:path';
import {
type Dict,
isRecord,
pickString,
pickNumber,
truncate,
buildEnvelope,
createTransport,
readStdin,
} from '../shared/lib';
const INGEST_URL = process.env.AGENTMON_INGEST_URL || 'http://localhost:8080';
const FRAMEWORK = process.env.AGENTMON_FRAMEWORK || 'hermes';
const HOST = process.env.AGENTMON_HOST || hostname();
const { enqueue, flush } = createTransport(INGEST_URL);
interface SessionState {
sessionStarted?: boolean;
runId?: string;
runStartedAt?: number;
runUsage?: Dict;
spans: { [key: string]: string };
}
// Token fields are reported per LLM call (api-result). A single run (user turn)
// can span several calls, so we sum them into a per-run total that rides along
// on run.end — the location the stats layer reads usage from.
const USAGE_TOKEN_FIELDS = [
'input_tokens',
'output_tokens',
'total_tokens',
'cache_read_tokens',
'cache_write_tokens',
'reasoning_tokens',
'total_cost',
];
function accumulateUsage(into: Dict, usage: Dict | undefined): Dict {
if (!usage) {
return into;
}
for (const key of USAGE_TOKEN_FIELDS) {
const v = pickNumber(usage[key]);
if (v !== undefined) {
into[key] = (pickNumber(into[key]) ?? 0) + v;
}
}
return into;
}
function runUsagePayload(state: SessionState): Dict | undefined {
return state.runUsage && Object.keys(state.runUsage).length > 0 ? state.runUsage : undefined;
}
const STATE_DIR = join(homedir(), '.agentmon-state', 'hermes');
function ensureStateDir() {
try {
mkdirSync(STATE_DIR, { recursive: true });
} catch {
// Hooks should never fail the agent because telemetry state is unavailable.
}
}
function loadState(sessionKey: string): SessionState {
try {
const raw = readFileSync(join(STATE_DIR, sessionKey + '.json'), 'utf8');
const state = JSON.parse(raw) as SessionState;
return { spans: {}, ...state };
} catch {
return { spans: {} };
}
}
function saveState(sessionKey: string | undefined, state: SessionState) {
if (!sessionKey) {
return;
}
ensureStateDir();
try {
writeFileSync(join(STATE_DIR, sessionKey + '.json'), JSON.stringify(state), 'utf8');
} catch {
// Best effort only.
}
}
function clearState(sessionKey: string | undefined) {
if (!sessionKey) {
return;
}
try {
unlinkSync(join(STATE_DIR, sessionKey + '.json'));
} catch {
// Best effort only.
}
}
function getExtra(input: Dict): Dict {
return isRecord(input.extra) ? input.extra : {};
}
function getSessionKey(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(
input.session_id,
input.sessionId,
input.sessionID,
input.session,
extra.session_id,
extra.sessionId,
extra.sessionID,
extra.parent_session_id,
extra.task_id,
);
}
function getToolCallId(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.tool_call_id, extra.tool_call_id, extra.call_id, extra.id);
}
function getToolName(input: Dict): string {
const extra = getExtra(input);
return pickString(input.tool_name, input.tool, input.name, extra.tool_name, extra.tool, extra.name) || 'unknown';
}
function getUsage(input: Dict): Dict | undefined {
const extra = getExtra(input);
const usage = isRecord(input.usage) ? input.usage :
isRecord(extra.usage) ? extra.usage :
undefined;
if (!usage) return undefined;
const result: Dict = {};
if (usage.input_tokens !== undefined) result.input_tokens = usage.input_tokens;
if (usage.prompt_tokens !== undefined) result.input_tokens = usage.prompt_tokens;
if (usage.output_tokens !== undefined) result.output_tokens = usage.output_tokens;
if (usage.completion_tokens !== undefined) result.output_tokens = usage.completion_tokens;
if (usage.cache_read_tokens !== undefined) result.cache_read_tokens = usage.cache_read_tokens;
if (usage.cache_write_tokens !== undefined) result.cache_write_tokens = usage.cache_write_tokens;
if (usage.cache_creation_tokens !== undefined) result.cache_write_tokens = usage.cache_creation_tokens;
if (usage.reasoning_tokens !== undefined) result.reasoning_tokens = usage.reasoning_tokens;
if (usage.total_tokens !== undefined) result.total_tokens = usage.total_tokens;
if (usage.total_cost !== undefined) result.total_cost = usage.total_cost;
if (usage.cost !== undefined) result.total_cost = usage.cost;
return Object.keys(result).length > 0 ? result : undefined;
}
function getModel(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.model, extra.model, extra.response_model);
}
function getPlatform(input: Dict): string | undefined {
const extra = getExtra(input);
return pickString(input.platform, extra.platform);
}
function getDuration(input: Dict): number | undefined {
const extra = getExtra(input);
return pickNumber(input.duration_ms, input.elapsed_ms, extra.duration_ms, extra.api_duration, extra.elapsed_ms);
}
function ensureSessionStarted(sessionKey: string | undefined, input: Dict, state: SessionState) {
if (!sessionKey || state.sessionStarted) {
return;
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
attributes: {
synthetic: true,
recovered_from: 'hermes-hook',
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input),
},
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
async function handleSessionStart(input: Dict) {
const sessionKey = getSessionKey(input) || randomUUID();
const state = loadState(sessionKey);
if (!state.sessionStarted) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.start', sessionKey, {
attributes: {
cwd: pickString(input.cwd),
platform: getPlatform(input),
model: getModel(input),
},
}));
state.sessionStarted = true;
saveState(sessionKey, state);
}
await flush();
}
async function handleRunStart(input: Dict) {
const sessionKey = getSessionKey(input) || randomUUID();
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: 'success',
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
}
const runId = randomUUID();
state.runId = runId;
state.runStartedAt = Date.now();
state.runUsage = {};
saveState(sessionKey, state);
const extra = getExtra(input);
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.start', sessionKey, {
runId,
attributes: {
is_first_turn: extra.is_first_turn,
platform: getPlatform(input),
model: getModel(input),
},
payload: {
prompt_preview: truncate(extra.user_message ?? input.user_message, 200),
},
}));
await flush();
}
async function handleRunEnd(input: Dict) {
const sessionKey = getSessionKey(input);
if (!sessionKey) {
await flush();
return;
}
const state = loadState(sessionKey);
ensureSessionStarted(sessionKey, input, state);
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: 'success',
duration_ms: getDuration(input) ?? (state.runStartedAt ? Date.now() - state.runStartedAt : undefined),
model: getModel(input),
response_preview: truncate(getExtra(input).assistant_response, 500),
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
state.runId = undefined;
state.runStartedAt = undefined;
state.runUsage = {};
saveState(sessionKey, state);
}
await flush();
}
async function handleToolStart(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
ensureSessionStarted(sessionKey, input, state);
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = randomUUID();
state.spans[spanKey] = spanId;
saveState(sessionKey, state);
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.start', sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: 'tool',
name: toolName,
tool_call_id: toolCallId,
},
payload: {
input: truncate(input.tool_input ?? getExtra(input).args, 500),
},
}));
await flush();
}
async function handleToolEnd(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const toolName = getToolName(input);
const toolCallId = getToolCallId(input);
const spanKey = toolCallId || toolName;
const spanId = state.spans[spanKey];
const extra = getExtra(input);
const result = extra.result ?? input.result;
const resultRecord = isRecord(result) ? result : {};
const success = extra.error === undefined && resultRecord.error === undefined;
enqueue(buildEnvelope(FRAMEWORK, HOST, 'span.end', sessionKey, {
runId: state.runId,
spanId,
attributes: {
span_kind: 'tool',
name: toolName,
tool_call_id: toolCallId,
},
payload: {
status: success ? 'success' : 'error',
result_preview: truncate(resultRecord.output ?? resultRecord.error ?? extra.error ?? result, 500),
duration_ms: getDuration(input),
},
}));
delete state.spans[spanKey];
saveState(sessionKey, state);
await flush();
}
async function handleAPIResult(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
const usage = getUsage(input);
if (!usage) {
await flush();
return;
}
if (sessionKey) {
state.runUsage = accumulateUsage(state.runUsage || {}, usage);
saveState(sessionKey, state);
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'metric.snapshot', sessionKey, {
runId: state.runId,
payload: {
metrics: {
usage,
model: getModel(input),
provider: pickString(getExtra(input).provider),
duration_ms: getDuration(input),
},
},
}));
await flush();
}
async function handleSessionEnd(input: Dict) {
const sessionKey = getSessionKey(input);
const state = sessionKey ? loadState(sessionKey) : { spans: {} };
if (state.runId) {
enqueue(buildEnvelope(FRAMEWORK, HOST, 'run.end', sessionKey, {
runId: state.runId,
payload: {
status: input.interrupted || getExtra(input).interrupted ? 'interrupted' : 'success',
duration_ms: state.runStartedAt ? Date.now() - state.runStartedAt : undefined,
model: getModel(input),
...(runUsagePayload(state) && { usage: runUsagePayload(state) }),
},
}));
}
enqueue(buildEnvelope(FRAMEWORK, HOST, 'session.end', sessionKey, {
payload: {
model: getModel(input),
completed: getExtra(input).completed,
interrupted: getExtra(input).interrupted,
},
}));
clearState(sessionKey);
await flush();
}
const handler = async () => {
const hookType = process.argv[2] || 'unknown';
let input: Dict = {};
try {
const stdin = await readStdin();
if (stdin) {
input = JSON.parse(stdin);
}
} catch {
input = {};
}
try {
switch (hookType) {
case 'session-start':
case 'start':
await handleSessionStart(input);
break;
case 'run-start':
case 'pre-llm':
await handleRunStart(input);
break;
case 'run-end':
case 'post-llm':
await handleRunEnd(input);
break;
case 'tool-start':
await handleToolStart(input);
break;
case 'tool-end':
await handleToolEnd(input);
break;
case 'api-result':
case 'post-api':
await handleAPIResult(input);
break;
case 'session-end':
case 'stop':
await handleSessionEnd(input);
break;
default:
console.debug(`[agentmon] unknown hermes hook type: ${hookType}`);
}
} catch (err) {
console.debug('[agentmon] hermes handler error:', err);
}
};
handler();
+21
View File
@@ -0,0 +1,21 @@
hooks:
on_session_start:
- command: "~/.local/bin/agentmon-hermes-handler session-start"
pre_llm_call:
- command: "~/.local/bin/agentmon-hermes-handler run-start"
post_llm_call:
- command: "~/.local/bin/agentmon-hermes-handler run-end"
pre_tool_call:
- matcher: ".*"
command: "~/.local/bin/agentmon-hermes-handler tool-start"
post_tool_call:
- matcher: ".*"
command: "~/.local/bin/agentmon-hermes-handler tool-end"
post_api_request:
- command: "~/.local/bin/agentmon-hermes-handler api-result"
on_session_finalize:
- command: "~/.local/bin/agentmon-hermes-handler session-end"
on_session_reset:
- command: "~/.local/bin/agentmon-hermes-handler session-start"
hooks_auto_accept: true
+463
View File
@@ -0,0 +1,463 @@
{
"name": "@anthropic-ai/agentmon-hermes",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@anthropic-ai/agentmon-hermes",
"version": "1.0.0",
"bin": {
"agentmon-hermes-handler": "handler.js"
},
"devDependencies": {
"esbuild": "^0.20.0",
"typescript": "^5.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.20.2",
"@esbuild/android-arm": "0.20.2",
"@esbuild/android-arm64": "0.20.2",
"@esbuild/android-x64": "0.20.2",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@esbuild/freebsd-arm64": "0.20.2",
"@esbuild/freebsd-x64": "0.20.2",
"@esbuild/linux-arm": "0.20.2",
"@esbuild/linux-arm64": "0.20.2",
"@esbuild/linux-ia32": "0.20.2",
"@esbuild/linux-loong64": "0.20.2",
"@esbuild/linux-mips64el": "0.20.2",
"@esbuild/linux-ppc64": "0.20.2",
"@esbuild/linux-riscv64": "0.20.2",
"@esbuild/linux-s390x": "0.20.2",
"@esbuild/linux-x64": "0.20.2",
"@esbuild/netbsd-x64": "0.20.2",
"@esbuild/openbsd-x64": "0.20.2",
"@esbuild/sunos-x64": "0.20.2",
"@esbuild/win32-arm64": "0.20.2",
"@esbuild/win32-ia32": "0.20.2",
"@esbuild/win32-x64": "0.20.2"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@anthropic-ai/agentmon-hermes",
"version": "1.0.0",
"description": "agentmon hook handler for Hermes Agent",
"main": "handler.js",
"type": "module",
"bin": {
"agentmon-hermes-handler": "./handler.js"
},
"scripts": {
"build": "npx esbuild handler.ts --bundle --platform=node --format=esm --outfile=handler.js"
},
"dependencies": {},
"devDependencies": {
"esbuild": "^0.20.0",
"typescript": "^5.3.0"
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*Session
session_id, session_id,
MIN(ts) as started_at, MIN(ts) as started_at,
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at, MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at,
MAX(source_framework) as framework, COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework,
MAX(payload->'event'->'source'->>'host') as host MAX(payload->'event'->'source'->>'host') as host
FROM events FROM events
WHERE session_id = $1 WHERE session_id = $1
+57 -1
View File
@@ -89,7 +89,7 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow,
session_id, session_id,
MIN(ts) as started_at, MIN(ts) as started_at,
MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at, MAX(CASE WHEN type = 'session.end' THEN ts END) as ended_at,
MAX(source_framework) as framework, COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') as framework,
MAX(client_id) as client_id, MAX(client_id) as client_id,
MAX(payload->'event'->'source'->>'host') as host, MAX(payload->'event'->'source'->>'host') as host,
COUNT(DISTINCT run_id) as run_count COUNT(DISTINCT run_id) as run_count
@@ -136,3 +136,59 @@ func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow,
return out, nextCursor, rows.Err() return out, nextCursor, rows.Err()
} }
// CountSessions returns the total number of sessions matching the filter (without limit or cursor).
func (d *DB) CountSessions(ctx context.Context, f SessionsFilter) (int, error) {
var innerConditions []string
var outerConditions []string
var args []any
argN := 1
if f.From == nil {
t := time.Now().Add(-24 * time.Hour)
f.From = &t
}
innerConditions = append(innerConditions, fmt.Sprintf("ts >= $%d", argN))
args = append(args, *f.From)
argN++
if f.To != nil {
innerConditions = append(innerConditions, fmt.Sprintf("ts <= $%d", argN))
args = append(args, *f.To)
argN++
}
if f.Framework != "" {
innerConditions = append(innerConditions, fmt.Sprintf("source_framework = $%d", argN))
args = append(args, f.Framework)
argN++
}
if f.Host != "" {
outerConditions = append(outerConditions, fmt.Sprintf("host = $%d", argN))
args = append(args, f.Host)
argN++
}
innerWhere := strings.Join(innerConditions, " AND ")
outerWhere := ""
if len(outerConditions) > 0 {
outerWhere = "WHERE " + strings.Join(outerConditions, " AND ")
}
query := fmt.Sprintf(`
WITH session_groups AS (
SELECT
session_id,
MAX(payload->'event'->'source'->>'host') as host
FROM events
WHERE session_id IS NOT NULL AND %s
GROUP BY session_id
)
SELECT COUNT(*) FROM session_groups %s
`, innerWhere, outerWhere)
var count int
err := d.sql.QueryRowContext(ctx, query, args...).Scan(&count)
return count, err
}
+24 -7
View File
@@ -33,6 +33,8 @@ type TimeseriesBucket struct {
OutputTokens int64 `json:"output_tokens"` OutputTokens int64 `json:"output_tokens"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
AvgDurationMS float64 `json:"avg_duration_ms"` AvgDurationMS float64 `json:"avg_duration_ms"`
ToolAvgMS float64 `json:"tool_avg_ms"`
ToolP95MS float64 `json:"tool_p95_ms"`
} }
type TimeseriesResult struct { type TimeseriesResult struct {
@@ -51,7 +53,7 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
WITH session_groups AS ( WITH session_groups AS (
SELECT SELECT
session_id, session_id,
COALESCE(MAX(source_framework), 'unknown') AS framework, COALESCE((ARRAY_AGG(source_framework ORDER BY CASE WHEN type = 'session.start' THEN 0 ELSE 1 END, ts) FILTER (WHERE source_framework IS NOT NULL))[1], 'unknown') AS framework,
MAX(ts) AS last_event_at, MAX(ts) AS last_event_at,
BOOL_OR(type = 'session.start') AS has_start, BOOL_OR(type = 'session.start') AS has_start,
BOOL_OR(type = 'session.end') AS has_end BOOL_OR(type = 'session.end') AS has_end
@@ -157,8 +159,11 @@ func (d *DB) GetSummary(ctx context.Context) (*Summary, error) {
} }
type TopTool struct { type TopTool struct {
Name string `json:"name"` Name string `json:"name"`
Count int `json:"count"` Count int `json:"count"`
AvgMS float64 `json:"avg_ms"`
P95MS float64 `json:"p95_ms"`
Errors int `json:"errors"`
} }
type TopModel struct { type TopModel struct {
@@ -176,7 +181,11 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
q := ` q := `
SELECT SELECT
payload->'attributes'->>'name' AS tool_name, payload->'attributes'->>'name' AS tool_name,
COUNT(*) AS cnt COUNT(*) AS cnt,
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8), 0) AS avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (
ORDER BY (payload->'payload'->>'duration_ms')::float8), 0) AS p95_ms,
COUNT(*) FILTER (WHERE payload->'payload'->>'status' = 'error') AS errors
FROM events FROM events
WHERE type = 'span.end' WHERE type = 'span.end'
AND payload->'attributes'->>'span_kind' = 'tool' AND payload->'attributes'->>'span_kind' = 'tool'
@@ -195,7 +204,7 @@ func (d *DB) GetTopTools(ctx context.Context, limit int) ([]TopTool, error) {
var out []TopTool var out []TopTool
for rows.Next() { for rows.Next() {
var t TopTool var t TopTool
if err := rows.Scan(&t.Name, &t.Count); err != nil { if err := rows.Scan(&t.Name, &t.Count, &t.AvgMS, &t.P95MS, &t.Errors); err != nil {
return nil, err return nil, err
} }
out = append(out, t) out = append(out, t)
@@ -300,7 +309,14 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul
COALESCE(SUM((payload->'payload'->'usage'->>'total_cost')::float8) COALESCE(SUM((payload->'payload'->'usage'->>'total_cost')::float8)
FILTER (WHERE type = 'run.end'), 0) AS cost, FILTER (WHERE type = 'run.end'), 0) AS cost,
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8) COALESCE(AVG((payload->'payload'->>'duration_ms')::float8)
FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms FILTER (WHERE type = 'run.end'), 0) AS avg_duration_ms,
COALESCE(AVG((payload->'payload'->>'duration_ms')::float8)
FILTER (WHERE type = 'span.end'
AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_avg_ms,
COALESCE(percentile_cont(0.95) WITHIN GROUP (
ORDER BY (payload->'payload'->>'duration_ms')::float8)
FILTER (WHERE type = 'span.end'
AND payload->'attributes'->>'span_kind' = 'tool'), 0) AS tool_p95_ms
FROM events FROM events
WHERE ts >= $2 WHERE ts >= $2
AND type IN ('run.start', 'run.end', 'span.end', 'error') AND type IN ('run.start', 'run.end', 'span.end', 'error')
@@ -318,7 +334,8 @@ func (d *DB) GetTimeseries(ctx context.Context, window string) (*TimeseriesResul
for rows.Next() { for rows.Next() {
var b TimeseriesBucket var b TimeseriesBucket
if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors, if err := rows.Scan(&b.TS, &b.Runs, &b.Tools, &b.Errors,
&b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS); err != nil { &b.Tokens, &b.InputTokens, &b.OutputTokens, &b.Cost, &b.AvgDurationMS,
&b.ToolAvgMS, &b.ToolP95MS); err != nil {
return nil, err return nil, err
} }
series = append(series, b) series = append(series, b)