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
This commit is contained in:
kaitranntt
2025-12-08 15:00:07 -05:00
parent e8a39d75c8
commit 8aae0db7da
8 changed files with 1148 additions and 108 deletions
+6
View File
@@ -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.**
+2 -1
View File
@@ -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)
+610 -69
View File
@@ -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<HealthReport> {
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<HealthCheck> {
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<HealthCheck> {
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}`,
};
}
+2 -2
View File
@@ -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(),
+2 -2
View File
@@ -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);
});
+188
View File
@@ -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 (
<div
className={cn(
'group flex items-start gap-4 p-4 rounded-xl border transition-all duration-200',
'hover:shadow-sm',
config.bg,
config.border
)}
>
<div
className={cn(
'mt-0.5 p-2 rounded-full bg-background/50 backdrop-blur-sm shadow-sm',
config.color
)}
>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 py-0.5">
<div className="flex items-center justify-between gap-4 mb-1">
<h4 className="text-base font-semibold tracking-tight">{check.name}</h4>
<span
className={cn(
'text-xs font-mono font-medium px-2 py-0.5 rounded-full bg-background/50',
config.color
)}
>
{config.label}
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">{check.message}</p>
</div>
{check.fixable && check.status !== 'ok' && (
<Button
size="sm"
variant="outline"
onClick={() => fixMutation.mutate(check.id)}
disabled={fixMutation.isPending}
className="h-9 px-4 ml-2 self-center bg-background shadow-sm hover:bg-background/80"
>
<Wrench className="w-3.5 h-3.5 mr-2" />
Fix Issue
</Button>
)}
</div>
);
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'group rounded-xl border transition-all duration-200 hover:shadow-sm',
config.bg,
config.border
)}
>
<CollapsibleTrigger asChild>
<button
className={cn(
'w-full flex items-start gap-4 p-4 text-left rounded-xl transition-all',
isOpen && 'rounded-b-none border-b border-border/10'
)}
>
<div
className={cn(
'mt-0.5 p-2 rounded-full bg-background/50 backdrop-blur-sm shadow-sm',
config.color
)}
>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 py-0.5">
<div className="flex items-center justify-between gap-4 mb-1">
<h4 className="text-base font-semibold tracking-tight">{check.name}</h4>
<div className="flex items-center gap-3">
<span
className={cn(
'text-xs font-mono font-medium px-2 py-0.5 rounded-full bg-background/50',
config.color
)}
>
{config.label}
</span>
<ChevronDown
className={cn(
'w-5 h-5 text-muted-foreground/70 transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">{check.message}</p>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 pt-2 space-y-3">
{check.details && (
<div className="bg-background/50 rounded-lg p-3 border border-border/10">
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap break-all leading-relaxed">
{check.details}
</p>
</div>
)}
{check.fix && (
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 bg-background/50 rounded-lg p-3 border border-border/10 flex items-center gap-3">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<code className="text-xs font-mono text-foreground break-all">{check.fix}</code>
</div>
{check.fixable && check.status !== 'ok' && (
<Button
size="sm"
onClick={() => fixMutation.mutate(check.id)}
disabled={fixMutation.isPending}
className="h-auto py-3 px-6 shadow-sm shrink-0"
>
<Wrench className="w-4 h-4 mr-2" />
Apply Fix
</Button>
)}
</div>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
+14 -1
View File
@@ -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<HealthReport>({
queryKey: ['health'],
+324 -33
View File
@@ -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<string, typeof Monitor> = {
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 (
<Card className={cn('transition-all duration-200', hasIssues && 'border-yellow-500/30')}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<div
className={cn(
'p-1.5 rounded-md',
hasIssues ? 'bg-yellow-500/10 text-yellow-500' : 'bg-muted text-muted-foreground'
)}
>
<Icon className="w-4 h-4" />
</div>
{group.name}
</CardTitle>
<Badge variant="secondary" className="font-mono text-xs">
{groupPassed}/{groupTotal}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{group.checks.map((check) => (
<HealthCheckItem key={check.id} check={check} />
))}
</div>
</CardContent>
</Card>
);
}
function SummaryCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number;
icon: typeof CheckCircle2;
color: string;
}) {
return (
<Card className="overflow-hidden border-none shadow-sm hover:shadow-md transition-all duration-200">
<CardContent className="p-0">
<div className="flex items-center gap-4 p-4 bg-card">
<div
className={cn(
'p-3 rounded-xl bg-background shadow-sm border',
color
.replace('text-', 'text-opacity-80 border-')
.replace('600', '200')
.replace('500', '200')
)}
>
<Icon className={cn('w-6 h-6', color)} />
</div>
<div>
<p className={cn('text-3xl font-bold font-mono tracking-tight', color)}>{value}</p>
<p className="text-sm font-medium text-muted-foreground">{label}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function LoadingSkeleton() {
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
{/* Hero Skeleton */}
<div className="rounded-xl border p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-7 w-[240px] mb-2" />
<Skeleton className="h-4 w-[180px]" />
</div>
<Skeleton className="h-10 w-24" />
</div>
</div>
{/* Summary Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-20 rounded-lg" />
))}
</div>
{/* Groups Skeleton */}
<div className="columns-1 md:columns-2 gap-4 space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="break-inside-avoid">
<Card>
<CardHeader className="pb-3">
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map((j) => (
<Skeleton key={j} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
))}
</div>
</div>
);
}
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 <LoadingSkeleton />;
}
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
const status = statusConfig[overallStatus];
const StatusIcon = status.icon;
return (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Health Dashboard</h1>
{dataUpdatedAt && (
<p className="text-sm text-muted-foreground">Last check: {formatTime(dataUpdatedAt)}</p>
)}
<div className="p-6 max-w-6xl mx-auto space-y-6">
{/* Hero Section */}
<div
className={cn(
'relative overflow-hidden rounded-xl border p-6',
'bg-gradient-to-br from-background via-background to-muted/30'
)}
>
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
backgroundSize: '24px 24px',
}}
/>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Left: Title and Status */}
<div className="flex items-center gap-4">
<div className={cn('p-3 rounded-xl', status.bg)}>
<Stethoscope className={cn('w-8 h-8', status.color)} />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">Health Check</h1>
{data?.version && (
<Badge variant="outline" className="font-mono text-xs">
v{data.version}
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<StatusIcon className={cn('w-4 h-4', status.color)} />
<span className={cn('text-sm font-medium', status.color)}>{status.label}</span>
</div>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={copyDoctorCommand}
className="gap-2 text-muted-foreground"
>
<Terminal className="w-4 h-4" />
ccs doctor
<Copy className="w-3 h-3" />
</Button>
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={cn('w-4 h-4 mr-2', isLoading && 'animate-spin')} />
Refresh
</Button>
</div>
</div>
{/* Last check time */}
{dataUpdatedAt && (
<p className="relative text-xs text-muted-foreground mt-4">
Last check: {formatTime(dataUpdatedAt)}
</p>
)}
</div>
{/* Summary Stats */}
{data && (
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-1">
<span className="font-medium text-green-600">{data.summary.passed}</span>
<span className="text-muted-foreground">passed</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium text-yellow-600">{data.summary.warnings}</span>
<span className="text-muted-foreground">warnings</span>
</div>
<div className="flex items-center gap-1">
<span className="font-medium text-red-600">{data.summary.errors}</span>
<span className="text-muted-foreground">errors</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SummaryCard
label="Passed"
value={data.summary.passed}
icon={CheckCircle2}
color="text-green-600"
/>
<SummaryCard
label="Warnings"
value={data.summary.warnings}
icon={AlertTriangle}
color="text-yellow-500"
/>
<SummaryCard
label="Errors"
value={data.summary.errors}
icon={XCircle}
color="text-red-500"
/>
<SummaryCard label="Info" value={data.summary.info} icon={Info} color="text-blue-500" />
</div>
)}
{isLoading && !data && <div className="text-muted-foreground">Running health checks...</div>}
{data && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.checks.map((check) => (
<HealthCard key={check.id} check={check} />
{/* Health Check Groups */}
{data?.groups && (
<div className="columns-1 md:columns-2 gap-4 space-y-4">
{data.groups.map((group) => (
<div key={group.id} className="break-inside-avoid">
<HealthGroupSection group={group} />
</div>
))}
</div>
)}
{/* Issues Summary */}
{data && (data.summary.errors > 0 || data.summary.warnings > 0) && (
<Card className="border-yellow-500/30 bg-yellow-500/5">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2 text-yellow-600">
<AlertTriangle className="w-4 h-4" />
Issues Detected
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{data.checks
.filter((c) => c.status === 'error' || c.status === 'warning')
.map((check) => (
<div
key={check.id}
className="flex items-start gap-3 p-3 rounded-lg bg-background border"
>
{check.status === 'error' ? (
<XCircle className="w-4 h-4 text-red-500 mt-0.5 shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{check.name}</p>
<p className="text-xs text-muted-foreground">{check.message}</p>
{check.fix && (
<code className="mt-1 block text-xs bg-muted px-2 py-1 rounded font-mono text-muted-foreground">
{check.fix}
</code>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}