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;
}
/** 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;
}
});
@@ -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({
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Progress
value={minQuota}
value={Math.max(0, Math.min(100, minQuota))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuota)}
/>
@@ -16,7 +16,9 @@ export function useSettingsTab() {
? 'proxy'
: tabParam === 'auth'
? 'auth'
: 'websearch';
: tabParam === 'backups'
? 'backups'
: 'websearch';
const setActiveTab = useCallback(
(tab: SettingsTab) => {
+50 -9
View File
@@ -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 (
<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
function SettingsPageInner() {
const { activeTab, setActiveTab } = useSettingsTab();
@@ -55,13 +94,15 @@ function SettingsPageInner() {
</div>
{/* Tab Content */}
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'proxy' && <ProxySection />}
{activeTab === 'auth' && <AuthSection />}
{activeTab === 'backups' && <BackupsSection />}
</Suspense>
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'proxy' && <ProxySection />}
{activeTab === 'auth' && <AuthSection />}
{activeTab === 'backups' && <BackupsSection />}
</Suspense>
</SectionErrorBoundary>
</div>
</Panel>
@@ -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<AbortController | null>(null);
const restoreAbortControllerRef = useRef<AbortController | null>(null);
// State
const [backups, setBackups] = useState<Backup[]>([]);
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) {
@@ -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() {
<div>
<p className="font-medium text-sm">Debug Mode</p>
<p className="text-xs text-muted-foreground">
Show verbose logging and diagnostic info (--verbose)
Enable developer diagnostics in browser console
</p>
</div>
<Switch