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:
kaitranntt
2026-01-14 13:52:54 -05:00
parent 01fe780514
commit bd5e9d2b78
9 changed files with 476 additions and 13 deletions
+4
View File
@@ -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);
+148
View File
@@ -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;