diff --git a/src/web-server/routes/persist-routes.ts b/src/web-server/routes/persist-routes.ts index 80f1b915..b3e9bbce 100644 --- a/src/web-server/routes/persist-routes.ts +++ b/src/web-server/routes/persist-routes.ts @@ -15,6 +15,9 @@ interface BackupFile { date: Date; } +/** Simple in-memory mutex for restore operations */ +let restoreLock = false; + /** Get Claude settings.json path */ function getClaudeSettingsPath(): string { return path.join(os.homedir(), '.claude', 'settings.json'); @@ -85,6 +88,14 @@ router.get('/backups', (_req: Request, res: Response): void => { * Body: { timestamp?: string } - If not provided, restores latest */ router.post('/restore', (req: Request, res: Response): void => { + // Check concurrent restore lock + if (restoreLock) { + res.status(409).json({ error: 'Restore already in progress' }); + return; + } + + restoreLock = true; + try { const { timestamp } = req.body; const backups = getBackupFiles(); @@ -107,19 +118,6 @@ router.post('/restore', (req: Request, res: Response): void => { backup = found; } - // Validate backup JSON - try { - const content = fs.readFileSync(backup.path, 'utf8'); - const parsed = JSON.parse(content); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - res.status(400).json({ error: 'Backup file is corrupted' }); - return; - } - } catch { - res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' }); - return; - } - // Security checks if (isSymlink(backup.path)) { res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); @@ -132,16 +130,65 @@ router.post('/restore', (req: Request, res: Response): void => { return; } - // Restore - fs.copyFileSync(backup.path, settingsPath); + // Read and validate backup JSON (do this once atomically) + let backupContent: string; + try { + backupContent = fs.readFileSync(backup.path, 'utf8'); + const parsed = JSON.parse(backupContent); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + res.status(400).json({ error: 'Backup file is corrupted' }); + return; + } + } catch { + res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' }); + return; + } - res.json({ - success: true, - timestamp: backup.timestamp, - date: backup.date.toISOString(), - }); + // Atomic restore with rollback capability + const settingsDir = path.dirname(settingsPath); + const tempPath = path.join(settingsDir, 'settings.json.restore-tmp'); + const backupPath = path.join(settingsDir, 'settings.json.rollback-tmp'); + + try { + // Step 1: Backup current settings for rollback + if (fs.existsSync(settingsPath)) { + fs.copyFileSync(settingsPath, backupPath); + } + + // Step 2: Write validated content to temp file + fs.writeFileSync(tempPath, backupContent, 'utf8'); + + // Step 3: Atomic rename (replaces existing file) + fs.renameSync(tempPath, settingsPath); + + // Step 4: Cleanup rollback backup on success + if (fs.existsSync(backupPath)) { + fs.unlinkSync(backupPath); + } + + res.json({ + success: true, + timestamp: backup.timestamp, + date: backup.date.toISOString(), + }); + } catch (error) { + // Rollback on failure + try { + if (fs.existsSync(backupPath)) { + fs.renameSync(backupPath, settingsPath); + } + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } catch { + // Ignore cleanup errors + } + throw error; + } } catch (error) { res.status(500).json({ error: (error as Error).message }); + } finally { + restoreLock = false; } }); diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index dd4a1989..031a01b3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -41,8 +41,9 @@ import type { AccountItemProps } from './types'; * Get color class based on quota percentage */ function getQuotaColor(percentage: number): string { - if (percentage <= 20) return 'bg-destructive'; - if (percentage <= 50) return 'bg-yellow-500'; + const clamped = Math.max(0, Math.min(100, percentage)); + if (clamped <= 20) return 'bg-destructive'; + if (clamped <= 50) return 'bg-yellow-500'; return 'bg-green-500'; } @@ -257,7 +258,7 @@ export function AccountItem({
diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 9ec10395..1a4f9192 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -16,7 +16,9 @@ export function useSettingsTab() { ? 'proxy' : tabParam === 'auth' ? 'auth' - : 'websearch'; + : tabParam === 'backups' + ? 'backups' + : 'websearch'; const setActiveTab = useCallback( (tab: SettingsTab) => { diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index e395db9c..abfd6529 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -3,10 +3,10 @@ * Main entry point with lazy-loaded sections and URL tab persistence */ -import { lazy, Suspense, startTransition, useEffect } from 'react'; +import { lazy, Suspense, startTransition, useEffect, Component, type ReactNode } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Button } from '@/components/ui/button'; -import { RefreshCw, FileCode, Copy, Check, GripVertical } from 'lucide-react'; +import { RefreshCw, FileCode, Copy, Check, GripVertical, AlertCircle } from 'lucide-react'; import { CodeEditor } from '@/components/shared/code-editor'; import { SettingsProvider } from './context'; import { useSettingsTab, useRawConfig } from './hooks'; @@ -21,6 +21,45 @@ const ProxySection = lazy(() => import('./sections/proxy')); const AuthSection = lazy(() => import('./sections/auth-section')); const BackupsSection = lazy(() => import('./sections/backups-section')); +// Error Boundary for lazy-loaded sections +class SectionErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ +

Failed to load section

+

{this.state.error?.message || 'Unknown error occurred'}

+ +
+
+ ); + } + + return this.props.children; + } +} + // Inner component that uses context function SettingsPageInner() { const { activeTab, setActiveTab } = useSettingsTab(); @@ -55,13 +94,15 @@ function SettingsPageInner() {
{/* Tab Content */} - }> - {activeTab === 'websearch' && } - {activeTab === 'globalenv' && } - {activeTab === 'proxy' && } - {activeTab === 'auth' && } - {activeTab === 'backups' && } - + + }> + {activeTab === 'websearch' && } + {activeTab === 'globalenv' && } + {activeTab === 'proxy' && } + {activeTab === 'auth' && } + {activeTab === 'backups' && } + + diff --git a/ui/src/pages/settings/sections/backups-section.tsx b/ui/src/pages/settings/sections/backups-section.tsx index 8d0ee98e..3a9985a7 100644 --- a/ui/src/pages/settings/sections/backups-section.tsx +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -3,7 +3,7 @@ * Settings section for managing config.yaml backups (list and restore) */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -25,6 +25,10 @@ interface BackupsResponse { export default function BackupsSection() { const { fetchRawConfig } = useRawConfig(); + // AbortController refs for cleanup + const abortControllerRef = useRef(null); + const restoreAbortControllerRef = useRef(null); + // State const [backups, setBackups] = useState([]); const [loading, setLoading] = useState(true); @@ -34,16 +38,24 @@ export default function BackupsSection() { // Fetch backups const fetchBackups = useCallback(async () => { + // Abort previous request + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + try { setLoading(true); setError(null); - const response = await fetch('/api/persist/backups'); + const response = await fetch('/api/persist/backups', { + signal: abortControllerRef.current.signal, + }); if (!response.ok) { throw new Error('Failed to fetch backups'); } const data: BackupsResponse = await response.json(); setBackups(data.backups || []); } catch (err) { + // Ignore abort errors + if (err instanceof Error && err.name === 'AbortError') return; setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); @@ -52,6 +64,10 @@ export default function BackupsSection() { // Restore backup const restoreBackup = async (timestamp: string) => { + // Abort previous restore request + restoreAbortControllerRef.current?.abort(); + restoreAbortControllerRef.current = new AbortController(); + try { setRestoring(timestamp); setError(null); @@ -59,6 +75,7 @@ export default function BackupsSection() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timestamp }), + signal: restoreAbortControllerRef.current.signal, }); if (!response.ok) { @@ -70,6 +87,8 @@ export default function BackupsSection() { await fetchBackups(); await fetchRawConfig(); } catch (err) { + // Ignore abort errors + if (err instanceof Error && err.name === 'AbortError') return; setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setRestoring(null); @@ -81,6 +100,14 @@ export default function BackupsSection() { fetchBackups(); }, [fetchBackups]); + // Cleanup: abort pending requests on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + restoreAbortControllerRef.current?.abort(); + }; + }, []); + // Clear success after timeout useEffect(() => { if (success) { diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 274ba607..ab9d8ef5 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -60,6 +60,13 @@ export default function ProxySection() { } }; + // Log when debug mode changes + useEffect(() => { + if (debugMode) { + console.log('[CCS Debug] Debug mode enabled - proxy config:', config); + } + }, [debugMode, config]); + // Load data on mount useEffect(() => { fetchConfig(); @@ -302,7 +309,7 @@ export default function ProxySection() {

Debug Mode

- Show verbose logging and diagnostic info (--verbose) + Enable developer diagnostics in browser console