apteva 0.2.5 → 0.2.7
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.
- package/dist/App.3kb50qa3.js +213 -0
- package/dist/index.html +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/db.ts +9 -0
- package/src/routes/api.ts +57 -3
- package/src/server.ts +58 -0
- package/src/web/App.tsx +8 -1
- package/src/web/components/agents/AgentCard.tsx +12 -2
- package/src/web/components/dashboard/Dashboard.tsx +27 -15
- package/src/web/components/layout/Header.tsx +20 -7
- package/src/web/components/settings/SettingsPage.tsx +88 -1
- package/src/web/components/telemetry/TelemetryPage.tsx +191 -60
- package/src/web/context/TelemetryContext.tsx +224 -0
- package/src/web/context/index.ts +2 -0
- package/dist/App.ggy88vnx.js +0 -213
|
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
|
|
|
2
2
|
import { CheckIcon } from "../common/Icons";
|
|
3
3
|
import type { Provider } from "../../types";
|
|
4
4
|
|
|
5
|
-
type SettingsTab = "providers" | "updates";
|
|
5
|
+
type SettingsTab = "providers" | "updates" | "data";
|
|
6
6
|
|
|
7
7
|
export function SettingsPage() {
|
|
8
8
|
const [activeTab, setActiveTab] = useState<SettingsTab>("providers");
|
|
@@ -23,6 +23,11 @@ export function SettingsPage() {
|
|
|
23
23
|
active={activeTab === "updates"}
|
|
24
24
|
onClick={() => setActiveTab("updates")}
|
|
25
25
|
/>
|
|
26
|
+
<SettingsNavItem
|
|
27
|
+
label="Data"
|
|
28
|
+
active={activeTab === "data"}
|
|
29
|
+
onClick={() => setActiveTab("data")}
|
|
30
|
+
/>
|
|
26
31
|
</nav>
|
|
27
32
|
</div>
|
|
28
33
|
|
|
@@ -30,6 +35,7 @@ export function SettingsPage() {
|
|
|
30
35
|
<div className="flex-1 overflow-auto p-6">
|
|
31
36
|
{activeTab === "providers" && <ProvidersSettings />}
|
|
32
37
|
{activeTab === "updates" && <UpdatesSettings />}
|
|
38
|
+
{activeTab === "data" && <DataSettings />}
|
|
33
39
|
</div>
|
|
34
40
|
</div>
|
|
35
41
|
);
|
|
@@ -628,3 +634,84 @@ function IntegrationKeyCard({
|
|
|
628
634
|
</div>
|
|
629
635
|
);
|
|
630
636
|
}
|
|
637
|
+
|
|
638
|
+
function DataSettings() {
|
|
639
|
+
const [clearing, setClearing] = useState(false);
|
|
640
|
+
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
|
641
|
+
const [eventCount, setEventCount] = useState<number | null>(null);
|
|
642
|
+
|
|
643
|
+
const fetchStats = async () => {
|
|
644
|
+
try {
|
|
645
|
+
const res = await fetch("/api/telemetry/stats");
|
|
646
|
+
const data = await res.json();
|
|
647
|
+
setEventCount(data.stats?.total_events || 0);
|
|
648
|
+
} catch {
|
|
649
|
+
setEventCount(null);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
fetchStats();
|
|
655
|
+
}, []);
|
|
656
|
+
|
|
657
|
+
const clearTelemetry = async () => {
|
|
658
|
+
if (!confirm("Are you sure you want to delete all telemetry data? This cannot be undone.")) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
setClearing(true);
|
|
663
|
+
setMessage(null);
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
const res = await fetch("/api/telemetry/clear", { method: "POST" });
|
|
667
|
+
const data = await res.json();
|
|
668
|
+
|
|
669
|
+
if (res.ok) {
|
|
670
|
+
setMessage({ type: "success", text: `Cleared ${data.deleted || 0} telemetry events.` });
|
|
671
|
+
setEventCount(0);
|
|
672
|
+
} else {
|
|
673
|
+
setMessage({ type: "error", text: data.error || "Failed to clear telemetry" });
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
setMessage({ type: "error", text: "Failed to clear telemetry" });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
setClearing(false);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<div className="max-w-2xl">
|
|
684
|
+
<div className="mb-6">
|
|
685
|
+
<h1 className="text-2xl font-semibold mb-1">Data Management</h1>
|
|
686
|
+
<p className="text-[#666]">Manage stored data and telemetry.</p>
|
|
687
|
+
</div>
|
|
688
|
+
|
|
689
|
+
<div className="bg-[#111] border border-[#1a1a1a] rounded-lg p-4">
|
|
690
|
+
<h3 className="font-medium mb-2">Telemetry Data</h3>
|
|
691
|
+
<p className="text-sm text-[#666] mb-4">
|
|
692
|
+
{eventCount !== null
|
|
693
|
+
? `${eventCount.toLocaleString()} events stored`
|
|
694
|
+
: "Loading..."}
|
|
695
|
+
</p>
|
|
696
|
+
|
|
697
|
+
{message && (
|
|
698
|
+
<div className={`mb-4 p-3 rounded text-sm ${
|
|
699
|
+
message.type === "success"
|
|
700
|
+
? "bg-green-500/10 text-green-400 border border-green-500/30"
|
|
701
|
+
: "bg-red-500/10 text-red-400 border border-red-500/30"
|
|
702
|
+
}`}>
|
|
703
|
+
{message.text}
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
|
|
707
|
+
<button
|
|
708
|
+
onClick={clearTelemetry}
|
|
709
|
+
disabled={clearing || eventCount === 0}
|
|
710
|
+
className="px-4 py-2 bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed rounded text-sm font-medium transition"
|
|
711
|
+
>
|
|
712
|
+
{clearing ? "Clearing..." : "Clear All Telemetry"}
|
|
713
|
+
</button>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { Select } from "../common/Select";
|
|
3
|
+
import { useTelemetryContext, type TelemetryEvent } from "../../context";
|
|
3
4
|
|
|
4
5
|
interface TelemetryStats {
|
|
5
6
|
total_events: number;
|
|
@@ -10,20 +11,6 @@ interface TelemetryStats {
|
|
|
10
11
|
total_output_tokens: number;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
interface TelemetryEvent {
|
|
14
|
-
id: string;
|
|
15
|
-
agent_id: string;
|
|
16
|
-
timestamp: string;
|
|
17
|
-
category: string;
|
|
18
|
-
type: string;
|
|
19
|
-
level: string;
|
|
20
|
-
trace_id: string | null;
|
|
21
|
-
thread_id: string | null;
|
|
22
|
-
data: Record<string, unknown> | null;
|
|
23
|
-
duration_ms: number | null;
|
|
24
|
-
error: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
14
|
interface UsageByAgent {
|
|
28
15
|
agent_id: string;
|
|
29
16
|
input_tokens: number;
|
|
@@ -33,10 +20,34 @@ interface UsageByAgent {
|
|
|
33
20
|
errors: number;
|
|
34
21
|
}
|
|
35
22
|
|
|
23
|
+
// Helper to extract stats from a single event
|
|
24
|
+
function extractEventStats(event: TelemetryEvent): {
|
|
25
|
+
llm_calls: number;
|
|
26
|
+
tool_calls: number;
|
|
27
|
+
errors: number;
|
|
28
|
+
input_tokens: number;
|
|
29
|
+
output_tokens: number;
|
|
30
|
+
} {
|
|
31
|
+
const isLlm = event.category === "LLM";
|
|
32
|
+
const isTool = event.category === "TOOL";
|
|
33
|
+
const isError = event.level === "error";
|
|
34
|
+
const inputTokens = (event.data?.input_tokens as number) || 0;
|
|
35
|
+
const outputTokens = (event.data?.output_tokens as number) || 0;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
llm_calls: isLlm ? 1 : 0,
|
|
39
|
+
tool_calls: isTool ? 1 : 0,
|
|
40
|
+
errors: isError ? 1 : 0,
|
|
41
|
+
input_tokens: inputTokens,
|
|
42
|
+
output_tokens: outputTokens,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
36
46
|
export function TelemetryPage() {
|
|
37
|
-
const
|
|
38
|
-
const [
|
|
39
|
-
const [
|
|
47
|
+
const { events: realtimeEvents } = useTelemetryContext();
|
|
48
|
+
const [fetchedStats, setFetchedStats] = useState<TelemetryStats | null>(null);
|
|
49
|
+
const [historicalEvents, setHistoricalEvents] = useState<TelemetryEvent[]>([]);
|
|
50
|
+
const [fetchedUsage, setFetchedUsage] = useState<UsageByAgent[]>([]);
|
|
40
51
|
const [loading, setLoading] = useState(true);
|
|
41
52
|
const [filter, setFilter] = useState({
|
|
42
53
|
category: "",
|
|
@@ -46,6 +57,9 @@ export function TelemetryPage() {
|
|
|
46
57
|
const [agents, setAgents] = useState<Array<{ id: string; name: string }>>([]);
|
|
47
58
|
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
|
|
48
59
|
|
|
60
|
+
// Track IDs that were in the fetched stats to avoid double-counting
|
|
61
|
+
const countedEventIdsRef = useRef<Set<string>>(new Set());
|
|
62
|
+
|
|
49
63
|
// Fetch agents for dropdown
|
|
50
64
|
useEffect(() => {
|
|
51
65
|
const fetchAgents = async () => {
|
|
@@ -60,16 +74,16 @@ export function TelemetryPage() {
|
|
|
60
74
|
fetchAgents();
|
|
61
75
|
}, []);
|
|
62
76
|
|
|
63
|
-
// Fetch
|
|
77
|
+
// Fetch stats and historical data (less frequently now since we have real-time)
|
|
64
78
|
const fetchData = async () => {
|
|
65
79
|
setLoading(true);
|
|
66
80
|
try {
|
|
67
81
|
// Fetch stats
|
|
68
82
|
const statsRes = await fetch("/api/telemetry/stats");
|
|
69
83
|
const statsData = await statsRes.json();
|
|
70
|
-
|
|
84
|
+
setFetchedStats(statsData.stats);
|
|
71
85
|
|
|
72
|
-
// Fetch events with filters
|
|
86
|
+
// Fetch historical events with filters
|
|
73
87
|
const params = new URLSearchParams();
|
|
74
88
|
if (filter.category) params.set("category", filter.category);
|
|
75
89
|
if (filter.level) params.set("level", filter.level);
|
|
@@ -78,12 +92,16 @@ export function TelemetryPage() {
|
|
|
78
92
|
|
|
79
93
|
const eventsRes = await fetch(`/api/telemetry/events?${params}`);
|
|
80
94
|
const eventsData = await eventsRes.json();
|
|
81
|
-
|
|
95
|
+
const events = eventsData.events || [];
|
|
96
|
+
setHistoricalEvents(events);
|
|
97
|
+
|
|
98
|
+
// Mark all fetched event IDs as counted (stats already include them)
|
|
99
|
+
countedEventIdsRef.current = new Set(events.map((e: TelemetryEvent) => e.id));
|
|
82
100
|
|
|
83
101
|
// Fetch usage by agent
|
|
84
102
|
const usageRes = await fetch("/api/telemetry/usage?group_by=agent");
|
|
85
103
|
const usageData = await usageRes.json();
|
|
86
|
-
|
|
104
|
+
setFetchedUsage(usageData.usage || []);
|
|
87
105
|
} catch (e) {
|
|
88
106
|
console.error("Failed to fetch telemetry:", e);
|
|
89
107
|
}
|
|
@@ -92,11 +110,110 @@ export function TelemetryPage() {
|
|
|
92
110
|
|
|
93
111
|
useEffect(() => {
|
|
94
112
|
fetchData();
|
|
95
|
-
//
|
|
96
|
-
const interval = setInterval(fetchData,
|
|
113
|
+
// Refresh stats every 60 seconds (events come in real-time)
|
|
114
|
+
const interval = setInterval(fetchData, 60000);
|
|
97
115
|
return () => clearInterval(interval);
|
|
98
116
|
}, [filter]);
|
|
99
117
|
|
|
118
|
+
// Compute real-time stats from new events (not already counted in fetched stats)
|
|
119
|
+
const stats = useMemo(() => {
|
|
120
|
+
if (!fetchedStats) return null;
|
|
121
|
+
|
|
122
|
+
// Calculate deltas from real-time events not in fetched data
|
|
123
|
+
let deltaEvents = 0;
|
|
124
|
+
let deltaLlmCalls = 0;
|
|
125
|
+
let deltaToolCalls = 0;
|
|
126
|
+
let deltaErrors = 0;
|
|
127
|
+
let deltaInputTokens = 0;
|
|
128
|
+
let deltaOutputTokens = 0;
|
|
129
|
+
|
|
130
|
+
for (const event of realtimeEvents) {
|
|
131
|
+
if (!countedEventIdsRef.current.has(event.id)) {
|
|
132
|
+
deltaEvents++;
|
|
133
|
+
const eventStats = extractEventStats(event);
|
|
134
|
+
deltaLlmCalls += eventStats.llm_calls;
|
|
135
|
+
deltaToolCalls += eventStats.tool_calls;
|
|
136
|
+
deltaErrors += eventStats.errors;
|
|
137
|
+
deltaInputTokens += eventStats.input_tokens;
|
|
138
|
+
deltaOutputTokens += eventStats.output_tokens;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
total_events: fetchedStats.total_events + deltaEvents,
|
|
144
|
+
total_llm_calls: fetchedStats.total_llm_calls + deltaLlmCalls,
|
|
145
|
+
total_tool_calls: fetchedStats.total_tool_calls + deltaToolCalls,
|
|
146
|
+
total_errors: fetchedStats.total_errors + deltaErrors,
|
|
147
|
+
total_input_tokens: fetchedStats.total_input_tokens + deltaInputTokens,
|
|
148
|
+
total_output_tokens: fetchedStats.total_output_tokens + deltaOutputTokens,
|
|
149
|
+
};
|
|
150
|
+
}, [fetchedStats, realtimeEvents]);
|
|
151
|
+
|
|
152
|
+
// Compute real-time usage by agent
|
|
153
|
+
const usage = useMemo(() => {
|
|
154
|
+
// Start with a copy of fetched usage as a map
|
|
155
|
+
const usageMap = new Map<string, UsageByAgent>();
|
|
156
|
+
for (const u of fetchedUsage) {
|
|
157
|
+
usageMap.set(u.agent_id, { ...u });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Add deltas from real-time events
|
|
161
|
+
for (const event of realtimeEvents) {
|
|
162
|
+
if (!countedEventIdsRef.current.has(event.id)) {
|
|
163
|
+
const eventStats = extractEventStats(event);
|
|
164
|
+
const existing = usageMap.get(event.agent_id);
|
|
165
|
+
if (existing) {
|
|
166
|
+
existing.llm_calls += eventStats.llm_calls;
|
|
167
|
+
existing.tool_calls += eventStats.tool_calls;
|
|
168
|
+
existing.errors += eventStats.errors;
|
|
169
|
+
existing.input_tokens += eventStats.input_tokens;
|
|
170
|
+
existing.output_tokens += eventStats.output_tokens;
|
|
171
|
+
} else {
|
|
172
|
+
usageMap.set(event.agent_id, {
|
|
173
|
+
agent_id: event.agent_id,
|
|
174
|
+
llm_calls: eventStats.llm_calls,
|
|
175
|
+
tool_calls: eventStats.tool_calls,
|
|
176
|
+
errors: eventStats.errors,
|
|
177
|
+
input_tokens: eventStats.input_tokens,
|
|
178
|
+
output_tokens: eventStats.output_tokens,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return Array.from(usageMap.values());
|
|
185
|
+
}, [fetchedUsage, realtimeEvents]);
|
|
186
|
+
|
|
187
|
+
// Merge real-time events with historical, filtering and deduping
|
|
188
|
+
const allEvents = React.useMemo(() => {
|
|
189
|
+
// Apply filters to real-time events
|
|
190
|
+
let filtered = realtimeEvents;
|
|
191
|
+
if (filter.agent_id) {
|
|
192
|
+
filtered = filtered.filter(e => e.agent_id === filter.agent_id);
|
|
193
|
+
}
|
|
194
|
+
if (filter.category) {
|
|
195
|
+
filtered = filtered.filter(e => e.category === filter.category);
|
|
196
|
+
}
|
|
197
|
+
if (filter.level) {
|
|
198
|
+
filtered = filtered.filter(e => e.level === filter.level);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Merge with historical, dedupe by ID
|
|
202
|
+
const seen = new Set(filtered.map(e => e.id));
|
|
203
|
+
const merged = [...filtered];
|
|
204
|
+
for (const evt of historicalEvents) {
|
|
205
|
+
if (!seen.has(evt.id)) {
|
|
206
|
+
merged.push(evt);
|
|
207
|
+
seen.add(evt.id);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Sort by timestamp descending
|
|
212
|
+
merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
213
|
+
|
|
214
|
+
return merged.slice(0, 100);
|
|
215
|
+
}, [realtimeEvents, historicalEvents, filter]);
|
|
216
|
+
|
|
100
217
|
const getAgentName = (agentId: string) => {
|
|
101
218
|
const agent = agents.find(a => a.id === agentId);
|
|
102
219
|
return agent?.name || agentId;
|
|
@@ -249,53 +366,67 @@ export function TelemetryPage() {
|
|
|
249
366
|
|
|
250
367
|
{/* Events List */}
|
|
251
368
|
<div className="bg-[#111] border border-[#1a1a1a] rounded-lg">
|
|
252
|
-
<div className="p-3 border-b border-[#1a1a1a]">
|
|
369
|
+
<div className="p-3 border-b border-[#1a1a1a] flex items-center justify-between">
|
|
253
370
|
<h2 className="font-medium">Recent Events</h2>
|
|
371
|
+
{realtimeEvents.length > 0 && (
|
|
372
|
+
<span className="text-xs text-[#666]">
|
|
373
|
+
{realtimeEvents.length} new
|
|
374
|
+
</span>
|
|
375
|
+
)}
|
|
254
376
|
</div>
|
|
255
377
|
|
|
256
|
-
{loading &&
|
|
378
|
+
{loading && allEvents.length === 0 ? (
|
|
257
379
|
<div className="p-8 text-center text-[#666]">Loading...</div>
|
|
258
|
-
) :
|
|
380
|
+
) : allEvents.length === 0 ? (
|
|
259
381
|
<div className="p-8 text-center text-[#666]">
|
|
260
|
-
No telemetry events yet. Events will appear here once agents start sending data.
|
|
382
|
+
No telemetry events yet. Events will appear here in real-time once agents start sending data.
|
|
261
383
|
</div>
|
|
262
384
|
) : (
|
|
263
385
|
<div className="divide-y divide-[#1a1a1a]">
|
|
264
|
-
{
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<div
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
<span className="
|
|
386
|
+
{allEvents.map((event) => {
|
|
387
|
+
// Only mark as new if event arrived in last 10 seconds
|
|
388
|
+
const eventTime = new Date(event.timestamp).getTime();
|
|
389
|
+
const isNew = Date.now() - eventTime < 10000;
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<div
|
|
393
|
+
key={event.id}
|
|
394
|
+
className="p-3 hover:bg-[#0a0a0a] transition cursor-pointer"
|
|
395
|
+
onClick={() => setExpandedEvent(expandedEvent === event.id ? null : event.id)}
|
|
396
|
+
>
|
|
397
|
+
<div className="flex items-start gap-3">
|
|
398
|
+
<span className={`px-2 py-0.5 rounded text-xs border ${categoryColors[event.category] || "bg-[#222] text-[#888] border-[#333]"}`}>
|
|
399
|
+
{event.category}
|
|
400
|
+
</span>
|
|
401
|
+
<div className="flex-1 min-w-0">
|
|
402
|
+
<div className="flex items-center gap-2">
|
|
403
|
+
<span className="font-medium text-sm">{event.type}</span>
|
|
404
|
+
<span className={`text-xs ${levelColors[event.level] || "text-[#666]"}`}>
|
|
405
|
+
{event.level}
|
|
406
|
+
</span>
|
|
407
|
+
{event.duration_ms && (
|
|
408
|
+
<span className="text-xs text-[#555]">{event.duration_ms}ms</span>
|
|
409
|
+
)}
|
|
410
|
+
{isNew && (
|
|
411
|
+
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
<div className="text-xs text-[#555] mt-1">
|
|
415
|
+
{getAgentName(event.agent_id)} · {new Date(event.timestamp).toLocaleString()}
|
|
416
|
+
</div>
|
|
417
|
+
{event.error && (
|
|
418
|
+
<div className="text-xs text-red-400 mt-1 font-mono">{event.error}</div>
|
|
419
|
+
)}
|
|
420
|
+
{expandedEvent === event.id && event.data && Object.keys(event.data).length > 0 && (
|
|
421
|
+
<pre className="text-xs text-[#666] mt-2 p-2 bg-[#0a0a0a] rounded overflow-x-auto">
|
|
422
|
+
{JSON.stringify(event.data, null, 2)}
|
|
423
|
+
</pre>
|
|
282
424
|
)}
|
|
283
425
|
</div>
|
|
284
|
-
<div className="text-xs text-[#555] mt-1">
|
|
285
|
-
{getAgentName(event.agent_id)} · {new Date(event.timestamp).toLocaleString()}
|
|
286
|
-
</div>
|
|
287
|
-
{event.error && (
|
|
288
|
-
<div className="text-xs text-red-400 mt-1 font-mono">{event.error}</div>
|
|
289
|
-
)}
|
|
290
|
-
{expandedEvent === event.id && event.data && Object.keys(event.data).length > 0 && (
|
|
291
|
-
<pre className="text-xs text-[#666] mt-2 p-2 bg-[#0a0a0a] rounded overflow-x-auto">
|
|
292
|
-
{JSON.stringify(event.data, null, 2)}
|
|
293
|
-
</pre>
|
|
294
|
-
)}
|
|
295
426
|
</div>
|
|
296
427
|
</div>
|
|
297
|
-
|
|
298
|
-
)
|
|
428
|
+
);
|
|
429
|
+
})}
|
|
299
430
|
</div>
|
|
300
431
|
)}
|
|
301
432
|
</div>
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
export interface TelemetryEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
agent_id: string;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
category: string;
|
|
8
|
+
type: string;
|
|
9
|
+
level: string;
|
|
10
|
+
trace_id?: string;
|
|
11
|
+
thread_id?: string;
|
|
12
|
+
data?: Record<string, unknown>;
|
|
13
|
+
duration_ms?: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TelemetryContextValue {
|
|
18
|
+
connected: boolean;
|
|
19
|
+
events: TelemetryEvent[];
|
|
20
|
+
lastActivityByAgent: Record<string, { timestamp: string; category: string; type: string }>;
|
|
21
|
+
activeAgents: Record<string, { type: string; expiresAt: number }>;
|
|
22
|
+
clearEvents: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TelemetryContext = createContext<TelemetryContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
const MAX_EVENTS = 200; // Keep last 200 events in memory
|
|
28
|
+
|
|
29
|
+
export function TelemetryProvider({ children }: { children: React.ReactNode }) {
|
|
30
|
+
const [connected, setConnected] = useState(false);
|
|
31
|
+
const [events, setEvents] = useState<TelemetryEvent[]>([]);
|
|
32
|
+
const [lastActivityByAgent, setLastActivityByAgent] = useState<Record<string, { timestamp: string; category: string; type: string }>>({});
|
|
33
|
+
const [activeAgents, setActiveAgents] = useState<Record<string, { type: string; expiresAt: number }>>({});
|
|
34
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
35
|
+
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
|
+
|
|
37
|
+
// Clean up expired active states
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const interval = setInterval(() => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
setActiveAgents(prev => {
|
|
42
|
+
const updated: Record<string, { type: string; expiresAt: number }> = {};
|
|
43
|
+
for (const [agentId, state] of Object.entries(prev)) {
|
|
44
|
+
if (state.expiresAt > now) {
|
|
45
|
+
updated[agentId] = state;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return updated;
|
|
49
|
+
});
|
|
50
|
+
}, 500);
|
|
51
|
+
return () => clearInterval(interval);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const connect = useCallback(() => {
|
|
55
|
+
if (eventSourceRef.current) {
|
|
56
|
+
eventSourceRef.current.close();
|
|
57
|
+
eventSourceRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const es = new EventSource("/api/telemetry/stream");
|
|
62
|
+
eventSourceRef.current = es;
|
|
63
|
+
|
|
64
|
+
es.onopen = () => {
|
|
65
|
+
setConnected(true);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
es.onmessage = (event) => {
|
|
69
|
+
// Ignore keepalive pings (comments starting with :)
|
|
70
|
+
if (!event.data || event.data.trim() === "") return;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const data = JSON.parse(event.data);
|
|
74
|
+
|
|
75
|
+
// Handle connection message
|
|
76
|
+
if (data.connected) {
|
|
77
|
+
setConnected(true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle array of events
|
|
82
|
+
if (Array.isArray(data)) {
|
|
83
|
+
setEvents(prev => {
|
|
84
|
+
const combined = [...data, ...prev];
|
|
85
|
+
return combined.slice(0, MAX_EVENTS);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Update last activity per agent
|
|
89
|
+
setLastActivityByAgent(prev => {
|
|
90
|
+
const updated = { ...prev };
|
|
91
|
+
for (const evt of data) {
|
|
92
|
+
const existing = updated[evt.agent_id];
|
|
93
|
+
if (!existing || new Date(evt.timestamp) > new Date(existing.timestamp)) {
|
|
94
|
+
updated[evt.agent_id] = {
|
|
95
|
+
timestamp: evt.timestamp,
|
|
96
|
+
category: evt.category,
|
|
97
|
+
type: evt.type,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return updated;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Set agents as active for 3 seconds (tracked in context, not component)
|
|
105
|
+
setActiveAgents(prev => {
|
|
106
|
+
const updated = { ...prev };
|
|
107
|
+
const expiresAt = Date.now() + 3000;
|
|
108
|
+
for (const evt of data) {
|
|
109
|
+
updated[evt.agent_id] = { type: evt.type, expiresAt };
|
|
110
|
+
}
|
|
111
|
+
return updated;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Ignore parse errors (likely keepalive or empty message)
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
es.onerror = () => {
|
|
120
|
+
setConnected(false);
|
|
121
|
+
es.close();
|
|
122
|
+
eventSourceRef.current = null;
|
|
123
|
+
|
|
124
|
+
// Reconnect after 2 seconds
|
|
125
|
+
if (reconnectTimeoutRef.current) {
|
|
126
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
127
|
+
}
|
|
128
|
+
reconnectTimeoutRef.current = setTimeout(connect, 2000);
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
// Failed to create EventSource, retry
|
|
132
|
+
setConnected(false);
|
|
133
|
+
if (reconnectTimeoutRef.current) {
|
|
134
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
135
|
+
}
|
|
136
|
+
reconnectTimeoutRef.current = setTimeout(connect, 2000);
|
|
137
|
+
}
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
connect();
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
if (eventSourceRef.current) {
|
|
145
|
+
eventSourceRef.current.close();
|
|
146
|
+
}
|
|
147
|
+
if (reconnectTimeoutRef.current) {
|
|
148
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}, [connect]);
|
|
152
|
+
|
|
153
|
+
const clearEvents = useCallback(() => {
|
|
154
|
+
setEvents([]);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<TelemetryContext.Provider value={{ connected, events, lastActivityByAgent, activeAgents, clearEvents }}>
|
|
159
|
+
{children}
|
|
160
|
+
</TelemetryContext.Provider>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Hook to access all telemetry
|
|
165
|
+
export function useTelemetryContext() {
|
|
166
|
+
const context = useContext(TelemetryContext);
|
|
167
|
+
if (!context) {
|
|
168
|
+
throw new Error("useTelemetryContext must be used within TelemetryProvider");
|
|
169
|
+
}
|
|
170
|
+
return context;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Hook to filter telemetry for a specific agent or category
|
|
174
|
+
export function useTelemetry(filter?: {
|
|
175
|
+
agent_id?: string;
|
|
176
|
+
category?: string;
|
|
177
|
+
limit?: number;
|
|
178
|
+
}) {
|
|
179
|
+
const { connected, events, lastActivityByAgent } = useTelemetryContext();
|
|
180
|
+
|
|
181
|
+
const filteredEvents = React.useMemo(() => {
|
|
182
|
+
let result = events;
|
|
183
|
+
|
|
184
|
+
if (filter?.agent_id) {
|
|
185
|
+
result = result.filter(e => e.agent_id === filter.agent_id);
|
|
186
|
+
}
|
|
187
|
+
if (filter?.category) {
|
|
188
|
+
result = result.filter(e => e.category === filter.category);
|
|
189
|
+
}
|
|
190
|
+
if (filter?.limit) {
|
|
191
|
+
result = result.slice(0, filter.limit);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
}, [events, filter?.agent_id, filter?.category, filter?.limit]);
|
|
196
|
+
|
|
197
|
+
const lastActivity = filter?.agent_id ? lastActivityByAgent[filter.agent_id] : undefined;
|
|
198
|
+
|
|
199
|
+
// Check if agent is "active" (had activity in last 10 seconds)
|
|
200
|
+
const isActive = React.useMemo(() => {
|
|
201
|
+
if (!lastActivity) return false;
|
|
202
|
+
const activityTime = new Date(lastActivity.timestamp).getTime();
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
return now - activityTime < 10000; // 10 seconds
|
|
205
|
+
}, [lastActivity]);
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
connected,
|
|
209
|
+
events: filteredEvents,
|
|
210
|
+
lastActivity,
|
|
211
|
+
isActive,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Hook for agent activity indicator - uses context-level tracking
|
|
216
|
+
export function useAgentActivity(agentId: string) {
|
|
217
|
+
const { activeAgents } = useTelemetryContext();
|
|
218
|
+
const activity = activeAgents[agentId];
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
isActive: !!activity,
|
|
222
|
+
type: activity?.type,
|
|
223
|
+
};
|
|
224
|
+
}
|