From 8aae0db7da9e691e9a35d222d6828d6e658c49c4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 15:00:07 -0500 Subject: [PATCH] feat(ui): redesign health dashboard to match ccs doctor output - Rewrite health-service.ts with 20+ comprehensive checks in 5 groups (System, Configuration, Profiles & Delegation, System Health, CLIProxy) - Add async support for port checking functionality - Create new health-check-item.tsx component with collapsible design - Redesign health.tsx with professional grouped layout, hero section, summary stats, and issues panel with actionable fix commands - Update use-health.ts with HealthGroup type support - Add development server documentation to CLAUDE.md --- CLAUDE.md | 6 + README.md | 3 +- src/web-server/health-service.ts | 679 +++++++++++++++++++++--- src/web-server/overview-routes.ts | 4 +- src/web-server/routes.ts | 4 +- ui/src/components/health-check-item.tsx | 188 +++++++ ui/src/hooks/use-health.ts | 15 +- ui/src/pages/health.tsx | 357 +++++++++++-- 8 files changed, 1148 insertions(+), 108 deletions(-) create mode 100644 ui/src/components/health-check-item.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 462bee76..cc88bcd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,12 @@ bun run format # Auto-fix formatting - `lib/` - Native shell scripts (bash, PowerShell) - `ui/` - React dashboard (Vite + React 19 + shadcn/ui) +**Development server (ALWAYS use for testing UI changes):** +```bash +bun run dev # Start dev server with hot reload (http://localhost:3000) +``` +**IMPORTANT:** Use `bun run dev` at CCS root level for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed (outdated) version. + ## UI Quality Gates (React Dashboard) **The ui/ directory has IDENTICAL quality gates to the main project.** diff --git a/README.md b/README.md index a0973777..290bea66 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ Features a modern React 19 dashboard with real-time updates. [![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=for-the-badge)]() +[![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) + [![npm](https://img.shields.io/npm/v/@kaitranntt/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kaitranntt/ccs) [![React](https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-100%25-3178C6?style=for-the-badge&logo=typescript)](https://www.typescriptlang.org/) -[![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) **Languages**: [English](README.md) | [Tiếng Việt](docs/vi/README.md) | [日本語](docs/ja/README.md) diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index d2f4ab7d..469d0539 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -1,102 +1,219 @@ /** * Health Check Service (Phase 06) * - * Runs health checks for CCS dashboard: Claude CLI, config files, CLIProxy binary. + * Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output. + * Groups: System, Configuration, Profiles & Delegation, System Health, CLIProxy */ import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import { execSync } from 'child_process'; import { getCcsDir, getConfigPath } from '../utils/config-manager'; -import { isCLIProxyInstalled, getInstalledCliproxyVersion, getCLIProxyPath } from '../cliproxy'; +import { + isCLIProxyInstalled, + getInstalledCliproxyVersion, + getCLIProxyPath, + getConfigPath as getCliproxyConfigPath, + getAllAuthStatus, + CLIPROXY_DEFAULT_PORT, +} from '../cliproxy'; +import { getClaudeCliInfo } from '../utils/claude-detector'; +import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import packageJson from '../../package.json'; export interface HealthCheck { id: string; name: string; - status: 'ok' | 'warning' | 'error'; + status: 'ok' | 'warning' | 'error' | 'info'; message: string; details?: string; + fix?: string; fixable?: boolean; } +export interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + export interface HealthReport { timestamp: number; - checks: HealthCheck[]; + version: string; + groups: HealthGroup[]; + checks: HealthCheck[]; // Flat list for backward compatibility summary: { total: number; passed: number; warnings: number; errors: number; + info: number; }; } /** * Run all health checks and return report */ -export function runHealthChecks(): HealthReport { - const checks: HealthCheck[] = []; +export async function runHealthChecks(): Promise { + const homedir = os.homedir(); + const ccsDir = getCcsDir(); + const claudeDir = path.join(homedir, '.claude'); + const version = packageJson.version; - // Check 1: Claude CLI - checks.push(checkClaudeCli()); + const groups: HealthGroup[] = []; - // Check 2: Config file - checks.push(checkConfigFile()); + // Group 1: System + const systemChecks: HealthCheck[] = []; + systemChecks.push(await checkClaudeCli()); + systemChecks.push(checkCcsDirectory(ccsDir)); + groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks }); - // Check 3: Profiles file - checks.push(checkProfilesFile()); + // Group 2: Configuration + const configChecks: HealthCheck[] = []; + configChecks.push(checkConfigFile()); + configChecks.push(...checkSettingsFiles(ccsDir)); + configChecks.push(checkClaudeSettings(claudeDir)); + groups.push({ + id: 'configuration', + name: 'Configuration', + icon: 'Settings', + checks: configChecks, + }); - // Check 4: CLIProxy binary - checks.push(checkCliproxy()); + // Group 3: Profiles & Delegation + const profileChecks: HealthCheck[] = []; + profileChecks.push(checkProfiles(ccsDir)); + profileChecks.push(checkInstances(ccsDir)); + profileChecks.push(checkDelegation(ccsDir)); + groups.push({ + id: 'profiles', + name: 'Profiles & Delegation', + icon: 'Users', + checks: profileChecks, + }); - // Check 5: CCS directory - checks.push(checkCcsDirectory()); + // Group 4: System Health + const healthChecks: HealthCheck[] = []; + healthChecks.push(checkPermissions(ccsDir)); + healthChecks.push(checkCcsSymlinks()); + healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); + groups.push({ + id: 'system-health', + name: 'System Health', + icon: 'Shield', + checks: healthChecks, + }); + + // Group 5: CLIProxy + const cliproxyChecks: HealthCheck[] = []; + cliproxyChecks.push(checkCliproxyBinary()); + cliproxyChecks.push(checkCliproxyConfig()); + cliproxyChecks.push(...checkOAuthProviders()); + cliproxyChecks.push(await checkCliproxyPort()); + groups.push({ + id: 'cliproxy', + name: 'CLIProxy (OAuth)', + icon: 'Zap', + checks: cliproxyChecks, + }); + + // Flatten all checks for backward compatibility + const allChecks = groups.flatMap((g) => g.checks); // Calculate summary const summary = { - total: checks.length, - passed: checks.filter((c) => c.status === 'ok').length, - warnings: checks.filter((c) => c.status === 'warning').length, - errors: checks.filter((c) => c.status === 'error').length, + total: allChecks.length, + passed: allChecks.filter((c) => c.status === 'ok').length, + warnings: allChecks.filter((c) => c.status === 'warning').length, + errors: allChecks.filter((c) => c.status === 'error').length, + info: allChecks.filter((c) => c.status === 'info').length, }; return { timestamp: Date.now(), - checks, + version, + groups, + checks: allChecks, summary, }; } -function checkClaudeCli(): HealthCheck { +// Check 1: Claude CLI +async function checkClaudeCli(): Promise { + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not found in PATH', + fix: 'Install: npm install -g @anthropic-ai/claude-code', + }; + } + try { const version = execSync('claude --version', { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); + + const versionMatch = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = versionMatch ? versionMatch[1] : 'unknown'; + return { id: 'claude-cli', name: 'Claude CLI', status: 'ok', - message: `Installed: ${version}`, + message: `v${versionStr}`, + details: cliInfo.path, }; } catch { return { id: 'claude-cli', name: 'Claude CLI', status: 'error', - message: 'Not found in PATH', - details: 'Install: npm install -g @anthropic-ai/claude-code', + message: 'Not working', + details: cliInfo.path, + fix: 'Reinstall Claude CLI', }; } } +// Check 2: CCS Directory +function checkCcsDirectory(ccsDir: string): HealthCheck { + if (fs.existsSync(ccsDir)) { + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'ok', + message: 'Exists', + details: '~/.ccs/', + }; + } + + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'error', + message: 'Not found', + details: ccsDir, + fix: 'Run: npm install -g @kaitranntt/ccs --force', + fixable: true, + }; +} + +// Check 3: Config file function checkConfigFile(): HealthCheck { const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'warning', message: 'Not found', details: configPath, @@ -109,15 +226,15 @@ function checkConfigFile(): HealthCheck { JSON.parse(content); return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'ok', - message: 'Valid JSON', + message: 'Valid', details: configPath, }; } catch { return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'error', message: 'Invalid JSON', details: configPath, @@ -125,84 +242,508 @@ function checkConfigFile(): HealthCheck { } } -function checkProfilesFile(): HealthCheck { - const ccsDir = getCcsDir(); - const profilesPath = path.join(ccsDir, 'profiles.json'); +// Check 4: Settings files (glm, kimi) +function checkSettingsFiles(ccsDir: string): HealthCheck[] { + const checks: HealthCheck[] = []; + const files = [ + { name: 'glm.settings.json', profile: 'glm' }, + { name: 'kimi.settings.json', profile: 'kimi' }, + ]; - if (!fs.existsSync(profilesPath)) { + const { DelegationValidator } = require('../utils/delegation-validator'); + + for (const file of files) { + const filePath = path.join(ccsDir, file.name); + + if (!fs.existsSync(filePath)) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'info', + message: 'Not configured', + details: filePath, + }); + continue; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + JSON.parse(content); + + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Key configured', + details: filePath, + }); + } else if (validation.error && validation.error.includes('placeholder')) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'warning', + message: 'Placeholder key', + details: filePath, + }); + } else { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Valid JSON', + details: filePath, + }); + } + } catch { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'error', + message: 'Invalid JSON', + details: filePath, + }); + } + } + + return checks; +} + +// Check 5: Claude settings +function checkClaudeSettings(claudeDir: string): HealthCheck { + const settingsPath = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(settingsPath)) { return { - id: 'profiles-file', - name: 'Profiles Registry', + id: 'claude-settings', + name: '~/.claude/settings.json', status: 'warning', - message: 'Not found (will be created on first account)', - details: profilesPath, - fixable: true, + message: 'Not found', + fix: 'Run: claude /login', }; } try { - const content = fs.readFileSync(profilesPath, 'utf8'); + const content = fs.readFileSync(settingsPath, 'utf8'); JSON.parse(content); return { - id: 'profiles-file', - name: 'Profiles Registry', + id: 'claude-settings', + name: '~/.claude/settings.json', status: 'ok', message: 'Valid', - details: profilesPath, }; } catch { return { - id: 'profiles-file', - name: 'Profiles Registry', - status: 'error', + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', message: 'Invalid JSON', - details: profilesPath, + fix: 'Run: claude /login', }; } } -function checkCliproxy(): HealthCheck { +// Check 6: Profiles +function checkProfiles(ccsDir: string): HealthCheck { + const configPath = path.join(ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.json not found', + }; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: 'Missing profiles object', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } catch (e) { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: (e as Error).message, + }; + } +} + +// Check 7: Instances +function checkInstances(ccsDir: string): HealthCheck { + const instancesDir = path.join(ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, + }; +} + +// Check 8: Delegation +function checkDelegation(ccsDir: string): HealthCheck { + const ccsClaudeCommandsDir = path.join(ccsDir, '.claude', 'commands'); + const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); + const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); + + if (!hasCcsCommand || !hasContinueCommand) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'Not installed', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const { DelegationValidator } = require('../utils/delegation-validator'); + const readyProfiles: string[] = []; + + for (const profile of ['glm', 'kimi']) { + const validation = DelegationValidator.validate(profile); + if (validation.valid) { + readyProfiles.push(profile); + } + } + + if (readyProfiles.length === 0) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'No profiles ready', + fix: 'Configure profiles with valid API keys', + }; + } + + return { + id: 'delegation', + name: 'Delegation', + status: 'ok', + message: `${readyProfiles.length} profiles ready`, + details: readyProfiles.join(', '), + }; +} + +// Check 9: Permissions +function checkPermissions(ccsDir: string): HealthCheck { + const testFile = path.join(ccsDir, '.permission-test'); + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + return { + id: 'permissions', + name: 'Permissions', + status: 'ok', + message: 'Write access verified', + }; + } catch { + return { + id: 'permissions', + name: 'Permissions', + status: 'error', + message: 'Cannot write to ~/.ccs/', + fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + }; + } +} + +// Check 10: CCS Symlinks +function checkCcsSymlinks(): HealthCheck { + try { + const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const itemCount = manager.ccsItems.length; + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'ok', + message: `${itemCount}/${itemCount} items linked`, + }; + } + + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: `${health.issues.length} issues found`, + fix: 'Run: ccs sync', + }; + } catch (e) { + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: 'Could not check', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +// Check 11: Settings Symlinks +function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck { + try { + const sharedDir = path.join(homedir, '.ccs', 'shared'); + const sharedSettings = path.join(sharedDir, 'settings.json'); + const claudeSettings = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(sharedSettings)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Shared not found', + fix: 'Run: ccs sync', + }; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Not a symlink', + fix: 'Run: ccs sync', + }; + } + + const sharedTarget = fs.readlinkSync(sharedSettings); + const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); + + if (resolvedShared !== claudeSettings) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Wrong target', + fix: 'Run: ccs sync', + }; + } + + // Check instances + const instancesDir = path.join(ccsDir, 'instances'); + if (!fs.existsSync(instancesDir)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: 'Shared symlink valid', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + let broken = 0; + for (const instance of instances) { + const instanceSettings = path.join(instancesDir, instance, 'settings.json'); + if (!fs.existsSync(instanceSettings)) { + broken++; + continue; + } + try { + const stats = fs.lstatSync(instanceSettings); + if (!stats.isSymbolicLink()) { + broken++; + continue; + } + const target = fs.readlinkSync(instanceSettings); + const resolved = path.resolve(path.dirname(instanceSettings), target); + if (resolved !== sharedSettings) { + broken++; + } + } catch { + broken++; + } + } + + if (broken > 0) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: `${broken} broken instance(s)`, + fix: 'Run: ccs sync', + }; + } + + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: `${instances.length} instance(s) valid`, + }; + } catch (e) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Check failed', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +// Check 12: CLIProxy Binary +function checkCliproxyBinary(): HealthCheck { if (isCLIProxyInstalled()) { const version = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); return { - id: 'cliproxy', - name: 'CLIProxy', + id: 'cliproxy-binary', + name: 'CLIProxy Binary', status: 'ok', - message: `Installed: ${version}`, + message: `v${version}`, details: binaryPath, }; } return { - id: 'cliproxy', - name: 'CLIProxy', - status: 'warning', - message: 'Not installed (optional)', - details: 'Required for gemini/codex/agy providers. Install: ccs cliproxy --latest', + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'info', + message: 'Not installed', + details: 'Downloads on first use', }; } -function checkCcsDirectory(): HealthCheck { - const ccsDir = getCcsDir(); +// Check 13: CLIProxy Config +function checkCliproxyConfig(): HealthCheck { + const configPath = getCliproxyConfigPath(); - if (!fs.existsSync(ccsDir)) { + if (fs.existsSync(configPath)) { return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'warning', - message: 'Not found', - details: ccsDir, - fixable: true, + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'ok', + message: 'cliproxy/config.yaml', }; } return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'ok', - message: 'Exists', - details: ccsDir, + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'info', + message: 'Not created', + details: 'Generated on first use', + }; +} + +// Check 14: OAuth Providers +function checkOAuthProviders(): HealthCheck[] { + const authStatuses = getAllAuthStatus(); + const checks: HealthCheck[] = []; + + for (const status of authStatuses) { + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'ok', + message: 'Authenticated', + details: lastAuth, + }); + } else { + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'info', + message: 'Not authenticated', + fix: `Run: ccs ${status.provider} --auth`, + }); + } + } + + return checks; +} + +// Check 15: CLIProxy Port +async function checkCliproxyPort(): Promise { + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'info', + message: `${CLIPROXY_DEFAULT_PORT} free`, + details: 'Proxy not running', + }; + } + + if (isCLIProxyProcess(portProcess)) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'ok', + message: 'CLIProxy running', + details: `PID ${portProcess.pid}`, + }; + } + + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'warning', + message: `Occupied by ${portProcess.processName}`, + details: `PID ${portProcess.pid}`, + fix: `Kill process: kill ${portProcess.pid}`, }; } diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index 208b4175..0ddaf1da 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -17,7 +17,7 @@ export const overviewRoutes = Router(); /** * GET /api/overview */ -overviewRoutes.get('/', (_req: Request, res: Response) => { +overviewRoutes.get('/', async (_req: Request, res: Response) => { try { const config = loadConfig(); @@ -33,7 +33,7 @@ overviewRoutes.get('/', (_req: Request, res: Response) => { const totalCliproxyCount = cliproxyVariantCount + authenticatedProviderCount; // Get quick health summary - const health = runHealthChecks(); + const health = await runHealthChecks(); res.json({ version: getVersion(), diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 26aedfdf..36e682b3 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -644,8 +644,8 @@ apiRoutes.post('/accounts/default', (req: Request, res: Response): void => { /** * GET /api/health - Run health checks */ -apiRoutes.get('/health', (_req: Request, res: Response) => { - const report = runHealthChecks(); +apiRoutes.get('/health', async (_req: Request, res: Response) => { + const report = await runHealthChecks(); res.json(report); }); diff --git a/ui/src/components/health-check-item.tsx b/ui/src/components/health-check-item.tsx new file mode 100644 index 00000000..7c69c119 --- /dev/null +++ b/ui/src/components/health-check-item.tsx @@ -0,0 +1,188 @@ +import { + CheckCircle2, + AlertTriangle, + XCircle, + Info, + Wrench, + ChevronDown, + Terminal, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { useFixHealth, type HealthCheck } from '@/hooks/use-health'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; + +const statusConfig = { + ok: { + icon: CheckCircle2, + color: 'text-green-600', + bg: 'bg-green-500/5', + border: 'border-green-500/20', + label: '[OK]', + }, + warning: { + icon: AlertTriangle, + color: 'text-yellow-500', + bg: 'bg-yellow-500/5', + border: 'border-yellow-500/20', + label: '[!]', + }, + error: { + icon: XCircle, + color: 'text-red-500', + bg: 'bg-red-500/5', + border: 'border-red-500/20', + label: '[X]', + }, + info: { + icon: Info, + color: 'text-blue-500', + bg: 'bg-blue-500/5', + border: 'border-blue-500/20', + label: '[i]', + }, +}; + +export function HealthCheckItem({ check }: { check: HealthCheck }) { + const fixMutation = useFixHealth(); + const config = statusConfig[check.status]; + const Icon = config.icon; + const [isOpen, setIsOpen] = useState(false); + + const hasExpandableContent = check.details || check.fix; + + if (!hasExpandableContent) { + return ( +
+
+ +
+
+
+

