mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user