bosun 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
@@ -2114,25 +2114,36 @@ function VoiceEndpointsEditor() {
2114
2114
  ];
2115
2115
  }, []);
2116
2116
 
2117
- const normalizeEp = useCallback((ep = {}, idx = 0) => ({
2118
- _id: ep._id ?? `ep-${idx}-${Date.now()}`,
2119
- name: String(ep.name || `endpoint-${idx + 1}`),
2120
- provider: ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider) ? ep.provider : "azure",
2117
+ const normalizeEp = useCallback((ep = {}, idx = 0) => {
2118
+ const provider = ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider)
2119
+ ? ep.provider
2120
+ : "azure";
2121
+ const transcriptionEnabled = ep.transcriptionEnabled == null
2122
+ ? provider !== "azure"
2123
+ : ep.transcriptionEnabled !== false;
2124
+ return {
2125
+ _id: ep._id ?? `ep-${idx}-${Date.now()}`,
2126
+ name: String(ep.name || `endpoint-${idx + 1}`),
2127
+ provider,
2121
2128
  endpoint: (() => {
2122
- const p = ["azure", "openai", "claude", "gemini", "custom"].includes(ep.provider) ? ep.provider : "azure";
2123
2129
  const raw = String(ep.endpoint || "");
2124
- return (p === "azure" || p === "custom") ? raw : (raw || getDefaultEndpointUrl(p, ep.authSource));
2130
+ return (provider === "azure" || provider === "custom")
2131
+ ? raw
2132
+ : (raw || getDefaultEndpointUrl(provider, ep.authSource));
2125
2133
  })(),
2126
2134
  deployment: String(ep.deployment || ""),
2127
2135
  model: String(ep.model || ""),
2128
2136
  visionModel: String(ep.visionModel || ""),
2137
+ transcriptionModel: String(ep.transcriptionModel || ""),
2138
+ transcriptionEnabled,
2129
2139
  apiKey: String(ep.apiKey || ""),
2130
2140
  voiceId: String(ep.voiceId || ""),
2131
2141
  role: ["primary", "backup"].includes(ep.role) ? ep.role : "primary",
2132
2142
  weight: Number(ep.weight) > 0 ? Number(ep.weight) : 1,
2133
2143
  enabled: ep.enabled !== false,
2134
2144
  authSource: ["apiKey", "oauth"].includes(ep.authSource) ? ep.authSource : "apiKey",
2135
- }), [getDefaultEndpointUrl]);
2145
+ };
2146
+ }, [getDefaultEndpointUrl]);
2136
2147
 
2137
2148
  // Fetch OAuth status for all providers
