mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 00:24:08 +00:00
feat(dashboard): implement full parity UX improvements
- Remove 'agy' hardcode from quota hook (multi-provider support) - Add quota N/A badge with AlertCircle icon for fetch failures - Add new 'backups' tab to Settings page with Archive icon - Create BackupsSection component with list/restore UI - Add persist backend routes (GET /api/persist/backups, POST /api/persist/restore) - Add debug mode toggle in Settings proxy section (localStorage persisted) Closes documentation gap for persist command dashboard parity.
This commit is contained in:
@@ -22,6 +22,7 @@ import copilotRoutes from './copilot-routes';
|
|||||||
import miscRoutes from './misc-routes';
|
import miscRoutes from './misc-routes';
|
||||||
import cliproxyServerRoutes from './proxy-routes';
|
import cliproxyServerRoutes from './proxy-routes';
|
||||||
import authRoutes from './auth-routes';
|
import authRoutes from './auth-routes';
|
||||||
|
import persistRoutes from './persist-routes';
|
||||||
|
|
||||||
// Create the main API router
|
// Create the main API router
|
||||||
export const apiRoutes = Router();
|
export const apiRoutes = Router();
|
||||||
@@ -42,6 +43,9 @@ apiRoutes.use('/health', healthRoutes);
|
|||||||
// ==================== Dashboard Auth ====================
|
// ==================== Dashboard Auth ====================
|
||||||
apiRoutes.use('/auth', authRoutes);
|
apiRoutes.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
// ==================== Persist (Backup Management) ====================
|
||||||
|
apiRoutes.use('/persist', persistRoutes);
|
||||||
|
|
||||||
// ==================== CLIProxy ====================
|
// ==================== CLIProxy ====================
|
||||||
// Variants, auth, accounts, stats, status, models, error logs
|
// Variants, auth, accounts, stats, status, models, error logs
|
||||||
apiRoutes.use('/cliproxy', variantRoutes);
|
apiRoutes.use('/cliproxy', variantRoutes);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
@@ -95,13 +96,13 @@ export function AccountItem({
|
|||||||
showQuota,
|
showQuota,
|
||||||
}: AccountItemProps) {
|
}: AccountItemProps) {
|
||||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
// 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(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
account.provider,
|
account.provider,
|
||||||
account.id,
|
account.id,
|
||||||
showQuota && account.provider === 'agy'
|
showQuota
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get last used time from runtime stats (more accurate than file)
|
// Get last used time from runtime stats (more accurate than file)
|
||||||
@@ -217,8 +218,8 @@ export function AccountItem({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quota bar - only for 'agy' provider */}
|
{/* Quota bar - supports all providers with quota API */}
|
||||||
{showQuota && account.provider === 'agy' && (
|
{showQuota && (
|
||||||
<div className="pl-11">
|
<div className="pl-11">
|
||||||
{quotaLoading ? (
|
{quotaLoading ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
@@ -285,8 +286,25 @@ export function AccountItem({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : quota?.error ? (
|
) : quota?.error || (quota && !quota.success) ? (
|
||||||
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
N/A
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p className="text-xs">{quota?.error || 'Quota information unavailable'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -218,13 +218,13 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise<Q
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get account quota
|
* Hook to get account quota
|
||||||
* Only enabled for 'agy' provider (Antigravity) as it's the only one supporting quota
|
* Supports all providers that have quota API implemented
|
||||||
*/
|
*/
|
||||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['account-quota', provider, accountId],
|
queryKey: ['account-quota', provider, accountId],
|
||||||
queryFn: () => fetchAccountQuota(provider, accountId),
|
queryFn: () => fetchAccountQuota(provider, accountId),
|
||||||
enabled: enabled && provider === 'agy' && !!accountId,
|
enabled: enabled && !!accountId,
|
||||||
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
|
staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime)
|
||||||
refetchInterval: 60000, // Refresh every 1 minute
|
refetchInterval: 60000, // Refresh every 1 minute
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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';
|
import type { SettingsTab } from '../types';
|
||||||
|
|
||||||
interface TabNavigationProps {
|
interface TabNavigationProps {
|
||||||
@@ -32,6 +32,10 @@ export function TabNavigation({ activeTab, onTabChange }: TabNavigationProps) {
|
|||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
Auth
|
Auth
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="backups" className="flex-1 gap-2">
|
||||||
|
<Archive className="w-4 h-4" />
|
||||||
|
Backups
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const WebSearchSection = lazy(() => import('./sections/websearch'));
|
|||||||
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
const GlobalEnvSection = lazy(() => import('./sections/globalenv-section'));
|
||||||
const ProxySection = lazy(() => import('./sections/proxy'));
|
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'));
|
||||||
|
|
||||||
// Inner component that uses context
|
// Inner component that uses context
|
||||||
function SettingsPageInner() {
|
function SettingsPageInner() {
|
||||||
@@ -59,6 +60,7 @@ function SettingsPageInner() {
|
|||||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||||
{activeTab === 'proxy' && <ProxySection />}
|
{activeTab === 'proxy' && <ProxySection />}
|
||||||
{activeTab === 'auth' && <AuthSection />}
|
{activeTab === 'auth' && <AuthSection />}
|
||||||
|
{activeTab === 'backups' && <BackupsSection />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -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<Backup[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [restoring, setRestoring] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-6 w-40" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Toast-style alerts */}
|
||||||
|
<div
|
||||||
|
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
|
||||||
|
error || success
|
||||||
|
? 'opacity-100 translate-y-0'
|
||||||
|
: 'opacity-0 -translate-y-2 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="py-2 shadow-lg">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-sm font-medium">{success}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Archive className="w-5 h-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">Settings Backups</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Restore previous versions of your config.yaml file. Backups are created automatically
|
||||||
|
when settings are modified.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Backups List */}
|
||||||
|
{backups.length === 0 ? (
|
||||||
|
<Card className="p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<Archive className="w-12 h-12 mx-auto mb-3 opacity-30 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No backups available</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Backups will appear here when you modify settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{backups.map((backup, index) => (
|
||||||
|
<Card key={backup.timestamp} className="p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<p className="text-sm font-medium font-mono">{backup.timestamp}</p>
|
||||||
|
{index === 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{backup.date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => restoreBackup(backup.timestamp)}
|
||||||
|
disabled={restoring !== null}
|
||||||
|
className="gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
<RotateCcw
|
||||||
|
className={`w-4 h-4 ${restoring === backup.timestamp ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{restoring === backup.timestamp ? 'Restoring...' : 'Restore'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-4 border-t bg-background">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
fetchBackups();
|
||||||
|
fetchRawConfig();
|
||||||
|
}}
|
||||||
|
disabled={loading || restoring !== null}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,16 +3,19 @@
|
|||||||
* Settings section for CLIProxyAPI configuration (local/remote)
|
* Settings section for CLIProxyAPI configuration (local/remote)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } 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';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { useProxyConfig, useRawConfig } from '../../hooks';
|
||||||
import { LocalProxyCard } from './local-proxy-card';
|
import { LocalProxyCard } from './local-proxy-card';
|
||||||
import { RemoteProxyCard } from './remote-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() {
|
export default function ProxySection() {
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
@@ -39,6 +42,24 @@ export default function ProxySection() {
|
|||||||
|
|
||||||
const { fetchRawConfig } = useRawConfig();
|
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
|
// Load data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
@@ -269,6 +290,35 @@ export default function ProxySection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Settings */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-base font-medium flex items-center gap-2">
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
Advanced
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 p-4 rounded-lg border bg-muted/30">
|
||||||
|
{/* Debug Mode Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Debug Mode</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Show verbose logging and diagnostic info (--verbose)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={debugMode}
|
||||||
|
onCheckedChange={handleDebugModeChange}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{debugMode && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 pl-0.5">
|
||||||
|
Debug mode enabled. Check browser console for detailed logs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Local Proxy Settings - Only show in Local mode */}
|
{/* Local Proxy Settings - Only show in Local mode */}
|
||||||
{!isRemoteMode && (
|
{!isRemoteMode && (
|
||||||
<LocalProxyCard
|
<LocalProxyCard
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export interface GlobalEnvConfig {
|
|||||||
|
|
||||||
// === Tab Types ===
|
// === Tab Types ===
|
||||||
|
|
||||||
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth';
|
export type SettingsTab = 'websearch' | 'globalenv' | 'proxy' | 'auth' | 'backups';
|
||||||
|
|
||||||
// === Re-exports from api-client ===
|
// === Re-exports from api-client ===
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user