apteva 0.2.7 → 0.2.9
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.m4hg4bxq.js +218 -0
- package/dist/index.html +4 -2
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/auth/index.ts +386 -0
- package/src/auth/middleware.ts +183 -0
- package/src/binary.ts +19 -1
- package/src/db.ts +688 -45
- package/src/integrations/composio.ts +437 -0
- package/src/integrations/index.ts +80 -0
- package/src/openapi.ts +1724 -0
- package/src/routes/api.ts +1476 -118
- package/src/routes/auth.ts +242 -0
- package/src/server.ts +121 -11
- package/src/web/App.tsx +64 -19
- package/src/web/components/agents/AgentCard.tsx +24 -22
- package/src/web/components/agents/AgentPanel.tsx +810 -45
- package/src/web/components/agents/AgentsView.tsx +81 -9
- package/src/web/components/agents/CreateAgentModal.tsx +28 -1
- package/src/web/components/api/ApiDocsPage.tsx +583 -0
- package/src/web/components/auth/CreateAccountStep.tsx +176 -0
- package/src/web/components/auth/LoginPage.tsx +91 -0
- package/src/web/components/auth/index.ts +2 -0
- package/src/web/components/common/Icons.tsx +56 -0
- package/src/web/components/common/Modal.tsx +184 -1
- package/src/web/components/dashboard/Dashboard.tsx +70 -22
- package/src/web/components/index.ts +3 -0
- package/src/web/components/layout/Header.tsx +135 -18
- package/src/web/components/layout/Sidebar.tsx +87 -43
- package/src/web/components/mcp/IntegrationsPanel.tsx +743 -0
- package/src/web/components/mcp/McpPage.tsx +451 -63
- package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
- package/src/web/components/settings/SettingsPage.tsx +340 -26
- package/src/web/components/tasks/TasksPage.tsx +22 -20
- package/src/web/components/telemetry/TelemetryPage.tsx +163 -61
- package/src/web/context/AuthContext.tsx +230 -0
- package/src/web/context/ProjectContext.tsx +182 -0
- package/src/web/context/index.ts +5 -0
- package/src/web/hooks/useAgents.ts +18 -6
- package/src/web/hooks/useOnboarding.ts +20 -4
- package/src/web/hooks/useProviders.ts +15 -5
- package/src/web/icon.png +0 -0
- package/src/web/index.html +1 -1
- package/src/web/styles.css +12 -0
- package/src/web/types.ts +10 -1
- package/dist/App.3kb50qa3.js +0 -213
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { TasksIcon } from "../common/Icons";
|
|
3
|
+
import { useAuth } from "../../context";
|
|
3
4
|
import type { Task } from "../../types";
|
|
4
5
|
|
|
5
6
|
interface TasksPageProps {
|
|
@@ -7,20 +8,14 @@ interface TasksPageProps {
|
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function TasksPage({ onSelectAgent }: TasksPageProps) {
|
|
11
|
+
const { authFetch } = useAuth();
|
|
10
12
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
11
13
|
const [loading, setLoading] = useState(true);
|
|
12
14
|
const [filter, setFilter] = useState<string>("all");
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
fetchTasks();
|
|
16
|
-
// Refresh every 10 seconds
|
|
17
|
-
const interval = setInterval(fetchTasks, 10000);
|
|
18
|
-
return () => clearInterval(interval);
|
|
19
|
-
}, [filter]);
|
|
20
|
-
|
|
21
|
-
const fetchTasks = async () => {
|
|
16
|
+
const fetchTasks = useCallback(async () => {
|
|
22
17
|
try {
|
|
23
|
-
const res = await
|
|
18
|
+
const res = await authFetch(`/api/tasks?status=${filter}`);
|
|
24
19
|
const data = await res.json();
|
|
25
20
|
setTasks(data.tasks || []);
|
|
26
21
|
} catch (e) {
|
|
@@ -28,7 +23,14 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
|
|
|
28
23
|
} finally {
|
|
29
24
|
setLoading(false);
|
|
30
25
|
}
|
|
31
|
-
};
|
|
26
|
+
}, [authFetch, filter]);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
fetchTasks();
|
|
30
|
+
// Refresh every 10 seconds
|
|
31
|
+
const interval = setInterval(fetchTasks, 10000);
|
|
32
|
+
return () => clearInterval(interval);
|
|
33
|
+
}, [fetchTasks]);
|
|
32
34
|
|
|
33
35
|
const statusColors: Record<string, string> = {
|
|
34
36
|
pending: "bg-yellow-500/20 text-yellow-400",
|
|
@@ -47,21 +49,21 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
|
|
|
47
49
|
];
|
|
48
50
|
|
|
49
51
|
return (
|
|
50
|
-
<div className="flex-1 p-6 overflow-auto">
|
|
51
|
-
<div className="max-w-4xl
|
|
52
|
-
<div className="
|
|
53
|
-
<div>
|
|
54
|
-
<h1 className="text-2xl font-semibold mb-1">Tasks</h1>
|
|
55
|
-
<p className="text-[#666]">
|
|
52
|
+
<div className="flex-1 p-4 md:p-6 overflow-auto">
|
|
53
|
+
<div className="max-w-4xl">
|
|
54
|
+
<div className="mb-6">
|
|
55
|
+
<div className="mb-4">
|
|
56
|
+
<h1 className="text-xl md:text-2xl font-semibold mb-1">Tasks</h1>
|
|
57
|
+
<p className="text-sm text-[#666]">
|
|
56
58
|
View tasks from all running agents
|
|
57
59
|
</p>
|
|
58
60
|
</div>
|
|
59
|
-
<div className="flex gap-2">
|
|
61
|
+
<div className="flex gap-2 overflow-x-auto scrollbar-hide pb-1">
|
|
60
62
|
{filterOptions.map(opt => (
|
|
61
63
|
<button
|
|
62
64
|
key={opt.value}
|
|
63
65
|
onClick={() => setFilter(opt.value)}
|
|
64
|
-
className={`px-3 py-1.5 rounded text-sm transition ${
|
|
66
|
+
className={`px-3 py-1.5 rounded text-sm transition whitespace-nowrap ${
|
|
65
67
|
filter === opt.value
|
|
66
68
|
? "bg-[#f97316] text-black"
|
|
67
69
|
: "bg-[#1a1a1a] hover:bg-[#222]"
|
|
@@ -113,7 +115,7 @@ export function TasksPage({ onSelectAgent }: TasksPageProps) {
|
|
|
113
115
|
</p>
|
|
114
116
|
)}
|
|
115
117
|
|
|
116
|
-
<div className="flex items-center gap-4 text-xs text-[#555]">
|
|
118
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[#555]">
|
|
117
119
|
<span>Type: {task.type}</span>
|
|
118
120
|
<span>Priority: {task.priority}</span>
|
|
119
121
|
{task.recurrence && <span>Recurrence: {task.recurrence}</span>}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React, { useState, useEffect, useMemo, useRef } from "react";
|
|
1
|
+
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
|
2
2
|
import { Select } from "../common/Select";
|
|
3
|
-
import { useTelemetryContext, type TelemetryEvent } from "../../context";
|
|
3
|
+
import { useTelemetryContext, useProjects, useAuth, type TelemetryEvent } from "../../context";
|
|
4
4
|
|
|
5
5
|
interface TelemetryStats {
|
|
6
6
|
total_events: number;
|
|
@@ -45,26 +45,33 @@ function extractEventStats(event: TelemetryEvent): {
|
|
|
45
45
|
|
|
46
46
|
export function TelemetryPage() {
|
|
47
47
|
const { events: realtimeEvents } = useTelemetryContext();
|
|
48
|
+
const { currentProjectId, currentProject } = useProjects();
|
|
49
|
+
const { authFetch } = useAuth();
|
|
48
50
|
const [fetchedStats, setFetchedStats] = useState<TelemetryStats | null>(null);
|
|
49
51
|
const [historicalEvents, setHistoricalEvents] = useState<TelemetryEvent[]>([]);
|
|
50
52
|
const [fetchedUsage, setFetchedUsage] = useState<UsageByAgent[]>([]);
|
|
51
53
|
const [loading, setLoading] = useState(true);
|
|
52
54
|
const [filter, setFilter] = useState({
|
|
53
|
-
category: "",
|
|
54
55
|
level: "",
|
|
55
56
|
agent_id: "",
|
|
56
57
|
});
|
|
57
|
-
|
|
58
|
+
// Categories to hide (DATABASE hidden by default)
|
|
59
|
+
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set(["DATABASE"]));
|
|
60
|
+
const [agents, setAgents] = useState<Array<{ id: string; name: string; projectId: string | null }>>([]);
|
|
58
61
|
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
|
|
59
62
|
|
|
60
63
|
// Track IDs that were in the fetched stats to avoid double-counting
|
|
61
64
|
const countedEventIdsRef = useRef<Set<string>>(new Set());
|
|
62
65
|
|
|
66
|
+
// Track which events are "new" (for animation) - stores event IDs with their arrival time
|
|
67
|
+
const [newEventIds, setNewEventIds] = useState<Set<string>>(new Set());
|
|
68
|
+
const seenEventIdsRef = useRef<Set<string>>(new Set());
|
|
69
|
+
|
|
63
70
|
// Fetch agents for dropdown
|
|
64
71
|
useEffect(() => {
|
|
65
72
|
const fetchAgents = async () => {
|
|
66
73
|
try {
|
|
67
|
-
const res = await
|
|
74
|
+
const res = await authFetch("/api/agents");
|
|
68
75
|
const data = await res.json();
|
|
69
76
|
setAgents(data.agents || []);
|
|
70
77
|
} catch (e) {
|
|
@@ -72,25 +79,40 @@ export function TelemetryPage() {
|
|
|
72
79
|
}
|
|
73
80
|
};
|
|
74
81
|
fetchAgents();
|
|
75
|
-
}, []);
|
|
82
|
+
}, [authFetch]);
|
|
83
|
+
|
|
84
|
+
// Filter agents by project
|
|
85
|
+
const filteredAgents = useMemo(() => {
|
|
86
|
+
if (currentProjectId === null) return agents;
|
|
87
|
+
if (currentProjectId === "unassigned") return agents.filter(a => !a.projectId);
|
|
88
|
+
return agents.filter(a => a.projectId === currentProjectId);
|
|
89
|
+
}, [agents, currentProjectId]);
|
|
90
|
+
|
|
91
|
+
// Get agent IDs for the current project
|
|
92
|
+
const projectAgentIds = useMemo(() => new Set(filteredAgents.map(a => a.id)), [filteredAgents]);
|
|
76
93
|
|
|
77
94
|
// Fetch stats and historical data (less frequently now since we have real-time)
|
|
78
95
|
const fetchData = async () => {
|
|
79
96
|
setLoading(true);
|
|
80
97
|
try {
|
|
98
|
+
// Build project filter param
|
|
99
|
+
const projectParam = currentProjectId === "unassigned" ? "null" : currentProjectId || "";
|
|
100
|
+
|
|
81
101
|
// Fetch stats
|
|
82
|
-
const
|
|
102
|
+
const statsParams = new URLSearchParams();
|
|
103
|
+
if (projectParam) statsParams.set("project_id", projectParam);
|
|
104
|
+
const statsRes = await authFetch(`/api/telemetry/stats${statsParams.toString() ? `?${statsParams}` : ""}`);
|
|
83
105
|
const statsData = await statsRes.json();
|
|
84
106
|
setFetchedStats(statsData.stats);
|
|
85
107
|
|
|
86
108
|
// Fetch historical events with filters
|
|
87
109
|
const params = new URLSearchParams();
|
|
88
|
-
if (filter.category) params.set("category", filter.category);
|
|
89
110
|
if (filter.level) params.set("level", filter.level);
|
|
90
111
|
if (filter.agent_id) params.set("agent_id", filter.agent_id);
|
|
91
|
-
params.set("
|
|
112
|
+
if (projectParam) params.set("project_id", projectParam);
|
|
113
|
+
params.set("limit", "100"); // Fetch more since we filter client-side
|
|
92
114
|
|
|
93
|
-
const eventsRes = await
|
|
115
|
+
const eventsRes = await authFetch(`/api/telemetry/events?${params}`);
|
|
94
116
|
const eventsData = await eventsRes.json();
|
|
95
117
|
const events = eventsData.events || [];
|
|
96
118
|
setHistoricalEvents(events);
|
|
@@ -99,7 +121,10 @@ export function TelemetryPage() {
|
|
|
99
121
|
countedEventIdsRef.current = new Set(events.map((e: TelemetryEvent) => e.id));
|
|
100
122
|
|
|
101
123
|
// Fetch usage by agent
|
|
102
|
-
const
|
|
124
|
+
const usageParams = new URLSearchParams();
|
|
125
|
+
usageParams.set("group_by", "agent");
|
|
126
|
+
if (projectParam) usageParams.set("project_id", projectParam);
|
|
127
|
+
const usageRes = await authFetch(`/api/telemetry/usage?${usageParams}`);
|
|
103
128
|
const usageData = await usageRes.json();
|
|
104
129
|
setFetchedUsage(usageData.usage || []);
|
|
105
130
|
} catch (e) {
|
|
@@ -113,7 +138,7 @@ export function TelemetryPage() {
|
|
|
113
138
|
// Refresh stats every 60 seconds (events come in real-time)
|
|
114
139
|
const interval = setInterval(fetchData, 60000);
|
|
115
140
|
return () => clearInterval(interval);
|
|
116
|
-
}, [filter]);
|
|
141
|
+
}, [filter, currentProjectId, authFetch]);
|
|
117
142
|
|
|
118
143
|
// Compute real-time stats from new events (not already counted in fetched stats)
|
|
119
144
|
const stats = useMemo(() => {
|
|
@@ -188,20 +213,33 @@ export function TelemetryPage() {
|
|
|
188
213
|
const allEvents = React.useMemo(() => {
|
|
189
214
|
// Apply filters to real-time events
|
|
190
215
|
let filtered = realtimeEvents;
|
|
216
|
+
|
|
217
|
+
// Filter by project (for real-time events)
|
|
218
|
+
if (currentProjectId !== null) {
|
|
219
|
+
filtered = filtered.filter(e => projectAgentIds.has(e.agent_id));
|
|
220
|
+
}
|
|
221
|
+
|
|
191
222
|
if (filter.agent_id) {
|
|
192
223
|
filtered = filtered.filter(e => e.agent_id === filter.agent_id);
|
|
193
224
|
}
|
|
194
|
-
|
|
195
|
-
|
|
225
|
+
// Filter out hidden categories
|
|
226
|
+
if (hiddenCategories.size > 0) {
|
|
227
|
+
filtered = filtered.filter(e => !hiddenCategories.has(e.category));
|
|
196
228
|
}
|
|
197
229
|
if (filter.level) {
|
|
198
230
|
filtered = filtered.filter(e => e.level === filter.level);
|
|
199
231
|
}
|
|
200
232
|
|
|
233
|
+
// Filter historical events too
|
|
234
|
+
let filteredHistorical = historicalEvents;
|
|
235
|
+
if (hiddenCategories.size > 0) {
|
|
236
|
+
filteredHistorical = filteredHistorical.filter(e => !hiddenCategories.has(e.category));
|
|
237
|
+
}
|
|
238
|
+
|
|
201
239
|
// Merge with historical, dedupe by ID
|
|
202
240
|
const seen = new Set(filtered.map(e => e.id));
|
|
203
241
|
const merged = [...filtered];
|
|
204
|
-
for (const evt of
|
|
242
|
+
for (const evt of filteredHistorical) {
|
|
205
243
|
if (!seen.has(evt.id)) {
|
|
206
244
|
merged.push(evt);
|
|
207
245
|
seen.add(evt.id);
|
|
@@ -212,7 +250,35 @@ export function TelemetryPage() {
|
|
|
212
250
|
merged.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
213
251
|
|
|
214
252
|
return merged.slice(0, 100);
|
|
215
|
-
}, [realtimeEvents, historicalEvents, filter]);
|
|
253
|
+
}, [realtimeEvents, historicalEvents, filter, hiddenCategories, currentProjectId, projectAgentIds]);
|
|
254
|
+
|
|
255
|
+
// Track new events for animation - mark events as "new" when they first appear
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const newIds: string[] = [];
|
|
258
|
+
for (const event of realtimeEvents) {
|
|
259
|
+
if (!seenEventIdsRef.current.has(event.id)) {
|
|
260
|
+
seenEventIdsRef.current.add(event.id);
|
|
261
|
+
newIds.push(event.id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (newIds.length > 0) {
|
|
266
|
+
setNewEventIds(prev => {
|
|
267
|
+
const updated = new Set(prev);
|
|
268
|
+
newIds.forEach(id => updated.add(id));
|
|
269
|
+
return updated;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Remove "new" status after 5 seconds
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
setNewEventIds(prev => {
|
|
275
|
+
const updated = new Set(prev);
|
|
276
|
+
newIds.forEach(id => updated.delete(id));
|
|
277
|
+
return updated;
|
|
278
|
+
});
|
|
279
|
+
}, 5000);
|
|
280
|
+
}
|
|
281
|
+
}, [realtimeEvents]);
|
|
216
282
|
|
|
217
283
|
const getAgentName = (agentId: string) => {
|
|
218
284
|
const agent = agents.find(a => a.id === agentId);
|
|
@@ -241,23 +307,26 @@ export function TelemetryPage() {
|
|
|
241
307
|
TASK: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
|
242
308
|
MEMORY: "bg-cyan-500/20 text-cyan-400 border-cyan-500/30",
|
|
243
309
|
MCP: "bg-orange-500/20 text-orange-400 border-orange-500/30",
|
|
310
|
+
DATABASE: "bg-pink-500/20 text-pink-400 border-pink-500/30",
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const allCategories = ["LLM", "TOOL", "CHAT", "TASK", "MEMORY", "MCP", "SYSTEM", "DATABASE", "ERROR"];
|
|
314
|
+
|
|
315
|
+
const toggleCategory = (category: string) => {
|
|
316
|
+
setHiddenCategories(prev => {
|
|
317
|
+
const updated = new Set(prev);
|
|
318
|
+
if (updated.has(category)) {
|
|
319
|
+
updated.delete(category);
|
|
320
|
+
} else {
|
|
321
|
+
updated.add(category);
|
|
322
|
+
}
|
|
323
|
+
return updated;
|
|
324
|
+
});
|
|
244
325
|
};
|
|
245
326
|
|
|
246
327
|
const agentOptions = [
|
|
247
328
|
{ value: "", label: "All Agents" },
|
|
248
|
-
...
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
const categoryOptions = [
|
|
252
|
-
{ value: "", label: "All Categories" },
|
|
253
|
-
{ value: "LLM", label: "LLM" },
|
|
254
|
-
{ value: "TOOL", label: "Tool" },
|
|
255
|
-
{ value: "CHAT", label: "Chat" },
|
|
256
|
-
{ value: "TASK", label: "Task" },
|
|
257
|
-
{ value: "MEMORY", label: "Memory" },
|
|
258
|
-
{ value: "MCP", label: "MCP" },
|
|
259
|
-
{ value: "SYSTEM", label: "System" },
|
|
260
|
-
{ value: "ERROR", label: "Error" },
|
|
329
|
+
...filteredAgents.map(a => ({ value: a.id, label: a.name })),
|
|
261
330
|
];
|
|
262
331
|
|
|
263
332
|
const levelOptions = [
|
|
@@ -270,10 +339,24 @@ export function TelemetryPage() {
|
|
|
270
339
|
|
|
271
340
|
return (
|
|
272
341
|
<div className="flex-1 overflow-auto p-6">
|
|
273
|
-
<div
|
|
342
|
+
<div>
|
|
274
343
|
{/* Header */}
|
|
275
344
|
<div className="mb-6">
|
|
276
|
-
<
|
|
345
|
+
<div className="flex items-center gap-3 mb-1">
|
|
346
|
+
{currentProject && (
|
|
347
|
+
<span
|
|
348
|
+
className="w-3 h-3 rounded-full"
|
|
349
|
+
style={{ backgroundColor: currentProject.color }}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
<h1 className="text-2xl font-semibold">
|
|
353
|
+
{currentProjectId === null
|
|
354
|
+
? "Telemetry"
|
|
355
|
+
: currentProjectId === "unassigned"
|
|
356
|
+
? "Telemetry - Unassigned"
|
|
357
|
+
: `Telemetry - ${currentProject?.name || ""}`}
|
|
358
|
+
</h1>
|
|
359
|
+
</div>
|
|
277
360
|
<p className="text-[#666]">
|
|
278
361
|
Monitor agent activity, token usage, and errors.
|
|
279
362
|
</p>
|
|
@@ -331,8 +414,8 @@ export function TelemetryPage() {
|
|
|
331
414
|
)}
|
|
332
415
|
|
|
333
416
|
{/* Filters */}
|
|
334
|
-
<div className="flex items-center gap-3 mb-4">
|
|
335
|
-
<div className="w-
|
|
417
|
+
<div className="flex flex-wrap items-center gap-3 mb-4">
|
|
418
|
+
<div className="w-44">
|
|
336
419
|
<Select
|
|
337
420
|
value={filter.agent_id}
|
|
338
421
|
options={agentOptions}
|
|
@@ -340,28 +423,42 @@ export function TelemetryPage() {
|
|
|
340
423
|
placeholder="All Agents"
|
|
341
424
|
/>
|
|
342
425
|
</div>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
426
|
+
{/* Category toggles */}
|
|
427
|
+
<div className="flex flex-wrap items-center gap-1.5 flex-1">
|
|
428
|
+
{allCategories.map((cat) => {
|
|
429
|
+
const isHidden = hiddenCategories.has(cat);
|
|
430
|
+
const colorClass = categoryColors[cat] || "bg-[#222] text-[#888] border-[#333]";
|
|
431
|
+
return (
|
|
432
|
+
<button
|
|
433
|
+
key={cat}
|
|
434
|
+
onClick={() => toggleCategory(cat)}
|
|
435
|
+
className={`px-2 py-0.5 rounded text-xs border transition-all ${
|
|
436
|
+
isHidden
|
|
437
|
+
? "bg-[#1a1a1a] text-[#555] border-[#333] opacity-50"
|
|
438
|
+
: colorClass
|
|
439
|
+
}`}
|
|
440
|
+
>
|
|
441
|
+
{cat}
|
|
442
|
+
</button>
|
|
443
|
+
);
|
|
444
|
+
})}
|
|
350
445
|
</div>
|
|
351
|
-
<div className="
|
|
352
|
-
<
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
446
|
+
<div className="flex items-center gap-2">
|
|
447
|
+
<div className="w-36">
|
|
448
|
+
<Select
|
|
449
|
+
value={filter.level}
|
|
450
|
+
options={levelOptions}
|
|
451
|
+
onChange={(value) => setFilter({ ...filter, level: value })}
|
|
452
|
+
placeholder="All Levels"
|
|
453
|
+
/>
|
|
454
|
+
</div>
|
|
455
|
+
<button
|
|
456
|
+
onClick={fetchData}
|
|
457
|
+
className="px-3 py-2 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] rounded text-sm transition"
|
|
458
|
+
>
|
|
459
|
+
Refresh
|
|
460
|
+
</button>
|
|
358
461
|
</div>
|
|
359
|
-
<button
|
|
360
|
-
onClick={fetchData}
|
|
361
|
-
className="px-3 py-2 bg-[#1a1a1a] hover:bg-[#222] border border-[#333] rounded text-sm transition"
|
|
362
|
-
>
|
|
363
|
-
Refresh
|
|
364
|
-
</button>
|
|
365
462
|
</div>
|
|
366
463
|
|
|
367
464
|
{/* Events List */}
|
|
@@ -384,18 +481,21 @@ export function TelemetryPage() {
|
|
|
384
481
|
) : (
|
|
385
482
|
<div className="divide-y divide-[#1a1a1a]">
|
|
386
483
|
{allEvents.map((event) => {
|
|
387
|
-
|
|
388
|
-
const eventTime = new Date(event.timestamp).getTime();
|
|
389
|
-
const isNew = Date.now() - eventTime < 10000;
|
|
484
|
+
const isNew = newEventIds.has(event.id);
|
|
390
485
|
|
|
391
486
|
return (
|
|
392
487
|
<div
|
|
393
488
|
key={event.id}
|
|
394
|
-
className=
|
|
489
|
+
className={`p-3 hover:bg-[#0a0a0a] cursor-pointer transition-all duration-500 ${
|
|
490
|
+
isNew ? "bg-green-500/5" : ""
|
|
491
|
+
}`}
|
|
492
|
+
style={{
|
|
493
|
+
animation: isNew ? "slideIn 0.3s ease-out" : undefined,
|
|
494
|
+
}}
|
|
395
495
|
onClick={() => setExpandedEvent(expandedEvent === event.id ? null : event.id)}
|
|
396
496
|
>
|
|
397
497
|
<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]"}`}>
|
|
498
|
+
<span className={`px-2 py-0.5 rounded text-xs border transition-colors duration-300 ${categoryColors[event.category] || "bg-[#222] text-[#888] border-[#333]"}`}>
|
|
399
499
|
{event.category}
|
|
400
500
|
</span>
|
|
401
501
|
<div className="flex-1 min-w-0">
|
|
@@ -407,9 +507,11 @@ export function TelemetryPage() {
|
|
|
407
507
|
{event.duration_ms && (
|
|
408
508
|
<span className="text-xs text-[#555]">{event.duration_ms}ms</span>
|
|
409
509
|
)}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
510
|
+
<span
|
|
511
|
+
className={`w-1.5 h-1.5 rounded-full bg-green-400 transition-opacity duration-1000 ${
|
|
512
|
+
isNew ? "opacity-100" : "opacity-0"
|
|
513
|
+
}`}
|
|
514
|
+
/>
|
|
413
515
|
</div>
|
|
414
516
|
<div className="text-xs text-[#555] mt-1">
|
|
415
517
|
{getAgentName(event.agent_id)} · {new Date(event.timestamp).toLocaleString()}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface User {
|
|
4
|
+
id: string;
|
|
5
|
+
username: string;
|
|
6
|
+
role: "admin" | "user";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface AuthStatus {
|
|
10
|
+
hasUsers: boolean;
|
|
11
|
+
authenticated: boolean;
|
|
12
|
+
user?: User;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AuthContextValue {
|
|
16
|
+
user: User | null;
|
|
17
|
+
isAuthenticated: boolean;
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
hasUsers: boolean | null;
|
|
20
|
+
accessToken: string | null;
|
|
21
|
+
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
|
22
|
+
logout: () => Promise<void>;
|
|
23
|
+
refreshToken: () => Promise<boolean>;
|
|
24
|
+
checkAuth: () => Promise<void>;
|
|
25
|
+
authFetch: (url: string, options?: RequestInit) => Promise<Response>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
export function useAuth(): AuthContextValue {
|
|
31
|
+
const context = useContext(AuthContext);
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error("useAuth must be used within an AuthProvider");
|
|
34
|
+
}
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AuthProviderProps {
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AuthProvider({ children }: AuthProviderProps) {
|
|
43
|
+
const [user, setUser] = useState<User | null>(null);
|
|
44
|
+
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
45
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
46
|
+
const [hasUsers, setHasUsers] = useState<boolean | null>(null);
|
|
47
|
+
|
|
48
|
+
// Refs to track state without causing re-renders
|
|
49
|
+
const tokenRef = useRef<string | null>(null);
|
|
50
|
+
const refreshingRef = useRef(false);
|
|
51
|
+
const initializedRef = useRef(false);
|
|
52
|
+
|
|
53
|
+
// Helper to set token in both state and ref
|
|
54
|
+
const updateToken = useCallback((token: string | null) => {
|
|
55
|
+
tokenRef.current = token;
|
|
56
|
+
setAccessToken(token);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
// Internal refresh function - prevents concurrent refreshes
|
|
60
|
+
const refreshTokenInternal = useCallback(async (): Promise<boolean> => {
|
|
61
|
+
// Prevent concurrent refresh calls
|
|
62
|
+
if (refreshingRef.current) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
refreshingRef.current = true;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch("/api/auth/refresh", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
credentials: "include",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = await res.json();
|
|
78
|
+
updateToken(data.accessToken);
|
|
79
|
+
|
|
80
|
+
// Get user info with new token
|
|
81
|
+
const meRes = await fetch("/api/auth/me", {
|
|
82
|
+
headers: { Authorization: `Bearer ${data.accessToken}` },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (meRes.ok) {
|
|
86
|
+
const meData = await meRes.json();
|
|
87
|
+
setUser(meData.user);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return false;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error("Token refresh failed:", e);
|
|
94
|
+
return false;
|
|
95
|
+
} finally {
|
|
96
|
+
refreshingRef.current = false;
|
|
97
|
+
}
|
|
98
|
+
}, [updateToken]);
|
|
99
|
+
|
|
100
|
+
// Check auth status
|
|
101
|
+
const checkAuth = useCallback(async () => {
|
|
102
|
+
try {
|
|
103
|
+
const token = tokenRef.current;
|
|
104
|
+
const res = await fetch("/api/auth/check", {
|
|
105
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
106
|
+
});
|
|
107
|
+
const data: AuthStatus = await res.json();
|
|
108
|
+
|
|
109
|
+
setHasUsers(data.hasUsers);
|
|
110
|
+
|
|
111
|
+
if (data.authenticated && data.user) {
|
|
112
|
+
setUser(data.user as User);
|
|
113
|
+
} else {
|
|
114
|
+
setUser(null);
|
|
115
|
+
// Try to refresh if we have users (meaning there might be a cookie)
|
|
116
|
+
if (data.hasUsers) {
|
|
117
|
+
const refreshed = await refreshTokenInternal();
|
|
118
|
+
if (!refreshed) {
|
|
119
|
+
updateToken(null);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
console.error("Auth check failed:", e);
|
|
125
|
+
setUser(null);
|
|
126
|
+
updateToken(null);
|
|
127
|
+
} finally {
|
|
128
|
+
setIsLoading(false);
|
|
129
|
+
}
|
|
130
|
+
}, [refreshTokenInternal, updateToken]);
|
|
131
|
+
|
|
132
|
+
// Login
|
|
133
|
+
const login = useCallback(async (username: string, password: string): Promise<{ success: boolean; error?: string }> => {
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch("/api/auth/login", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "Content-Type": "application/json" },
|
|
138
|
+
credentials: "include",
|
|
139
|
+
body: JSON.stringify({ username, password }),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const data = await res.json();
|
|
143
|
+
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
return { success: false, error: data.error || "Login failed" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
updateToken(data.accessToken);
|
|
149
|
+
setUser(data.user);
|
|
150
|
+
setHasUsers(true);
|
|
151
|
+
|
|
152
|
+
return { success: true };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error("Login failed:", e);
|
|
155
|
+
return { success: false, error: "Login failed" };
|
|
156
|
+
}
|
|
157
|
+
}, [updateToken]);
|
|
158
|
+
|
|
159
|
+
// Logout
|
|
160
|
+
const logout = useCallback(async () => {
|
|
161
|
+
try {
|
|
162
|
+
const token = tokenRef.current;
|
|
163
|
+
await fetch("/api/auth/logout", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
credentials: "include",
|
|
166
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
167
|
+
});
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error("Logout failed:", e);
|
|
170
|
+
} finally {
|
|
171
|
+
setUser(null);
|
|
172
|
+
updateToken(null);
|
|
173
|
+
}
|
|
174
|
+
}, [updateToken]);
|
|
175
|
+
|
|
176
|
+
// Authenticated fetch wrapper - uses ref for latest token
|
|
177
|
+
const authFetch = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
|
178
|
+
const headers = new Headers(options.headers);
|
|
179
|
+
const token = tokenRef.current;
|
|
180
|
+
if (token) {
|
|
181
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
182
|
+
}
|
|
183
|
+
return fetch(url, { ...options, headers });
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
// Public refresh function
|
|
187
|
+
const refreshToken = useCallback(async (): Promise<boolean> => {
|
|
188
|
+
return refreshTokenInternal();
|
|
189
|
+
}, [refreshTokenInternal]);
|
|
190
|
+
|
|
191
|
+
// Check auth on mount - only once
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (initializedRef.current) return;
|
|
194
|
+
initializedRef.current = true;
|
|
195
|
+
checkAuth();
|
|
196
|
+
}, [checkAuth]);
|
|
197
|
+
|
|
198
|
+
// Set up token refresh interval
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
if (!accessToken) return;
|
|
201
|
+
|
|
202
|
+
// Refresh token 1 minute before expiry (tokens last 15 min)
|
|
203
|
+
const refreshInterval = setInterval(() => {
|
|
204
|
+
refreshTokenInternal();
|
|
205
|
+
}, 14 * 60 * 1000); // 14 minutes
|
|
206
|
+
|
|
207
|
+
return () => clearInterval(refreshInterval);
|
|
208
|
+
}, [accessToken, refreshTokenInternal]);
|
|
209
|
+
|
|
210
|
+
const value: AuthContextValue = {
|
|
211
|
+
user,
|
|
212
|
+
isAuthenticated: !!user,
|
|
213
|
+
isLoading,
|
|
214
|
+
hasUsers,
|
|
215
|
+
accessToken,
|
|
216
|
+
login,
|
|
217
|
+
logout,
|
|
218
|
+
refreshToken,
|
|
219
|
+
checkAuth,
|
|
220
|
+
authFetch,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Hook to get auth headers for API calls
|
|
227
|
+
export function useAuthHeaders(): Record<string, string> {
|
|
228
|
+
const { accessToken } = useAuth();
|
|
229
|
+
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
|
|
230
|
+
}
|