fix(dashboard): resolve edge cases in backup restore and settings UI

Backend:
- Add atomic restore with mutex to prevent concurrent corruption
- Implement rollback on failure for backup restore
- Add symlink security validation

Frontend:
- Add 'backups' tab to URL validation in settings hook
- Add error boundary for lazy-loaded settings sections
- Clamp progress bar values to prevent visual bugs
- Add AbortController cleanup to prevent memory leaks
- Fix misleading debug mode description and add actual console logging
This commit is contained in:
kaitranntt
2026-01-14 15:42:12 -05:00
parent bd5e9d2b78
commit 2e45447bb7
6 changed files with 161 additions and 36 deletions
+67 -20
View File
@@ -15,6 +15,9 @@ interface BackupFile {
date: Date; date: Date;
} }
/** Simple in-memory mutex for restore operations */
let restoreLock = false;
/** Get Claude settings.json path */ /** Get Claude settings.json path */
function getClaudeSettingsPath(): string { function getClaudeSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json'); 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 * Body: { timestamp?: string } - If not provided, restores latest
*/ */
router.post('/restore', (req: Request, res: Response): void => { 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 { try {
const { timestamp } = req.body; const { timestamp } = req.body;
const backups = getBackupFiles(); const backups = getBackupFiles();
@@ -107,19 +118,6 @@ router.post('/restore', (req: Request, res: Response): void => {
backup = found; 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 // Security checks
if (isSymlink(backup.path)) { if (isSymlink(backup.path)) {
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); 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; return;
} }
// Restore // Read and validate backup JSON (do this once atomically)
fs.copyFileSync(backup.path, settingsPath); 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({ // Atomic restore with rollback capability
success: true, const settingsDir = path.dirname(settingsPath);
timestamp: backup.timestamp, const tempPath = path.join(settingsDir, 'settings.json.restore-tmp');
date: backup.date.toISOString(), 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) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} finally {
restoreLock = false;
} }
}); });
@@ -41,8 +41,9 @@ import type { AccountItemProps } from './types';
* Get color class based on quota percentage * Get color class based on quota percentage
*/ */
function getQuotaColor(percentage: number): string { function getQuotaColor(percentage: number): string {
if (percentage <= 20) return 'bg-destructive'; const clamped = Math.max(0, Math.min(100, percentage));
if (percentage <= 50) return 'bg-yellow-500'; if (clamped <= 20) return 'bg-destructive';
if (clamped <= 50) return 'bg-yellow-500';
return 'bg-green-500'; return 'bg-green-500';
} }
@@ -257,7 +258,7 @@ export function AccountItem({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress <Progress
value={minQuota} value={Math.max(0, Math.min(100, minQuota))}
className="h-2 flex-1" className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuota)} indicatorClassName={getQuotaColor(minQuota)}
/> />
@@ -16,7 +16,9 @@ export function useSettingsTab() {
? 'proxy' ? 'proxy'
: tabParam === 'auth' : tabParam === 'auth'
? 'auth' ? 'auth'
: 'websearch'; : tabParam === 'backups'
? 'backups'
: 'websearch';
const setActiveTab = useCallback( const setActiveTab = useCallback(
(tab: SettingsTab) => { (tab: SettingsTab) => {
+50 -9
View File
@@ -3,10 +3,10 @@
* Main entry point with lazy-loaded sections and URL tab persistence * 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 { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Button } from '@/components/ui/button'; 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 { CodeEditor } from '@/components/shared/code-editor';
import { SettingsProvider } from './context'; import { SettingsProvider } from './context';
import { useSettingsTab, useRawConfig } from './hooks'; import { useSettingsTab, useRawConfig } from './hooks';
@@ -21,6 +21,45 @@ const ProxySection = lazy(() => import('./sections/proxy'));
const AuthSection = lazy(() => import('./sections/auth-section')); const AuthSection = lazy(() => import('./sections/auth-section'));
const BackupsSection = lazy(() => import('./sections/backups-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 (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center p-6 max-w-md">
<AlertCircle className="w-12 h-12 mx-auto mb-4 text-destructive" />
<p className="font-medium text-foreground mb-2">Failed to load section</p>
<p className="text-sm mb-4">{this.state.error?.message || 'Unknown error occurred'}</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="inline-flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Reload page
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
// Inner component that uses context // Inner component that uses context
function SettingsPageInner() { function SettingsPageInner() {
const { activeTab, setActiveTab } = useSettingsTab(); const { activeTab, setActiveTab } = useSettingsTab();
@@ -55,13 +94,15 @@ function SettingsPageInner() {
</div> </div>
{/* Tab Content */} {/* Tab Content */}
<Suspense fallback={<SectionSkeleton />}> <SectionErrorBoundary>
{activeTab === 'websearch' && <WebSearchSection />} <Suspense fallback={<SectionSkeleton />}>
{activeTab === 'globalenv' && <GlobalEnvSection />} {activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'proxy' && <ProxySection />} {activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'auth' && <AuthSection />} {activeTab === 'proxy' && <ProxySection />}
{activeTab === 'backups' && <BackupsSection />} {activeTab === 'auth' && <AuthSection />}
</Suspense> {activeTab === 'backups' && <BackupsSection />}
</Suspense>
</SectionErrorBoundary>
</div> </div>
</Panel> </Panel>
@@ -3,7 +3,7 @@
* Settings section for managing config.yaml backups (list and restore) * 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 { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -25,6 +25,10 @@ interface BackupsResponse {
export default function BackupsSection() { export default function BackupsSection() {
const { fetchRawConfig } = useRawConfig(); const { fetchRawConfig } = useRawConfig();
// AbortController refs for cleanup
const abortControllerRef = useRef<AbortController | null>(null);
const restoreAbortControllerRef = useRef<AbortController | null>(null);
// State // State
const [backups, setBackups] = useState<Backup[]>([]); const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -34,16 +38,24 @@ export default function BackupsSection() {
// Fetch backups // Fetch backups
const fetchBackups = useCallback(async () => { const fetchBackups = useCallback(async () => {
// Abort previous request
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await fetch('/api/persist/backups'); const response = await fetch('/api/persist/backups', {
signal: abortControllerRef.current.signal,
});
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch backups'); throw new Error('Failed to fetch backups');
} }
const data: BackupsResponse = await response.json(); const data: BackupsResponse = await response.json();
setBackups(data.backups || []); setBackups(data.backups || []);
} catch (err) { } catch (err) {
// Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -52,6 +64,10 @@ export default function BackupsSection() {
// Restore backup // Restore backup
const restoreBackup = async (timestamp: string) => { const restoreBackup = async (timestamp: string) => {
// Abort previous restore request
restoreAbortControllerRef.current?.abort();
restoreAbortControllerRef.current = new AbortController();
try { try {
setRestoring(timestamp); setRestoring(timestamp);
setError(null); setError(null);
@@ -59,6 +75,7 @@ export default function BackupsSection() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timestamp }), body: JSON.stringify({ timestamp }),
signal: restoreAbortControllerRef.current.signal,
}); });
if (!response.ok) { if (!response.ok) {
@@ -70,6 +87,8 @@ export default function BackupsSection() {
await fetchBackups(); await fetchBackups();
await fetchRawConfig(); await fetchRawConfig();
} catch (err) { } catch (err) {
// Ignore abort errors
if (err instanceof Error && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Unknown error'); setError(err instanceof Error ? err.message : 'Unknown error');
} finally { } finally {
setRestoring(null); setRestoring(null);
@@ -81,6 +100,14 @@ export default function BackupsSection() {
fetchBackups(); fetchBackups();
}, [fetchBackups]); }, [fetchBackups]);
// Cleanup: abort pending requests on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
restoreAbortControllerRef.current?.abort();
};
}, []);
// Clear success after timeout // Clear success after timeout
useEffect(() => { useEffect(() => {
if (success) { if (success) {
@@ -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 // Load data on mount
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
@@ -302,7 +309,7 @@ export default function ProxySection() {
<div> <div>
<p className="font-medium text-sm">Debug Mode</p> <p className="font-medium text-sm">Debug Mode</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Show verbose logging and diagnostic info (--verbose) Enable developer diagnostics in browser console
</p> </p>
</div> </div>
<Switch <Switch