{check.name}

+ + {config.label} + +
+

{check.message}

+
+ {check.fixable && check.status !== 'ok' && ( + + )} +
+ ); + } + + return ( + +
+ + + + + +
+ {check.details && ( +
+

+ {check.details} +

+
+ )} + + {check.fix && ( +
+
+ + {check.fix} +
+ + {check.fixable && check.status !== 'ok' && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/ui/src/hooks/use-health.ts b/ui/src/hooks/use-health.ts index f25e5bbc..77e7ce92 100644 --- a/ui/src/hooks/use-health.ts +++ b/ui/src/hooks/use-health.ts @@ -4,23 +4,36 @@ import { toast } from 'sonner'; interface HealthCheck { id: string; name: string; - status: 'ok' | 'warning' | 'error'; + status: 'ok' | 'warning' | 'error' | 'info'; message: string; details?: string; + fix?: string; fixable?: boolean; } +interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + interface HealthReport { timestamp: number; + version: string; + groups: HealthGroup[]; checks: HealthCheck[]; summary: { total: number; passed: number; warnings: number; errors: number; + info: number; }; } +export type { HealthCheck, HealthGroup, HealthReport }; + export function useHealth() { return useQuery({ queryKey: ['health'], diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index d623725a..2e3bcd31 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,7 +1,183 @@ import { Button } from '@/components/ui/button'; -import { RefreshCw } from 'lucide-react'; -import { HealthCard } from '@/components/health-card'; -import { useHealth } from '@/hooks/use-health'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + RefreshCw, + CheckCircle2, + AlertTriangle, + XCircle, + Info, + Monitor, + Settings, + Users, + Shield, + Zap, + Stethoscope, + Copy, + Terminal, +} from 'lucide-react'; +import { HealthCheckItem } from '@/components/health-check-item'; +import { useHealth, type HealthGroup } from '@/hooks/use-health'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; + +const groupIcons: Record = { + Monitor, + Settings, + Users, + Shield, + Zap, +}; + +const statusConfig = { + ok: { + icon: CheckCircle2, + label: 'All Systems Operational', + color: 'text-green-600', + bg: 'bg-green-500/10', + border: 'border-green-500/20', + }, + warning: { + icon: AlertTriangle, + label: 'Some Issues Detected', + color: 'text-yellow-500', + bg: 'bg-yellow-500/10', + border: 'border-yellow-500/20', + }, + error: { + icon: XCircle, + label: 'Action Required', + color: 'text-red-500', + bg: 'bg-red-500/10', + border: 'border-red-500/20', + }, +}; + +function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { + if (summary.errors > 0) return 'error'; + if (summary.warnings > 0) return 'warning'; + return 'ok'; +} + +function HealthGroupSection({ group }: { group: HealthGroup }) { + const Icon = groupIcons[group.icon] || Monitor; + + const groupPassed = group.checks.filter((c) => c.status === 'ok').length; + const groupTotal = group.checks.length; + const hasIssues = group.checks.some((c) => c.status === 'error' || c.status === 'warning'); + + return ( + + +
+ +
+ +
+ {group.name} +
+ + {groupPassed}/{groupTotal} + +
+
+ +
+ {group.checks.map((check) => ( + + ))} +
+
+
+ ); +} + +function SummaryCard({ + label, + value, + icon: Icon, + color, +}: { + label: string; + value: number; + icon: typeof CheckCircle2; + color: string; +}) { + return ( + + +
+
+ +
+
+

{value}

+

{label}

+
+
+
+
+ ); +} + +function LoadingSkeleton() { + return ( +
+ {/* Hero Skeleton */} +
+
+ +
+ + +
+ +
+
+ + {/* Summary Skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ + {/* Groups Skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( +
+ + + + + +
+ {[1, 2, 3].map((j) => ( + + ))} +
+
+
+
+ ))} +
+
+ ); +} export function HealthPage() { const { data, isLoading, refetch, dataUpdatedAt } = useHealth(); @@ -10,47 +186,162 @@ export function HealthPage() { return new Date(timestamp).toLocaleTimeString(); }; + const copyDoctorCommand = () => { + navigator.clipboard.writeText('ccs doctor'); + toast.success('Copied to clipboard'); + }; + + if (isLoading && !data) { + return ; + } + + const overallStatus = data ? getOverallStatus(data.summary) : 'ok'; + const status = statusConfig[overallStatus]; + const StatusIcon = status.icon; + return ( -
-
-
-

Health Dashboard

- {dataUpdatedAt && ( -

Last check: {formatTime(dataUpdatedAt)}

- )} +
+ {/* Hero Section */} +
+ {/* Subtle background pattern */} +
+
- + +
+ {/* Left: Title and Status */} +
+
+ +
+
+
+

Health Check

+ {data?.version && ( + + v{data.version} + + )} +
+
+ + {status.label} +
+
+
+ + {/* Right: Actions */} +
+ + +
+
+ + {/* Last check time */} + {dataUpdatedAt && ( +

+ Last check: {formatTime(dataUpdatedAt)} +

+ )}
+ {/* Summary Stats */} {data && ( -
-
- {data.summary.passed} - passed -
-
- {data.summary.warnings} - warnings -
-
- {data.summary.errors} - errors -
+
+ + + +
)} - {isLoading && !data &&
Running health checks...
} - - {data && ( -
- {data.checks.map((check) => ( - + {/* Health Check Groups */} + {data?.groups && ( +
+ {data.groups.map((group) => ( +
+ +
))}
)} + + {/* Issues Summary */} + {data && (data.summary.errors > 0 || data.summary.warnings > 0) && ( + + + + + Issues Detected + + + +
+ {data.checks + .filter((c) => c.status === 'error' || c.status === 'warning') + .map((check) => ( +
+ {check.status === 'error' ? ( + + ) : ( + + )} +
+

{check.name}

+

{check.message}

+ {check.fix && ( + + {check.fix} + + )} +
+
+ ))} +
+
+
+ )}
); }