feat(web-dashboard): add rest api and real-time sync

REST API & CRUD:
- Add REST API routes for profiles, cliproxy, accounts
- Integrate React Query with data fetching hooks
- Create API client wrapper with TypeScript types
- Add shadcn/ui components (dialog, input, label, table)
- Build profiles table with edit/delete actions
- Create profile form dialog with zod validation
- Mount QueryClientProvider in App

Real-time Sync & WebSocket:
- Implement file watcher with chokidar for config changes
- Create WebSocket server with broadcast capability
- Add client auto-reconnect with exponential backoff
- Integrate React Query cache invalidation on WS messages
- Add connection status indicator in header
- Implement heartbeat keepalive (30s interval)
- Add graceful cleanup on server shutdown
- Show toast notifications for external config changes

Dependencies:
- Add @tanstack/react-query, react-table, react-hook-form, zod
- Add chokidar for file watching
This commit is contained in:
kaitranntt committed 2025-12-07 12:00:56 -05:00
1 parent 6a6f2a2463
commit 56502ab6a8
25 files changed
+2230 -5024

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
/**
* React Query hooks for profiles
* Phase 03: REST API Routes & CRUD
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, type CreateProfile, type UpdateProfile } from '@/lib/api-client';
import { toast } from 'sonner';
export function useProfiles() {
return useQuery({
queryKey: ['profiles'],
queryFn: () => api.profiles.list(),
});
}
export function useCreateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProfile) => api.profiles.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile created successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, data }: { name: string; data: UpdateProfile }) =>
api.profiles.update(name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile updated successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useDeleteProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.profiles.delete(name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Profile deleted successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* WebSocket Hook (Phase 04)
*
* Manages WebSocket connection, auto-reconnect, and React Query invalidation.
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
interface WSMessage {
type: string;
path?: string;
timestamp?: number;
}
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
export function useWebSocket() {
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const queryClient = useQueryClient();
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
setStatus('connecting');
const ws = new WebSocket(`ws://${window.location.host}`);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
reconnectAttempts.current = 0;
console.log('[WS] Connected');
};
ws.onmessage = (event) => {
try {
const message: WSMessage = JSON.parse(event.data);
handleMessage(message);
} catch {
console.log('[WS] Invalid message');
}
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
// Attempt reconnect with exponential backoff
if (reconnectAttempts.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
setTimeout(connect, delay);
}
};
ws.onerror = () => {
console.log('[WS] Connection error');
};
}, []);
const handleMessage = (message: WSMessage) => {
switch (message.type) {
case 'connected':
console.log('[WS] Server acknowledged connection');
break;
case 'config-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
toast.info('Configuration updated externally');
break;
case 'settings-changed':
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.info('Settings file updated');
break;
case 'profiles-changed':
queryClient.invalidateQueries({ queryKey: ['accounts'] });
toast.info('Accounts updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log(`[WS] Unknown message: ${message.type}`);
}
};
const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
wsRef.current?.close();
}, []);
// Connect on mount, disconnect on unmount
useEffect(() => {
connect();
return () => disconnect();
}, [connect, disconnect]);
// Heartbeat to keep connection alive
useEffect(() => {
const interval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
return () => clearInterval(interval);
}, []);
return { status, connect, disconnect };
}