2138
2149
  const fetchOAuthStatuses = useCallback(async () => {
@@ -2359,12 +2370,20 @@ function VoiceEndpointsEditor() {
2359
2370
  onInput=${(e) => updateEndpoint(ep._id, "endpoint", e.target.value)} />
2360
2371
  </div>
2361
2372
  <div style="grid-column:1/-1">
2362
- <div class="setting-row-label">Audio Model (Realtime)</div>
2363
- <input type="text" value=${ep.deployment} placeholder="gpt-realtime-1.5"
2373
+ <div class="setting-row-label">Deployment Name</div>
2374
+ <input type="text" value=${ep.deployment} placeholder="my-gpt-4o-realtime"
2364
2375
  onInput=${(e) => updateEndpoint(ep._id, "deployment", e.target.value)} />
2365
2376
  <div class="meta-text" style="margin-top:3px">
2366
- Connectivity tests use the endpoint URL exactly as entered.
2367
- If you enter only a host, Bosun appends the default Azure OpenAI probe route.
2377
+ The deployment name from Azure AI Foundry (not the model name).
2378
+ Find it under your resource Deployments. Leave empty to test credentials only.
2379
+ </div>
2380
+ </div>
2381
+ <div style="grid-column:1/-1">
2382
+ <div class="setting-row-label">Audio Model (Realtime)</div>
2383
+ <input type="text" value=${ep.model} placeholder="gpt-4o-realtime-preview"
2384
+ onInput=${(e) => updateEndpoint(ep._id, "model", e.target.value)} />
2385
+ <div class="meta-text" style="margin-top:3px">
2386
+ The underlying model name (e.g. gpt-4o-realtime-preview). Used at runtime.
2368
2387
  </div>
2369
2388
  </div>
2370
2389
  `}
@@ -2451,6 +2470,27 @@ function VoiceEndpointsEditor() {
2451
2470
  onInput=${(e) => updateEndpoint(ep._id, "visionModel", e.target.value)} />
2452
2471
  <div class="meta-text" style="margin-top:3px">Model used for screenshot / image analysis tasks.</div>
2453
2472
  </div>
2473
+ ${(ep.provider === "openai" || ep.provider === "azure") && html`
2474
+ <div style="grid-column:1/-1;display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">
2475
+ <div>
2476
+ <div class="setting-row-label">Transcription Model</div>
2477
+ <input type="text" value=${ep.transcriptionModel || ""}
2478
+ placeholder="gpt-4o-transcribe"
2479
+ onInput=${(e) => updateEndpoint(ep._id, "transcriptionModel", e.target.value)} />
2480
+ <div class="meta-text" style="margin-top:3px">
2481
+ Model used for input audio transcription. Leave blank for default (gpt-4o-transcribe).
2482
+ ${ep.provider === "azure" ? " Azure endpoints default transcription OFF unless enabled." : ""}
2483
+ </div>
2484
+ </div>
2485
+ <div style="display:flex;align-items:center;gap:6px;padding-bottom:22px">
2486
+ <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px">
2487
+ <input type="checkbox" checked=${ep.transcriptionEnabled !== false}
2488
+ onChange=${(e) => updateEndpoint(ep._id, "transcriptionEnabled", e.target.checked)} />
2489
+ Enable
2490
+ </label>
2491
+ </div>
2492
+ </div>
2493
+ `}
2454
2494
  </div>
2455
2495
  <!-- Test Connection -->
2456
2496
  <div style="display:flex;align-items:center;gap:10px;margin-top:8px">
@@ -1,166 +1,388 @@
1
1
  /* ─────────────────────────────────────────────────────────────
2
- * Tab: Telemetry analytics, quality signals, alerts
2
+ * Tab: Telemetry / Usage Analytics
3
+ * Shows agent runs, skill invocations, MCP tool usage, activity
4
+ * trend chart, and top-N bar charts.
3
5
  * ────────────────────────────────────────────────────────────── */
4
6
  import { h } from "preact";
5
- import { useMemo } from "preact/hooks";
7
+ import { useState, useMemo, useEffect } from "preact/hooks";
6
8
  import htm from "htm";
7
9
 
8
10
  const html = htm.bind(h);
9
11
 
10
12
  import {
11
- telemetrySummary,
12
13
  telemetryErrors,
13
- telemetryExecutors,
14
14
  telemetryAlerts,
15
+ usageAnalytics,
15
16
  loadTelemetrySummary,
16
17
  loadTelemetryErrors,
17
18
  loadTelemetryExecutors,
18
19
  loadTelemetryAlerts,
20
+ loadUsageAnalytics,
19
21
  scheduleRefresh,
20
22
  } from "../modules/state.js";
21
- import { Card, EmptyState, SkeletonCard, Badge } from "../components/shared.js";
23
+ import { Card, EmptyState, Badge } from "../components/shared.js";
24
+
25
+ // ── Colour palettes ──────────────────────────────────────────────────────────
26
+
27
+ const AGENT_PALETTE = [
28
+ "#6366f1", "#8b5cf6", "#a78bfa", "#c4b5fd", "#e879f9", "#f472b6",
29
+ ];
30
+ const SKILL_PALETTE = [
31
+ "#10b981", "#14b8a6", "#f59e0b", "#84cc16", "#2dd4bf", "#fbbf24",
32
+ ];
33
+ const MCP_PALETTE = [
34
+ "#f97316", "#fb923c", "#fbbf24", "#f43f5e", "#22d3ee", "#a3e635",
35
+ ];
36
+
37
+ function paletteColor(palette, index) {
38
+ return palette[index % palette.length];
39
+ }
40
+
41
+ // ── Formatters ───────────────────────────────────────────────────────────────
22
42
 
23
43
  function formatCount(value) {
24
44
  if (value == null) return "–";
45
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
25
46
  return String(value);
26
47
  }
27
48
 
28
- function formatSeconds(value) {
29
- if (!value && value !== 0) return "";
30
- if (value >= 60) return `${Math.round(value / 60)}m`;
31
- return `${value}s`;
49
+ function formatRelative(isoStr) {
50
+ if (!isoStr) return "never";
51
+ const diff = Date.now() - Date.parse(isoStr);
52
+ if (!Number.isFinite(diff) || diff < 0) return "just now";
53
+ const mins = Math.floor(diff / 60000);
54
+ if (mins < 2) return "just now";
55
+ if (mins < 60) return `${mins} minutes ago`;
56
+ const hrs = Math.floor(mins / 60);
57
+ if (hrs < 24) return `about ${hrs} hour${hrs > 1 ? "s" : ""} ago`;
58
+ const days = Math.floor(hrs / 24);
59
+ return `${days} day${days > 1 ? "s" : ""} ago`;
60
+ }
61
+
62
+ function formatSinceDate(isoStr) {
63
+ if (!isoStr) return null;
64
+ const d = new Date(isoStr);
65
+ if (isNaN(d)) return null;
66
+ return d.toLocaleDateString("en-US", {
67
+ month: "short", day: "numeric", year: "numeric",
68
+ });
32
69
  }
33
70
 
34
71
  function severityBadge(sev = "medium") {
35
- const normalized = String(sev).toLowerCase();
36
- if (normalized === "high" || normalized === "critical") return "danger";
37
- if (normalized === "medium") return "warning";
72
+ const n = String(sev).toLowerCase();
73
+ if (n === "high" || n === "critical") return "danger";
74
+ if (n === "medium") return "warning";
38
75
  return "info";
39
76
  }
40
77
 
78
+ // ── SVG Trend Chart ──────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * Renders smooth catmull-rom curves for each series in `seriesMap`.
82
+ * `seriesMap` is `{ name: number[] }` aligned with `dates`.
83
+ */
84
+ function TrendLines({ dates, seriesMap, palette }) {
85
+ if (!dates?.length || !seriesMap) return null;
86
+ const entries = Object.entries(seriesMap);
87
+ if (!entries.length) return null;
88
+
89
+ const W = 400, H = 140;
90
+ const PL = 28, PR = 8, PT = 8, PB = 24;
91
+ const iW = W - PL - PR;
92
+ const iH = H - PT - PB;
93
+
94
+ const allVals = entries.flatMap(([, v]) => v);
95
+ const maxVal = Math.max(...allVals, 1);
96
+ const n = dates.length;
97
+ const xOf = (i) => PL + (n < 2 ? iW / 2 : (i / (n - 1)) * iW);
98
+ const yOf = (v) => PT + iH - (v / maxVal) * iH;
99
+
100
+ function smoothPath(values) {
101
+ if (!values.length) return "";
102
+ const pts = values.map((v, i) => [xOf(i), yOf(v)]);
103
+ if (pts.length === 1) return `M ${pts[0][0]} ${pts[0][1]}`;
104
+ let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
105
+ for (let i = 0; i < pts.length - 1; i++) {
106
+ const p0 = pts[Math.max(0, i - 1)];
107
+ const p1 = pts[i];
108
+ const p2 = pts[i + 1];
109
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
110
+ const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
111
+ const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
112
+ const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
113
+ const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
114
+ d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)},${cp2x.toFixed(1)} ${cp2y.toFixed(1)},${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
115
+ }
116
+ return d;
117
+ }
118
+
119
+ const ySteps = [0, Math.ceil(maxVal / 2), maxVal];
120
+ const labelIdxs = n <= 3 ? [...Array(n).keys()] : [0, Math.floor(n / 2), n - 1];
121
+ const xLabels = [...new Set(labelIdxs)].map((i) => ({
122
+ x: xOf(i),
123
+ label: (dates[i] || "").slice(5),
124
+ }));
125
+
126
+ return html`
127
+ <svg viewBox="0 0 ${W} ${H}" class="analytics-trend-svg" aria-hidden="true">
128
+ ${ySteps.map((v) => html`
129
+ <g key=${v}>
130
+ <line x1=${PL} y1=${yOf(v)} x2=${W - PR} y2=${yOf(v)}
131
+ stroke="var(--border)" stroke-width="0.5" stroke-dasharray="3,3"/>
132
+ <text x=${PL - 4} y=${yOf(v) + 4} text-anchor="end"
133
+ font-size="9" fill="var(--text-hint)">${v}</text>
134
+ </g>
135
+ `)}
136
+ ${xLabels.map(({ x, label }) => html`
137
+ <text key=${label} x=${x} y=${H - 5} text-anchor="middle"
138
+ font-size="9" fill="var(--text-hint)">${label}</text>
139
+ `)}
140
+ ${entries.map(([name, values], i) => html`
141
+ <path key=${name} d=${smoothPath(values)} fill="none"
142
+ stroke=${paletteColor(palette, i)} stroke-width="1.8"
143
+ stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/>
144
+ `)}
145
+ </svg>
146
+ `;
147
+ }
148
+
149
+ function ChartLegend({ label, seriesMap, palette }) {
150
+ const names = Object.keys(seriesMap || {});
151
+ if (!names.length) return null;
152
+ return html`
153
+ <div class="analytics-legend-group">
154
+ <span class="analytics-legend-category">${label}</span>
155
+ ${names.map((name, i) => html`
156
+ <span key=${name} class="analytics-legend-item">
157
+ <span class="analytics-legend-dot"
158
+ style="background:${paletteColor(palette, i)}"></span>
159
+ ${name}
160
+ </span>
161
+ `)}
162
+ </div>
163
+ `;
164
+ }
165
+
166
+ // ── Bar Chart ────────────────────────────────────────────────────────────────
167
+
168
+ function TopBarChart({ items, palette, title }) {
169
+ if (!items?.length) {
170
+ return html`<${EmptyState} title="No data yet"
171
+ description="Activity will appear here once tasks run." />`;
172
+ }
173
+ const max = items[0].count || 1;
174
+ return html`
175
+ <ul class="analytics-bar-list" aria-label=${title}>
176
+ ${items.map(({ name, count }, i) => html`
177
+ <li key=${name} class="analytics-bar-row">
178
+ <span class="analytics-bar-label" title=${name}>${name}</span>
179
+ <div class="analytics-bar-track">
180
+ <div class="analytics-bar-fill"
181
+ style="width:${Math.max(2, (count / max) * 100).toFixed(1)}%;background:${paletteColor(palette, i)}">
182
+ </div>
183
+ </div>
184
+ <span class="analytics-bar-count">${count}</span>
185
+ </li>
186
+ `)}
187
+ </ul>
188
+ `;
189
+ }
190
+
191
+ // ── Stat card ────────────────────────────────────────────────────────────────
192
+
193
+ function AnalyticsStat({ icon, label, value }) {
194
+ return html`
195
+ <div class="analytics-stat">
196
+ <div class="analytics-stat-icon">${icon}</div>
197
+ <div class="analytics-stat-body">
198
+ <div class="analytics-stat-label">${label}</div>
199
+ <div class="analytics-stat-value">${value}</div>
200
+ </div>
201
+ </div>
202
+ `;
203
+ }
204
+
205
+ // ── Constants ────────────────────────────────────────────────────────────────
206
+
207
+ const PERIODS = [
208
+ { days: 7, label: "7d" },
209
+ { days: 30, label: "30d" },
210
+ { days: 90, label: "90d" },
211
+ ];
212
+
213
+ const TREND_TABS = ["agents", "skills", "mcp"];
214
+
215
+ // ── Main exported component ──────────────────────────────────────────────────
216
+
41
217
  export function TelemetryTab() {
42
- const summary = telemetrySummary.value;
43
- const errors = telemetryErrors.value || [];
44
- const executors = telemetryExecutors.value || {};
45
- const alerts = telemetryAlerts.value || [];
218
+ const data = usageAnalytics.value;
219
+ const [period, setPeriod] = useState(30);
220
+ const [trendTab, setTrendTab] = useState("agents");
221
+
222
+ useEffect(() => {
223
+ loadUsageAnalytics(period).catch(() => {});
224
+ }, [period]);
46
225
 
47
- const hasSummary = summary && summary.total > 0;
226
+ const trend = data?.trend;
48
227
 
49
- const executorRows = useMemo(
50
- () => Object.entries(executors).sort((a, b) => b[1] - a[1]),
51
- [executors],
52
- );
228
+ const trendSeriesMap = useMemo(() => {
229
+ if (!trend) return null;
230
+ if (trendTab === "agents") return trend.agents || {};
231
+ if (trendTab === "skills") return trend.skills || {};
232
+ return trend.mcpTools || {};
233
+ }, [trend, trendTab]);
53
234
 
54
- const alertRows = useMemo(
55
- () => alerts.slice(-10).reverse(),
56
- [alerts],
57
- );
235
+ const trendPalette =
236
+ trendTab === "agents" ? AGENT_PALETTE
237
+ : trendTab === "skills" ? SKILL_PALETTE
238
+ : MCP_PALETTE;
239
+
240
+ const hasTrend = trend?.dates?.length > 0 &&
241
+ Object.keys(trendSeriesMap || {}).length > 0;
242
+
243
+ const sinceLabel = formatSinceDate(data?.sinceAt);
58
244
 
59
245
  return html`
60
- <section class="telemetry-tab">
61
- <div class="section-header">
62
- <h2>Telemetry</h2>
63
- <button
64
- class="btn btn-ghost btn-sm"
65
- onClick=${() => {
246
+ <section class="telemetry-tab analytics-tab">
247
+
248
+ <div class="section-header analytics-header">
249
+ <div class="analytics-title-row">
250
+ <h2>Usage Analytics</h2>
251
+ ${sinceLabel ? html`
252
+ <span class="analytics-since">Since ${sinceLabel}</span>
253
+ ` : null}
254
+ </div>
255
+ <div class="analytics-header-actions">
256
+ <div class="analytics-period-toggle">
257
+ ${PERIODS.map(({ days, label }) => html`
258
+ <button key=${days}
259
+ class="analytics-period-btn ${period === days ? "active" : ""}"
260
+ onClick=${() => setPeriod(days)}>
261
+ ${label}
262
+ </button>
263
+ `)}
264
+ </div>
265
+ <button class="btn btn-ghost btn-sm" onClick=${() => {
266
+ loadUsageAnalytics(period).catch(() => {});
66
267
  loadTelemetrySummary();
67
268
  loadTelemetryErrors();
68
269
  loadTelemetryExecutors();
69
270
  loadTelemetryAlerts();
70
271
  scheduleRefresh(4000);
71
- }}
72
- >
73
- Refresh
74
- </button>
272
+ }}>Refresh</button>
273
+ </div>
75
274
  </div>
76
275
 
77
- ${!hasSummary
78
- ? html`<${EmptyState}
79
- title="No telemetry yet"
80
- description="Telemetry appears here once agents start running."
81
- />`
82
- : html`<${Card} title="Summary" class="telemetry-summary">
83
- <div class="metric-grid">
84
- <div>
85
- <div class="metric-label">Sessions</div>
86
- <div class="metric-value">${formatCount(summary.total)}</div>
87
- </div>
88
- <div>
89
- <div class="metric-label">Success</div>
90
- <div class="metric-value">
91
- ${formatCount(summary.success)} (${summary.successRate}%)
92
- </div>
93
- </div>
94
- <div>
95
- <div class="metric-label">Avg Duration</div>
96
- <div class="metric-value">${formatSeconds(summary.avgDuration)}</div>
97
- </div>
98
- <div>
99
- <div class="metric-label">Errors</div>
100
- <div class="metric-value">${formatCount(summary.totalErrors)}</div>
101
- </div>
102
- </div>
103
- </${Card}>`}
104
-
105
- <div class="telemetry-grid">
106
- <${Card} title="Top Errors">
107
- ${errors.length === 0
108
- ? html`<${EmptyState}
109
- title="No errors logged"
110
- description="Errors appear here when failures are detected."
111
- />`
112
- : html`<ul class="telemetry-list">
113
- ${errors.slice(0, 8).map(
114
- (err) => html`<li>
115
- <span class="telemetry-label">${err.fingerprint}</span>
116
- <span class="telemetry-count">${err.count}</span>
117
- </li>`,
118
- )}
119
- </ul>`}
120
- </${Card}>
276
+ <!-- Summary stat cards -->
277
+ <div class="analytics-stats-row">
278
+ <${AnalyticsStat} icon="⚡" label="Agent Runs"
279
+ value=${data ? formatCount(data.agentRuns) : "–"} />
280
+ <${AnalyticsStat} icon="✦" label="Skill Invocations"
281
+ value=${data ? formatCount(data.skillInvocations) : ""} />
282
+ <${AnalyticsStat} icon="" label="MCP Tools"
283
+ value=${data ? formatCount(data.mcpToolCalls) : "–"} />
284
+ <${AnalyticsStat} icon="≈" label="Avg / Day"
285
+ value=${data ? formatCount(data.avgPerDay) : "–"} />
286
+ <${AnalyticsStat} icon="🕐" label="Last Active"
287
+ value=${data?.lastActiveAt ? formatRelative(data.lastActiveAt) : "–"} />
288
+ </div>
289
+
290
+ <!-- Activity trend chart -->
291
+ <${Card} title="Activity Trend" class="analytics-trend-card">
292
+ <div class="analytics-trend-header">
293
+ <div class="analytics-trend-tabs">
294
+ ${TREND_TABS.map((tab) => html`
295
+ <button key=${tab}
296
+ class="analytics-trend-tab ${trendTab === tab ? "active" : ""}"
297
+ onClick=${() => setTrendTab(tab)}>
298
+ ${tab === "agents" ? "Agents" : tab === "skills" ? "Skills" : "MCP Tools"}
299
+ </button>
300
+ `)}
301
+ </div>
302
+ </div>
303
+
304
+ ${hasTrend ? html`
305
+ <div class="analytics-legend">
306
+ <${ChartLegend}
307
+ label=${trendTab === "agents" ? "AGENTS" : trendTab === "skills" ? "SKILLS" : "MCP TOOLS"}
308
+ seriesMap=${trendSeriesMap}
309
+ palette=${trendPalette}
310
+ />
311
+ </div>
312
+ <${TrendLines} dates=${trend.dates} seriesMap=${trendSeriesMap} palette=${trendPalette} />
313
+ ` : html`
314
+ <${EmptyState} title="No activity data"
315
+ description="Agent runs will appear here once they start." />
316
+ `}
317
+ </${Card}>
121
318
 
122
- <${Card} title="Executors">
123
- ${executorRows.length === 0
124
- ? html`<${EmptyState}
125
- title="No executor data"
126
- description="Run tasks to populate executor usage."
127
- />`
128
- : html`<ul class="telemetry-list">
129
- ${executorRows.map(
130
- ([name, count]) => html`<li>
131
- <span class="telemetry-label">${name}</span>
132
- <span class="telemetry-count">${count}</span>
133
- </li>`,
134
- )}
135
- </ul>`}
319
+ <!-- Top-N bar charts row -->
320
+ <div class="analytics-top-grid">
321
+ <${Card} title="Top Agents">
322
+ <${TopBarChart} items=${data?.topAgents || []}
323
+ palette=${AGENT_PALETTE} title="Top Agents" />
324
+ </${Card}>
325
+ <${Card} title="Top Skills">
326
+ <${TopBarChart} items=${data?.topSkills || []}
327
+ palette=${SKILL_PALETTE} title="Top Skills" />
328
+ </${Card}>
329
+ <${Card} title="Top MCP Tools">
330
+ <${TopBarChart} items=${data?.topMcpTools || []}
331
+ palette=${MCP_PALETTE} title="Top MCP Tools" />
136
332
  </${Card}>
137
333
  </div>
138
334
 
335
+ <!-- Errors + alerts (preserved from classic telemetry) -->
336
+ <${ClassicTelemetry} />
337
+ </section>
338
+ `;
339
+ }
340
+
341
+ // ── Classic error/alert section ──────────────────────────────────────────────
342
+
343
+ function ClassicTelemetry() {
344
+ const errors = telemetryErrors.value || [];
345
+ const alerts = telemetryAlerts.value || [];
346
+ const alertRows = useMemo(() => alerts.slice(-10).reverse(), [alerts]);
347
+ if (!errors.length && !alerts.length) return null;
348
+
349
+ return html`
350
+ <div class="telemetry-grid">
351
+ <${Card} title="Top Errors">
352
+ ${errors.length === 0
353
+ ? html`<${EmptyState} title="No errors logged"
354
+ description="Errors appear here when failures are detected." />`
355
+ : html`<ul class="telemetry-list">
356
+ ${errors.slice(0, 8).map((err) => html`
357
+ <li key=${err.fingerprint}>
358
+ <span class="telemetry-label">${err.fingerprint}</span>
359
+ <span class="telemetry-count">${err.count}</span>
360
+ </li>`)}
361
+ </ul>`}
362
+ </${Card}>
363
+
139
364
  <${Card} title="Recent Alerts">
140
365
  ${alertRows.length === 0
141
- ? html`<${EmptyState}
142
- title="No alerts"
143
- description="Analyzer alerts will show up here."
144
- />`
366
+ ? html`<${EmptyState} title="No alerts"
367
+ description="Analyzer alerts will show up here." />`
145
368
  : html`<ul class="telemetry-alerts">
146
- ${alertRows.map(
147
- (alert) => html`<li>
369
+ ${alertRows.map((alert) => html`
370
+ <li key=${(alert.attempt_id || "") + (alert.type || "")}>
148
371
  <div>
149
372
  <div class="telemetry-alert-title">
150
373
  ${alert.type || "alert"}
151
- <${Badge} tone=${severityBadge(alert.severity)}>${
152
- String(alert.severity || "medium").toUpperCase()
153
- }</${Badge}>
374
+ <${Badge} tone=${severityBadge(alert.severity)}>
375
+ ${String(alert.severity || "medium").toUpperCase()}
376
+ </${Badge}>
154
377
  </div>
155
378
  <div class="telemetry-alert-meta">
156
379
  ${alert.attempt_id || "unknown"}
157
380
  ${alert.executor ? html` · ${alert.executor}` : ""}
158
381
  </div>
159
382
  </div>
160
- </li>`,
161
- )}
383
+ </li>`)}
162
384
  </ul>`}
163
385
  </${Card}>
164
- </section>
386
+ </div>
165
387
  `;
166
388
  }