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..80f1b915 --- /dev/null +++ b/src/web-server/routes/persist-routes.ts @@ -0,0 +1,148 @@ +/** + * 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; +} + +/** 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', (req: Request, res: Response): void => { + 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; + } + + // 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' }); + return; + } + + const settingsPath = getClaudeSettingsPath(); + if (isSymlink(settingsPath)) { + res.status(400).json({ error: 'settings.json is a symlink - refusing for security' }); + return; + } + + // Restore + fs.copyFileSync(backup.path, settingsPath); + + res.json({ + success: true, + timestamp: backup.timestamp, + date: backup.date.toISOString(), + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +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..dd4a1989 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, @@ -95,13 +96,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 +218,8 @@ export function AccountItem({ - {/* Quota bar - only for 'agy' provider */} - {showQuota && account.provider === 'agy' && ( + {/* Quota bar - supports all providers with quota API */} + {showQuota && (
{quota?.error || 'Quota information unavailable'}
+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 + + ); diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index ce86a63c..e395db9c 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -19,6 +19,7 @@ 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')); +const BackupsSection = lazy(() => import('./sections/backups-section')); // Inner component that uses context function SettingsPageInner() { @@ -59,6 +60,7 @@ function SettingsPageInner() { {activeTab === 'globalenv' &&+ Backups + } {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..8d0ee98e --- /dev/null +++ b/ui/src/pages/settings/sections/backups-section.tsx @@ -0,0 +1,237 @@ +/** + * Backups Section + * Settings section for managing config.yaml backups (list and restore) + */ + +import { useEffect, useState, useCallback } 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 { 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(); + + // 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); + + // Fetch backups + const fetchBackups = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetch('/api/persist/backups'); + if (!response.ok) { + throw new Error('Failed to fetch backups'); + } + const data: BackupsResponse = await response.json(); + setBackups(data.backups || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + // Restore backup + const restoreBackup = async (timestamp: string) => { + try { + setRestoring(timestamp); + setError(null); + const response = await fetch('/api/persist/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ timestamp }), + }); + + 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) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setRestoring(null); + } + }; + + // Load on mount + useEffect(() => { + fetchBackups(); + }, [fetchBackups]); + + // 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 && ( ++ + {/* Scrollable Content */} ++ + )} + {success && ( ++ {error} +++ )} ++ {success} + + + + {/* Footer */} ++ {/* Header */} ++++ + {/* Backups List */} + {backups.length === 0 ? ( ++++ Settings Backups
++ Restore previous versions of your config.yaml file. Backups are created automatically + when settings are modified. +
++ + ) : ( ++++ No backups available
++ Backups will appear here when you modify settings +
++ {backups.map((backup, index) => ( ++ )} ++ + ))} +++++ ++ ++++{backup.timestamp}
+ {index === 0 && ( ++ Latest + + )} +{backup.date}
++ ++ > + ); +} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index a9807ec8..274ba607 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,24 @@ 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 + } + }; + // Load data on mount useEffect(() => { fetchConfig(); @@ -269,6 +290,35 @@ export default function ProxySection() { + {/* Advanced Settings */} +++ {/* Local Proxy Settings - Only show in Local mode */} {!isRemoteMode && (+
++ Advanced + + {/* Debug Mode Toggle */} ++++ {debugMode && ( +++Debug Mode
++ Show verbose logging and diagnostic info (--verbose) +
++ + Debug mode enabled. Check browser console for detailed logs. +
+ )} +