diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 4dc44164..8761df91 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -22,6 +22,7 @@ import copilotRoutes from './copilot-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; +import persistRoutes from './persist-routes'; // Create the main API router export const apiRoutes = Router(); @@ -42,6 +43,9 @@ apiRoutes.use('/health', healthRoutes); // ==================== Dashboard Auth ==================== apiRoutes.use('/auth', authRoutes); +// ==================== Persist (Backup Management) ==================== +apiRoutes.use('/persist', persistRoutes); + // ==================== CLIProxy ==================== // Variants, auth, accounts, stats, status, models, error logs apiRoutes.use('/cliproxy', variantRoutes); diff --git a/src/web-server/routes/persist-routes.ts b/src/web-server/routes/persist-routes.ts new file mode 100644 index 00000000..576fbb0f --- /dev/null +++ b/src/web-server/routes/persist-routes.ts @@ -0,0 +1,254 @@ +/** + * Persist Routes - Backup management for ~/.claude/settings.json + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const router = Router(); + +interface BackupFile { + path: string; + timestamp: string; + date: Date; +} + +/** + * Async mutex for restore operations - prevents race conditions + * Uses a Promise queue pattern for atomic lock acquisition + */ +class RestoreMutex { + private locked = false; + private queue: Array<() => void> = []; + + async acquire(): Promise { + if (this.locked) { + // Already locked - add to queue and wait + return new Promise((resolve) => { + this.queue.push(() => resolve(false)); // Return false = was queued, reject + }); + } + this.locked = true; + return true; + } + + release(): void { + const next = this.queue.shift(); + if (next) { + next(); // Signal queued request to fail + } else { + this.locked = false; + } + } +} + +const restoreMutex = new RestoreMutex(); + +/** Get Claude settings.json path */ +function getClaudeSettingsPath(): string { + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +/** Check if path is a symlink (security check) */ +function isSymlink(filePath: string): boolean { + try { + const stats = fs.lstatSync(filePath); + return stats.isSymbolicLink(); + } catch { + return false; + } +} + +/** Get all backup files sorted by date (newest first) */ +function getBackupFiles(): BackupFile[] { + const settingsPath = getClaudeSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + return []; + } + const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; + const files = fs + .readdirSync(dir) + .filter((f) => backupPattern.test(f)) + .map((f) => { + const match = f.match(backupPattern); + if (!match) return null; + const timestamp = match[1]; + const year = parseInt(timestamp.slice(0, 4)); + const month = parseInt(timestamp.slice(4, 6)) - 1; + const day = parseInt(timestamp.slice(6, 8)); + const hour = parseInt(timestamp.slice(9, 11)); + const min = parseInt(timestamp.slice(11, 13)); + const sec = parseInt(timestamp.slice(13, 15)); + return { + path: path.join(dir, f), + timestamp, + date: new Date(year, month, day, hour, min, sec), + }; + }) + .filter((f): f is BackupFile => f !== null) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + return files; +} + +/** + * GET /api/persist/backups - List available backups + */ +router.get('/backups', (_req: Request, res: Response): void => { + try { + const backups = getBackupFiles(); + res.json({ + backups: backups.map((b, i) => ({ + timestamp: b.timestamp, + date: b.date.toISOString(), + isLatest: i === 0, + })), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/persist/restore - Restore from a backup + * Body: { timestamp?: string } - If not provided, restores latest + */ +router.post('/restore', async (req: Request, res: Response): Promise => { + // Atomic mutex acquisition - prevents race conditions + const acquired = await restoreMutex.acquire(); + if (!acquired) { + res.status(409).json({ error: 'Restore already in progress' }); + return; + } + + try { + const { timestamp } = req.body; + const backups = getBackupFiles(); + + if (backups.length === 0) { + res.status(404).json({ error: 'No backups found' }); + return; + } + + // Find backup + let backup: BackupFile; + if (!timestamp) { + backup = backups[0]; // Latest + } else { + const found = backups.find((b) => b.timestamp === timestamp); + if (!found) { + res.status(404).json({ error: `Backup not found: ${timestamp}` }); + return; + } + backup = found; + } + + // Security: reject symlinks to prevent path traversal attacks + if (isSymlink(backup.path)) { + res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); + return; + } + + const settingsPath = getClaudeSettingsPath(); + if (isSymlink(settingsPath)) { + res.status(400).json({ error: 'settings.json is a symlink - refusing for security' }); + return; + } + + // Read backup content securely using file descriptor to prevent TOCTOU + // Open with O_NOFOLLOW equivalent check then read atomically + let backupContent: string; + let fd: number | undefined; + try { + // Verify not symlink immediately before open + const stats = fs.lstatSync(backup.path); + if (stats.isSymbolicLink()) { + res + .status(400) + .json({ error: 'Backup became symlink during read - refusing for security' }); + return; + } + // Open file descriptor for atomic read + fd = fs.openSync(backup.path, 'r'); + const buffer = Buffer.alloc(stats.size); + fs.readSync(fd, buffer, 0, stats.size, 0); + backupContent = buffer.toString('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 (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + res.status(404).json({ error: 'Backup was deleted during restore' }); + return; + } + res.status(400).json({ error: 'Backup file is corrupted or invalid JSON' }); + return; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors + } + } + } + + // Atomic restore with rollback capability + const settingsDir = path.dirname(settingsPath); + const tempPath = path.join(settingsDir, 'settings.json.restore-tmp'); + const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp'); + + try { + // Step 1: Backup current settings for rollback + if (fs.existsSync(settingsPath)) { + fs.copyFileSync(settingsPath, rollbackPath); + } + + // 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(rollbackPath)) { + fs.unlinkSync(rollbackPath); + } + + res.json({ + success: true, + timestamp: backup.timestamp, + date: backup.date.toISOString(), + }); + } catch (error) { + // Rollback on failure + try { + if (fs.existsSync(rollbackPath)) { + fs.renameSync(rollbackPath, settingsPath); + } + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } catch (rollbackErr) { + console.error('[persist-routes] Rollback failed:', rollbackErr); + res.status(500).json({ + error: 'Restore failed and rollback unsuccessful - manual recovery may be needed', + }); + return; + } + throw error; + } + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } finally { + restoreMutex.release(); + } +}); + +export default router; diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 8c6ac2f0..031a01b3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -24,6 +24,7 @@ import { HelpCircle, Pause, Play, + AlertCircle, } from 'lucide-react'; import { cn, @@ -40,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'; } @@ -95,13 +97,13 @@ export function AccountItem({ showQuota, }: AccountItemProps) { // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) - const { data: stats } = useCliproxyStats(showQuota && account.provider === 'agy'); + const { data: stats } = useCliproxyStats(showQuota); - // Fetch quota for 'agy' provider accounts + // Fetch quota for all provider accounts const { data: quota, isLoading: quotaLoading } = useAccountQuota( account.provider, account.id, - showQuota && account.provider === 'agy' + showQuota ); // Get last used time from runtime stats (more accurate than file) @@ -217,8 +219,8 @@ export function AccountItem({ - {/* Quota bar - only for 'agy' provider */} - {showQuota && account.provider === 'agy' && ( + {/* Quota bar - supports all providers with quota API */} + {showQuota && (
{quotaLoading ? (
@@ -256,7 +258,7 @@ export function AccountItem({
@@ -285,8 +287,25 @@ export function AccountItem({
- ) : quota?.error ? ( -
{quota.error}
+ ) : quota?.error || (quota && !quota.success) ? ( + + + +
+ + + N/A + +
+
+ +

{quota?.error || 'Quota information unavailable'}

+
+
+
) : null}
)} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index cc9cf222..0bc54a0c 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -218,13 +218,13 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise fetchAccountQuota(provider, accountId), - enabled: enabled && provider === 'agy' && !!accountId, + enabled: enabled && !!accountId, staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime) refetchInterval: 60000, // Refresh every 1 minute retry: 1, diff --git a/ui/src/pages/settings/components/tab-navigation.tsx b/ui/src/pages/settings/components/tab-navigation.tsx index a318d81d..5f0eabba 100644 --- a/ui/src/pages/settings/components/tab-navigation.tsx +++ b/ui/src/pages/settings/components/tab-navigation.tsx @@ -4,7 +4,7 @@ */ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Globe, Settings2, Server, KeyRound } from 'lucide-react'; +import { Globe, Settings2, Server, KeyRound, Archive } from 'lucide-react'; import type { SettingsTab } from '../types'; interface TabNavigationProps { @@ -32,6 +32,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) { Auth + + + Backups + ); diff --git a/ui/src/pages/settings/hooks/use-settings-tab.ts b/ui/src/pages/settings/hooks/use-settings-tab.ts index 9ec10395..8644d45a 100644 --- a/ui/src/pages/settings/hooks/use-settings-tab.ts +++ b/ui/src/pages/settings/hooks/use-settings-tab.ts @@ -8,7 +8,8 @@ import type { SettingsTab } from '../types'; export function useSettingsTab() { const [searchParams, setSearchParams] = useSearchParams(); - const tabParam = searchParams.get('tab'); + // Normalize to lowercase for case-insensitive matching (fixes ?tab=Backups vs ?tab=backups) + const tabParam = searchParams.get('tab')?.toLowerCase(); const activeTab: SettingsTab = tabParam === 'globalenv' ? 'globalenv' @@ -16,7 +17,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 ce86a63c..79806c9c 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -3,10 +3,18 @@ * 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, + type ComponentType, +} 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'; @@ -14,11 +22,73 @@ import { TabNavigation } from './components/tab-navigation'; import { SectionSkeleton } from './components/section-skeleton'; import type { SettingsTab } from './types'; -// Lazy-loaded sections -const WebSearchSection = lazy(() => import('./sections/websearch')); -const GlobalEnvSection = lazy(() => import('./sections/globalenv-section')); -const ProxySection = lazy(() => import('./sections/proxy')); -const AuthSection = lazy(() => import('./sections/auth-section')); +/** + * Retry wrapper for dynamic imports with exponential backoff + * Handles temporary network failures gracefully + */ +function retryImport>( + importFn: () => Promise<{ default: T }>, + retries = 3, + delay = 1000 +): Promise<{ default: T }> { + return importFn().catch((error: Error) => { + if (retries <= 0) throw error; + return new Promise((resolve) => setTimeout(resolve, delay)).then(() => + retryImport(importFn, retries - 1, delay * 2) + ); + }); +} + +/** Lazy load with automatic retry on failure */ +function lazyWithRetry>(importFn: () => Promise<{ default: T }>) { + return lazy(() => retryImport(importFn)); +} + +// Lazy-loaded sections with retry capability +const WebSearchSection = lazyWithRetry(() => import('./sections/websearch')); +const GlobalEnvSection = lazyWithRetry(() => import('./sections/globalenv-section')); +const ProxySection = lazyWithRetry(() => import('./sections/proxy')); +const AuthSection = lazyWithRetry(() => import('./sections/auth-section')); +const BackupsSection = lazyWithRetry(() => 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() { @@ -54,12 +124,15 @@ function SettingsPageInner() {
{/* Tab Content */} - }> - {activeTab === 'websearch' && } - {activeTab === 'globalenv' && } - {activeTab === 'proxy' && } - {activeTab === 'auth' && } - + + }> + {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 new file mode 100644 index 00000000..7a484e4a --- /dev/null +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -0,0 +1,307 @@ +/** + * Backups Section + * Settings section for managing settings.json backups (list and restore) + */ + +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'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { RefreshCw, CheckCircle2, AlertCircle, RotateCcw, Clock, Archive } from 'lucide-react'; +import { useRawConfig } from '../hooks'; + +interface Backup { + timestamp: string; + date: string; +} + +interface BackupsResponse { + backups: Backup[]; +} + +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); + const [restoring, setRestoring] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [confirmRestore, setConfirmRestore] = useState(null); // Confirmation dialog state + + // 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', { + 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); + } + }, []); + + // Restore backup (wrapped in useCallback for callback stability) + const restoreBackup = useCallback( + async (timestamp: string) => { + // Abort previous restore request + restoreAbortControllerRef.current?.abort(); + restoreAbortControllerRef.current = new AbortController(); + + try { + setRestoring(timestamp); + setError(null); + const response = await fetch('/api/persist/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timestamp }), + signal: restoreAbortControllerRef.current.signal, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to restore backup'); + } + + setSuccess('Backup restored successfully'); + 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); + } + }, + [fetchBackups, fetchRawConfig] + ); + + // Load on mount + useEffect(() => { + fetchBackups(); + }, [fetchBackups]); + + // Cleanup: abort pending requests on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + restoreAbortControllerRef.current?.abort(); + }; + }, []); + + // Clear success after timeout + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(null), 3000); + return () => clearTimeout(timer); + } + }, [success]); + + // Clear error after timeout + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 5000); + return () => clearTimeout(timer); + } + }, [error]); + + // Loading skeleton + if (loading) { + return ( + <> + +
+
+ + +
+ {[1, 2, 3].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+
+ +
+ + ); + } + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + {success} +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Header */} +
+
+ +

