apteva 0.2.6 → 0.2.8
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.hzbfeg94.js +217 -0
- package/dist/index.html +3 -1
- 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 +570 -32
- package/src/routes/api.ts +913 -38
- package/src/routes/auth.ts +242 -0
- package/src/server.ts +60 -8
- package/src/web/App.tsx +61 -19
- package/src/web/components/agents/AgentCard.tsx +30 -41
- package/src/web/components/agents/AgentPanel.tsx +751 -11
- package/src/web/components/agents/AgentsView.tsx +81 -9
- package/src/web/components/agents/CreateAgentModal.tsx +28 -1
- 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 +48 -0
- package/src/web/components/common/Modal.tsx +1 -1
- package/src/web/components/dashboard/Dashboard.tsx +91 -31
- package/src/web/components/index.ts +3 -0
- package/src/web/components/layout/Header.tsx +145 -15
- package/src/web/components/layout/Sidebar.tsx +81 -43
- package/src/web/components/mcp/McpPage.tsx +261 -32
- package/src/web/components/onboarding/OnboardingWizard.tsx +64 -8
- package/src/web/components/settings/SettingsPage.tsx +404 -18
- package/src/web/components/tasks/TasksPage.tsx +21 -19
- package/src/web/components/telemetry/TelemetryPage.tsx +271 -81
- package/src/web/context/AuthContext.tsx +230 -0
- package/src/web/context/ProjectContext.tsx +182 -0
- package/src/web/context/TelemetryContext.tsx +98 -76
- 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/styles.css +12 -0
- package/src/web/types.ts +6 -0
- package/dist/App.0mzj9cz9.js +0 -213
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
|
2
|
+
import { useAuth } from "./AuthContext";
|
|
3
|
+
|
|
4
|
+
export interface Project {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string | null;
|
|
8
|
+
color: string;
|
|
9
|
+
agentCount: number;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ProjectContextValue {
|
|
15
|
+
projects: Project[];
|
|
16
|
+
currentProjectId: string | null; // null = "All Projects", "unassigned" = unassigned agents
|
|
17
|
+
currentProject: Project | null;
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
error: string | null;
|
|
20
|
+
unassignedCount: number;
|
|
21
|
+
setCurrentProjectId: (id: string | null) => void;
|
|
22
|
+
createProject: (data: { name: string; description?: string; color?: string }) => Promise<Project | null>;
|
|
23
|
+
updateProject: (id: string, data: { name?: string; description?: string; color?: string }) => Promise<Project | null>;
|
|
24
|
+
deleteProject: (id: string) => Promise<boolean>;
|
|
25
|
+
refreshProjects: () => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ProjectContext = createContext<ProjectContextValue | null>(null);
|
|
29
|
+
|
|
30
|
+
export function useProjects(): ProjectContextValue {
|
|
31
|
+
const context = useContext(ProjectContext);
|
|
32
|
+
if (!context) {
|
|
33
|
+
throw new Error("useProjects must be used within a ProjectProvider");
|
|
34
|
+
}
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ProjectProviderProps {
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STORAGE_KEY = "apteva_current_project";
|
|
43
|
+
|
|
44
|
+
export function ProjectProvider({ children }: ProjectProviderProps) {
|
|
45
|
+
const { authFetch, isAuthenticated, isLoading: authLoading } = useAuth();
|
|
46
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
47
|
+
const [currentProjectId, setCurrentProjectIdState] = useState<string | null>(() => {
|
|
48
|
+
// Load from localStorage
|
|
49
|
+
if (typeof window !== "undefined") {
|
|
50
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
});
|
|
54
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
55
|
+
const [error, setError] = useState<string | null>(null);
|
|
56
|
+
const [unassignedCount, setUnassignedCount] = useState(0);
|
|
57
|
+
|
|
58
|
+
const setCurrentProjectId = useCallback((id: string | null) => {
|
|
59
|
+
setCurrentProjectIdState(id);
|
|
60
|
+
if (typeof window !== "undefined") {
|
|
61
|
+
if (id === null) {
|
|
62
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
63
|
+
} else {
|
|
64
|
+
localStorage.setItem(STORAGE_KEY, id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const currentProject = projects.find(p => p.id === currentProjectId) || null;
|
|
70
|
+
|
|
71
|
+
const refreshProjects = useCallback(async () => {
|
|
72
|
+
if (!isAuthenticated && !authLoading) {
|
|
73
|
+
setProjects([]);
|
|
74
|
+
setIsLoading(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
setError(null);
|
|
80
|
+
const res = await authFetch("/api/projects");
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
throw new Error("Failed to fetch projects");
|
|
83
|
+
}
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
setProjects(data.projects || []);
|
|
86
|
+
setUnassignedCount(data.unassignedCount || 0);
|
|
87
|
+
|
|
88
|
+
// If current project no longer exists, reset to all
|
|
89
|
+
if (currentProjectId && currentProjectId !== "unassigned" && !data.projects.find((p: Project) => p.id === currentProjectId)) {
|
|
90
|
+
setCurrentProjectId(null);
|
|
91
|
+
}
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error("Failed to fetch projects:", e);
|
|
94
|
+
setError("Failed to load projects");
|
|
95
|
+
} finally {
|
|
96
|
+
setIsLoading(false);
|
|
97
|
+
}
|
|
98
|
+
}, [authFetch, isAuthenticated, authLoading, currentProjectId, setCurrentProjectId]);
|
|
99
|
+
|
|
100
|
+
const createProject = useCallback(async (data: { name: string; description?: string; color?: string }): Promise<Project | null> => {
|
|
101
|
+
try {
|
|
102
|
+
const res = await authFetch("/api/projects", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify(data),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const err = await res.json();
|
|
109
|
+
throw new Error(err.error || "Failed to create project");
|
|
110
|
+
}
|
|
111
|
+
const result = await res.json();
|
|
112
|
+
await refreshProjects();
|
|
113
|
+
return result.project;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error("Failed to create project:", e);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}, [authFetch, refreshProjects]);
|
|
119
|
+
|
|
120
|
+
const updateProject = useCallback(async (id: string, data: { name?: string; description?: string; color?: string }): Promise<Project | null> => {
|
|
121
|
+
try {
|
|
122
|
+
const res = await authFetch(`/api/projects/${id}`, {
|
|
123
|
+
method: "PUT",
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify(data),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
const err = await res.json();
|
|
129
|
+
throw new Error(err.error || "Failed to update project");
|
|
130
|
+
}
|
|
131
|
+
const result = await res.json();
|
|
132
|
+
await refreshProjects();
|
|
133
|
+
return result.project;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.error("Failed to update project:", e);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}, [authFetch, refreshProjects]);
|
|
139
|
+
|
|
140
|
+
const deleteProject = useCallback(async (id: string): Promise<boolean> => {
|
|
141
|
+
try {
|
|
142
|
+
const res = await authFetch(`/api/projects/${id}`, {
|
|
143
|
+
method: "DELETE",
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const err = await res.json();
|
|
147
|
+
throw new Error(err.error || "Failed to delete project");
|
|
148
|
+
}
|
|
149
|
+
if (currentProjectId === id) {
|
|
150
|
+
setCurrentProjectId(null);
|
|
151
|
+
}
|
|
152
|
+
await refreshProjects();
|
|
153
|
+
return true;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
console.error("Failed to delete project:", e);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}, [authFetch, currentProjectId, setCurrentProjectId, refreshProjects]);
|
|
159
|
+
|
|
160
|
+
// Fetch projects when authenticated
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!authLoading) {
|
|
163
|
+
refreshProjects();
|
|
164
|
+
}
|
|
165
|
+
}, [authLoading, refreshProjects]);
|
|
166
|
+
|
|
167
|
+
const value: ProjectContextValue = {
|
|
168
|
+
projects,
|
|
169
|
+
currentProjectId,
|
|
170
|
+
currentProject,
|
|
171
|
+
isLoading,
|
|
172
|
+
error,
|
|
173
|
+
unassignedCount,
|
|
174
|
+
setCurrentProjectId,
|
|
175
|
+
createProject,
|
|
176
|
+
updateProject,
|
|
177
|
+
deleteProject,
|
|
178
|
+
refreshProjects,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>;
|
|
182
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from "react";
|
|
1
|
+
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
|
|
3
3
|
export interface TelemetryEvent {
|
|
4
4
|
id: string;
|
|
@@ -18,6 +18,7 @@ interface TelemetryContextValue {
|
|
|
18
18
|
connected: boolean;
|
|
19
19
|
events: TelemetryEvent[];
|
|
20
20
|
lastActivityByAgent: Record<string, { timestamp: string; category: string; type: string }>;
|
|
21
|
+
activeAgents: Record<string, { type: string; expiresAt: number }>;
|
|
21
22
|
clearEvents: () => void;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -29,70 +30,111 @@ export function TelemetryProvider({ children }: { children: React.ReactNode }) {
|
|
|
29
30
|
const [connected, setConnected] = useState(false);
|
|
30
31
|
const [events, setEvents] = useState<TelemetryEvent[]>([]);
|
|
31
32
|
const [lastActivityByAgent, setLastActivityByAgent] = useState<Record<string, { timestamp: string; category: string; type: string }>>({});
|
|
33
|
+
const [activeAgents, setActiveAgents] = useState<Record<string, { type: string; expiresAt: number }>>({});
|
|
32
34
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
33
35
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
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
|
+
|
|
35
54
|
const connect = useCallback(() => {
|
|
36
55
|
if (eventSourceRef.current) {
|
|
37
56
|
eventSourceRef.current.close();
|
|
57
|
+
eventSourceRef.current = null;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
100
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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)
|
|
79
116
|
}
|
|
80
|
-
}
|
|
81
|
-
console.error("Failed to parse telemetry event:", e);
|
|
82
|
-
}
|
|
83
|
-
};
|
|
117
|
+
};
|
|
84
118
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
119
|
+
es.onerror = () => {
|
|
120
|
+
setConnected(false);
|
|
121
|
+
es.close();
|
|
122
|
+
eventSourceRef.current = null;
|
|
89
123
|
|
|
90
|
-
|
|
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);
|
|
91
133
|
if (reconnectTimeoutRef.current) {
|
|
92
134
|
clearTimeout(reconnectTimeoutRef.current);
|
|
93
135
|
}
|
|
94
|
-
reconnectTimeoutRef.current = setTimeout(connect,
|
|
95
|
-
}
|
|
136
|
+
reconnectTimeoutRef.current = setTimeout(connect, 2000);
|
|
137
|
+
}
|
|
96
138
|
}, []);
|
|
97
139
|
|
|
98
140
|
useEffect(() => {
|
|
@@ -113,7 +155,7 @@ export function TelemetryProvider({ children }: { children: React.ReactNode }) {
|
|
|
113
155
|
}, []);
|
|
114
156
|
|
|
115
157
|
return (
|
|
116
|
-
<TelemetryContext.Provider value={{ connected, events, lastActivityByAgent, clearEvents }}>
|
|
158
|
+
<TelemetryContext.Provider value={{ connected, events, lastActivityByAgent, activeAgents, clearEvents }}>
|
|
117
159
|
{children}
|
|
118
160
|
</TelemetryContext.Provider>
|
|
119
161
|
);
|
|
@@ -170,33 +212,13 @@ export function useTelemetry(filter?: {
|
|
|
170
212
|
};
|
|
171
213
|
}
|
|
172
214
|
|
|
173
|
-
// Hook for agent activity indicator
|
|
215
|
+
// Hook for agent activity indicator - uses context-level tracking
|
|
174
216
|
export function useAgentActivity(agentId: string) {
|
|
175
|
-
const {
|
|
176
|
-
const
|
|
177
|
-
const lastActivity = lastActivityByAgent[agentId];
|
|
178
|
-
|
|
179
|
-
useEffect(() => {
|
|
180
|
-
if (!lastActivity) {
|
|
181
|
-
setIsActive(false);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Set active when we get new activity
|
|
186
|
-
setIsActive(true);
|
|
187
|
-
|
|
188
|
-
// Clear active state after 3 seconds of no activity
|
|
189
|
-
const timeout = setTimeout(() => {
|
|
190
|
-
setIsActive(false);
|
|
191
|
-
}, 3000);
|
|
192
|
-
|
|
193
|
-
return () => clearTimeout(timeout);
|
|
194
|
-
}, [lastActivity?.timestamp]);
|
|
217
|
+
const { activeAgents } = useTelemetryContext();
|
|
218
|
+
const activity = activeAgents[agentId];
|
|
195
219
|
|
|
196
220
|
return {
|
|
197
|
-
isActive,
|
|
198
|
-
|
|
199
|
-
category: lastActivity?.category,
|
|
200
|
-
type: lastActivity?.type,
|
|
221
|
+
isActive: !!activity,
|
|
222
|
+
type: activity?.type,
|
|
201
223
|
};
|
|
202
224
|
}
|
package/src/web/context/index.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export { TelemetryProvider, useTelemetryContext, useTelemetry, useAgentActivity } from "./TelemetryContext";
|
|
2
2
|
export type { TelemetryEvent } from "./TelemetryContext";
|
|
3
|
+
|
|
4
|
+
export { AuthProvider, useAuth, useAuthHeaders } from "./AuthContext";
|
|
5
|
+
|
|
6
|
+
export { ProjectProvider, useProjects } from "./ProjectContext";
|
|
7
|
+
export type { Project } from "./ProjectContext";
|