mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 20:17:24 +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 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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user