Settings Backups

+
+

+ Restore previous versions of your settings.json file. Backups are created + automatically when settings are modified. +

+
+ + {/* Backups List */} + {backups.length === 0 ? ( + +
+ +

No backups available

+

+ Backups will appear here when you modify settings +

+
+
+ ) : ( +
+ {backups.map((backup, index) => ( + +
+
+ +
+
+

{backup.timestamp}

+ {index === 0 && ( + + Latest + + )} +
+

{backup.date}

+
+
+ +
+
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ +
+ + {/* Restore Confirmation Dialog */} + setConfirmRestore(null)}> + + + Restore Backup? + + This will replace your current settings with backup from{' '} + + {confirmRestore} + + . This action cannot be undone. + + + + Cancel + { + if (confirmRestore) { + restoreBackup(confirmRestore); + } + setConfirmRestore(null); + }} + > + Restore + + + + + + ); +} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index a9807ec8..76dfe445 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -3,16 +3,19 @@ * Settings section for CLIProxyAPI configuration (local/remote) */ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud } from 'lucide-react'; +import { RefreshCw, CheckCircle2, AlertCircle, Laptop, Cloud, Bug } from 'lucide-react'; import { useProxyConfig, useRawConfig } from '../../hooks'; import { LocalProxyCard } from './local-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card'; +/** LocalStorage key for debug mode preference */ +const DEBUG_MODE_KEY = 'ccs_debug_mode'; + export default function ProxySection() { const { config, @@ -39,6 +42,40 @@ export default function ProxySection() { const { fetchRawConfig } = useRawConfig(); + // Debug mode state (persisted in localStorage) + const [debugMode, setDebugMode] = useState(() => { + try { + return localStorage.getItem(DEBUG_MODE_KEY) === 'true'; + } catch { + return false; + } + }); + + const handleDebugModeChange = (enabled: boolean) => { + setDebugMode(enabled); + try { + localStorage.setItem(DEBUG_MODE_KEY, String(enabled)); + } catch { + // Ignore storage errors + } + }; + + // Log when debug mode changes (sanitize sensitive fields) + useEffect(() => { + if (debugMode && config) { + // Sanitize config before logging to prevent credential exposure + const sanitizedConfig = { + ...config, + remote: { + ...config.remote, + auth_token: config.remote.auth_token ? '[REDACTED]' : undefined, + management_key: config.remote.management_key ? '[REDACTED]' : undefined, + }, + }; + console.log('[CCS Debug] Debug mode enabled - proxy config:', sanitizedConfig); + } + }, [debugMode, config]); + // Load data on mount useEffect(() => { fetchConfig(); @@ -269,6 +306,35 @@ export default function ProxySection() { + {/* Advanced Settings */} +
+

+ + Advanced +

+
+ {/* Debug Mode Toggle */} +
+
+

Debug Mode

+

+ Enable developer diagnostics in browser console +

+
+ +
+ {debugMode && ( +

+ Debug mode enabled. Check browser console for detailed logs. +

+ )} +
+
+ {/* Local Proxy Settings - Only show in Local mode */} {!isRemoteMode && (