mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
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:
1 parent
6a6f2a2463
commit
56502ab6a8
25 files changed
+2230
-5024
No files matched your search
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user