bosun 0.37.1 → 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.
@@ -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
  }
@@ -1116,11 +1116,9 @@ function WorkflowAgentLibraryPicker({ config, onUpdate }) {
1116
1116
  if (agents.length > 0) { setExpanded(e => !e); return; }
1117
1117
  setLoading(true);
1118
1118
  try {
1119
- const res = await apiFetch("/api/library?type=agent");
1120
- if (res.ok) {
1121
- const data = await res.json();
1122
- setAgents(data.entries || data || []);
1123
- }
1119
+ const res = await apiFetch("/api/library?type=agent&agentType=task");
1120
+ const data = Array.isArray(res?.data) ? res.data : [];
1121
+ setAgents(data);
1124
1122
  } catch { /* ignore */ }
1125
1123
  setLoading(false);
1126
1124
  setExpanded(true);
@@ -2321,6 +2319,25 @@ export function WorkflowsTab() {
2321
2319
  loadNodeTypes();
2322
2320
  }, []);
2323
2321
 
2322
+ useEffect(() => {
2323
+ const onWorkspaceSwitched = () => {
2324
+ activeWorkflow.value = null;
2325
+ selectedRunId.value = null;
2326
+ selectedRunDetail.value = null;
2327
+ workflowRuns.value = [];
2328
+ workflowRunsLimit.value = WORKFLOW_RUN_PAGE_SIZE;
2329
+ viewMode.value = "list";
2330
+ setRouteParams({}, { replace: true, skipGuard: true });
2331
+ loadWorkflows();
2332
+ loadTemplates();
2333
+ loadNodeTypes();
2334
+ };
2335
+ window.addEventListener("ve:workspace-switched", onWorkspaceSwitched);
2336
+ return () => {
2337
+ window.removeEventListener("ve:workspace-switched", onWorkspaceSwitched);
2338
+ };
2339
+ }, []);
2340
+
2324
2341
  useEffect(() => {
2325
2342
  const route = routeParams.value || {};
2326
2343
  const workflowId = String(route.workflowId || "").trim();