re-uploading work

This commit is contained in:
2026-02-04 17:46:30 -06:00
commit 3b14c65998
1388 changed files with 381262 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import { AuthService, ApiError } from "@/api";
import type { UserInfo, LoginRequest } from "@/api";
import {
startTokenRefreshMonitor,
stopTokenRefreshMonitor,
} from "@/lib/api-wrapper";
interface AuthContextType {
user: UserInfo | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (credentials: LoginRequest) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
getToken: () => string | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadUser();
}, []);
// Start/stop token refresh monitoring based on auth state
useEffect(() => {
if (user) {
startTokenRefreshMonitor();
} else {
stopTokenRefreshMonitor();
}
return () => {
stopTokenRefreshMonitor();
};
}, [user]);
const loadUser = async () => {
const token = localStorage.getItem("access_token");
if (!token) {
setIsLoading(false);
return;
}
try {
const response = await AuthService.getCurrentUser();
setUser(response.data);
} catch (error) {
console.error("Failed to load user:", error);
if (error instanceof ApiError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setUser(null);
} finally {
setIsLoading(false);
}
};
const login = async (credentials: LoginRequest) => {
try {
const response = await AuthService.login({
requestBody: credentials,
});
const { access_token, refresh_token, user: userInfo } = response.data;
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
// If user info is included in response, use it; otherwise load it
if (userInfo) {
setUser(userInfo);
} else {
await loadUser();
}
} catch (error) {
console.error("Login failed:", error);
if (error instanceof ApiError) {
console.error(`API Error ${error.status}: ${error.message}`);
}
throw error;
}
};
const logout = () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
stopTokenRefreshMonitor();
setUser(null);
};
const refreshUser = async () => {
await loadUser();
};
const getToken = () => {
return localStorage.getItem("access_token");
};
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
refreshUser,
getToken,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,341 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
ReactNode,
} from "react";
export interface Notification {
notification_type: string;
entity_type: string;
entity_id: number;
user_id?: number;
payload: unknown;
timestamp: string;
}
export type NotificationHandler = (notification: Notification) => void;
interface WebSocketContextValue {
connected: boolean;
subscribe: (filter: string, handler: NotificationHandler) => void;
unsubscribe: (filter: string, handler: NotificationHandler) => void;
}
const WebSocketContext = createContext<WebSocketContextValue | null>(null);
interface WebSocketProviderProps {
children: ReactNode;
url?: string;
autoConnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
/**
* WebSocketProvider maintains a single WebSocket connection for the entire application.
*
* Note: In React 18 StrictMode (development only), components mount twice to help detect
* side effects. This may briefly create two WebSocket connections, but the first one is
* cleaned up immediately. In production builds, only one connection is created.
*/
export function WebSocketProvider({
children,
url: providedUrl,
autoConnect = true,
reconnectInterval = 5000,
maxReconnectAttempts = 10,
}: WebSocketProviderProps) {
// Construct WebSocket URL from base (add /ws path if not present)
const baseUrl =
providedUrl || import.meta.env.VITE_WS_URL || "ws://localhost:8081";
const url = baseUrl.endsWith("/ws") ? baseUrl : `${baseUrl}/ws`;
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const shouldConnectRef = useRef(autoConnect);
const reconnectAttemptsRef = useRef(0);
const isConnectingRef = useRef(false);
// Map of filter -> Set of handlers
const subscriptionsRef = useRef<Map<string, Set<NotificationHandler>>>(
new Map(),
);
const connect = useCallback(() => {
// Don't reconnect if we're already connected, connecting, or explicitly disconnected
if (
wsRef.current?.readyState === WebSocket.OPEN ||
isConnectingRef.current ||
!shouldConnectRef.current
) {
return;
}
// Check max reconnect attempts
if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
console.error(
`[WebSocket] Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`,
);
shouldConnectRef.current = false;
return;
}
const attemptConnect = () => {
try {
isConnectingRef.current = true;
const ws = new WebSocket(url);
ws.onopen = () => {
setConnected(true);
isConnectingRef.current = false;
reconnectAttemptsRef.current = 0; // Reset attempts on successful connection
// Re-subscribe to all filters
subscriptionsRef.current.forEach((_, filter) => {
ws.send(
JSON.stringify({
type: "subscribe",
filter,
}),
);
});
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Handle different message types
if (message.type === "welcome") {
// Connection acknowledged
} else if (message.notification_type) {
// This is a notification - dispatch to all relevant handlers
const notification = message as Notification;
// Call handlers for entity_type:* subscriptions
const entityFilter = `entity_type:${notification.entity_type}`;
const handlers = subscriptionsRef.current.get(entityFilter);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(notification);
} catch (error) {
console.error("[WebSocket] Handler error:", error);
}
});
}
}
} catch (error) {
console.error("[WebSocket] Failed to parse message:", error);
}
};
ws.onerror = (error) => {
console.error("[WebSocket] Error:", error);
};
ws.onclose = () => {
setConnected(false);
isConnectingRef.current = false;
wsRef.current = null;
// Attempt to reconnect if we should still be connected
if (
shouldConnectRef.current &&
reconnectAttemptsRef.current < maxReconnectAttempts
) {
reconnectAttemptsRef.current += 1;
const delay = Math.min(
reconnectInterval * reconnectAttemptsRef.current,
30000,
);
// Attempting reconnection with backoff
reconnectTimeoutRef.current = setTimeout(() => {
attemptConnect();
}, delay);
}
};
wsRef.current = ws;
} catch (error) {
console.error("[WebSocket] Failed to connect:", error);
isConnectingRef.current = false;
// Retry connection with backoff
if (
shouldConnectRef.current &&
reconnectAttemptsRef.current < maxReconnectAttempts
) {
reconnectAttemptsRef.current += 1;
const delay = Math.min(
reconnectInterval * reconnectAttemptsRef.current,
30000,
);
reconnectTimeoutRef.current = setTimeout(() => {
attemptConnect();
}, delay);
}
}
};
attemptConnect();
}, [url, reconnectInterval, maxReconnectAttempts]);
const disconnect = useCallback(() => {
shouldConnectRef.current = false;
isConnectingRef.current = false;
reconnectAttemptsRef.current = 0;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
// Close connection cleanly
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close(1000, "Client disconnecting");
}
wsRef.current = null;
}
setConnected(false);
}, []);
const subscribe = useCallback(
(filter: string, handler: NotificationHandler) => {
// Add handler to the set for this filter
if (!subscriptionsRef.current.has(filter)) {
subscriptionsRef.current.set(filter, new Set());
}
const handlers = subscriptionsRef.current.get(filter)!;
const hadHandlers = handlers.size > 0;
handlers.add(handler);
// Only send subscribe message if this is the first handler for this filter
if (!hadHandlers && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "subscribe",
filter,
}),
);
}
},
[],
);
const unsubscribe = useCallback(
(filter: string, handler: NotificationHandler) => {
const handlers = subscriptionsRef.current.get(filter);
if (!handlers) return;
handlers.delete(handler);
// If no more handlers for this filter, unsubscribe from the server
if (handlers.size === 0) {
subscriptionsRef.current.delete(filter);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "unsubscribe",
filter,
}),
);
}
}
},
[],
);
// Connect on mount if autoConnect is enabled
useEffect(() => {
if (autoConnect) {
shouldConnectRef.current = true;
connect();
}
return () => {
disconnect();
};
}, [autoConnect, connect, disconnect]);
const value: WebSocketContextValue = {
connected,
subscribe,
unsubscribe,
};
return (
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);
}
export function useWebSocketContext(): WebSocketContextValue {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error(
"useWebSocketContext must be used within WebSocketProvider",
);
}
return context;
}
/**
* Hook for subscribing to specific entity type notifications
* Uses the shared WebSocket connection from context
*
* @example
* // Subscribe to all execution updates
* useEntityNotifications('execution', () => {
* queryClient.invalidateQueries(['executions']);
* });
*/
export function useEntityNotifications(
entityType: string,
onNotification: NotificationHandler,
enabled = true,
) {
const { connected, subscribe, unsubscribe } = useWebSocketContext();
// Stable reference to the handler
const handlerRef = useRef(onNotification);
// Stable reference to the wrapper function (created once, never changes)
const stableHandlerRef = useRef<NotificationHandler | null>(null);
// Initialize the stable handler once
if (stableHandlerRef.current === null) {
stableHandlerRef.current = (notification) => {
handlerRef.current(notification);
};
}
// Update ref when handler changes (but don't cause re-subscription)
useEffect(() => {
handlerRef.current = onNotification;
}, [onNotification]);
useEffect(() => {
if (!connected || !enabled) return;
const filter = `entity_type:${entityType}`;
const stableHandler = stableHandlerRef.current!;
subscribe(filter, stableHandler);
return () => {
unsubscribe(filter, stableHandler);
};
}, [connected, enabled, entityType, subscribe, unsubscribe]);
return { connected };
}