mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 14:16:21 +00:00
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:
@@ -40,6 +40,12 @@ bun run format # Auto-fix formatting
|
|||||||
- `lib/` - Native shell scripts (bash, PowerShell)
|
- `lib/` - Native shell scripts (bash, PowerShell)
|
||||||
- `ui/` - React dashboard (Vite + React 19 + shadcn/ui)
|
- `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)
|
## UI Quality Gates (React Dashboard)
|
||||||
|
|
||||||
**The ui/ directory has IDENTICAL quality gates to the main project.**
|
**The ui/ directory has IDENTICAL quality gates to the main project.**
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ Features a modern React 19 dashboard with real-time updates.
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[]()
|
[]()
|
||||||
|
[](https://claudekit.cc?ref=HMNKXOHN)
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/@kaitranntt/ccs)
|
[](https://www.npmjs.com/package/@kaitranntt/ccs)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://claudekit.cc?ref=HMNKXOHN)
|
|
||||||
|
|
||||||
**Languages**: [English](README.md) | [Tiếng Việt](docs/vi/README.md) | [日本語](docs/ja/README.md)
|
**Languages**: [English](README.md) | [Tiếng Việt](docs/vi/README.md) | [日本語](docs/ja/README.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +1,219 @@
|
|||||||
/**
|
/**
|
||||||
* Health Check Service (Phase 06)
|
* 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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { getCcsDir, getConfigPath } from '../utils/config-manager';
|
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 {
|
export interface HealthCheck {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'ok' | 'warning' | 'error';
|
status: 'ok' | 'warning' | 'error' | 'info';
|
||||||
message: string;
|
message: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
fix?: string;
|
||||||
fixable?: boolean;
|
fixable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HealthGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
checks: HealthCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HealthReport {
|
export interface HealthReport {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
checks: HealthCheck[];
|
version: string;
|
||||||
|
groups: HealthGroup[];
|
||||||
|
checks: HealthCheck[]; // Flat list for backward compatibility
|
||||||
summary: {
|
summary: {
|
||||||
total: number;
|
total: number;
|
||||||
passed: number;
|
passed: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
info: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run all health checks and return report
|
* Run all health checks and return report
|
||||||
*/
|
*/
|
||||||
export function runHealthChecks(): HealthReport {
|
export async function runHealthChecks(): Promise<HealthReport> {
|
||||||
const checks: HealthCheck[] = [];
|
const homedir = os.homedir();
|
||||||
|
const ccsDir = getCcsDir();
|
||||||
|
const claudeDir = path.join(homedir, '.claude');
|
||||||
|
const version = packageJson.version;
|
||||||
|
|
||||||
// Check 1: Claude CLI
|
const groups: HealthGroup[] = [];
|
||||||
checks.push(checkClaudeCli());
|
|
||||||
|
|
||||||
// Check 2: Config file
|
// Group 1: System
|
||||||
checks.push(checkConfigFile());
|
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
|
// Group 2: Configuration
|
||||||
checks.push(checkProfilesFile());
|
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
|
// Group 3: Profiles & Delegation
|
||||||
checks.push(checkCliproxy());
|
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
|
// Group 4: System Health
|
||||||
checks.push(checkCcsDirectory());
|
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
|
// Calculate summary
|
||||||
const summary = {
|
const summary = {
|
||||||
total: checks.length,
|
total: allChecks.length,
|
||||||
passed: checks.filter((c) => c.status === 'ok').length,
|
passed: allChecks.filter((c) => c.status === 'ok').length,
|
||||||
warnings: checks.filter((c) => c.status === 'warning').length,
|
warnings: allChecks.filter((c) => c.status === 'warning').length,
|
||||||
errors: checks.filter((c) => c.status === 'error').length,
|
errors: allChecks.filter((c) => c.status === 'error').length,
|
||||||
|
info: allChecks.filter((c) => c.status === 'info').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
checks,
|
version,
|
||||||
|
groups,
|
||||||
|
checks: allChecks,
|
||||||
summary,
|
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 {
|
try {
|
||||||
const version = execSync('claude --version', {
|
const version = execSync('claude --version', {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
|
const versionMatch = version.match(/(\d+\.\d+\.\d+)/);
|
||||||
|
const versionStr = versionMatch ? versionMatch[1] : 'unknown';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'claude-cli',
|
id: 'claude-cli',
|
||||||
name: 'Claude CLI',
|
name: 'Claude CLI',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: `Installed: ${version}`,
|
message: `v${versionStr}`,
|
||||||
|
details: cliInfo.path,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
id: 'claude-cli',
|
id: 'claude-cli',
|
||||||
name: 'Claude CLI',
|
name: 'Claude CLI',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Not found in PATH',
|
message: 'Not working',
|
||||||
details: 'Install: npm install -g @anthropic-ai/claude-code',
|
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 {
|
function checkConfigFile(): HealthCheck {
|
||||||
const configPath = getConfigPath();
|
const configPath = getConfigPath();
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
return {
|
return {
|
||||||
id: 'config-file',
|
id: 'config-file',
|
||||||
name: 'Config File',
|
name: 'config.json',
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
message: 'Not found',
|
message: 'Not found',
|
||||||
details: configPath,
|
details: configPath,
|
||||||
@@ -109,15 +226,15 @@ function checkConfigFile(): HealthCheck {
|
|||||||
JSON.parse(content);
|
JSON.parse(content);
|
||||||
return {
|
return {
|
||||||
id: 'config-file',
|
id: 'config-file',
|
||||||
name: 'Config File',
|
name: 'config.json',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Valid JSON',
|
message: 'Valid',
|
||||||
details: configPath,
|
details: configPath,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
id: 'config-file',
|
id: 'config-file',
|
||||||
name: 'Config File',
|
name: 'config.json',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: 'Invalid JSON',
|
message: 'Invalid JSON',
|
||||||
details: configPath,
|
details: configPath,
|
||||||
@@ -125,84 +242,508 @@ function checkConfigFile(): HealthCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkProfilesFile(): HealthCheck {
|
// Check 4: Settings files (glm, kimi)
|
||||||
const ccsDir = getCcsDir();
|
function checkSettingsFiles(ccsDir: string): HealthCheck[] {
|
||||||
const profilesPath = path.join(ccsDir, 'profiles.json');
|
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 {
|
return {
|
||||||
id: 'profiles-file',
|
id: 'claude-settings',
|
||||||
name: 'Profiles Registry',
|
name: '~/.claude/settings.json',
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
message: 'Not found (will be created on first account)',
|
message: 'Not found',
|
||||||
details: profilesPath,
|
fix: 'Run: claude /login',
|
||||||
fixable: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(profilesPath, 'utf8');
|
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||||
JSON.parse(content);
|
JSON.parse(content);
|
||||||
return {
|
return {
|
||||||
id: 'profiles-file',
|
id: 'claude-settings',
|
||||||
name: 'Profiles Registry',
|
name: '~/.claude/settings.json',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Valid',
|
message: 'Valid',
|
||||||
details: profilesPath,
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
id: 'profiles-file',
|
id: 'claude-settings',
|
||||||
name: 'Profiles Registry',
|
name: '~/.claude/settings.json',
|
||||||
status: 'error',
|
status: 'warning',
|
||||||
message: 'Invalid JSON',
|
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()) {
|
if (isCLIProxyInstalled()) {
|
||||||
const version = getInstalledCliproxyVersion();
|
const version = getInstalledCliproxyVersion();
|
||||||
const binaryPath = getCLIProxyPath();
|
const binaryPath = getCLIProxyPath();
|
||||||
return {
|
return {
|
||||||
id: 'cliproxy',
|
id: 'cliproxy-binary',
|
||||||
name: 'CLIProxy',
|
name: 'CLIProxy Binary',
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: `Installed: ${version}`,
|
message: `v${version}`,
|
||||||
details: binaryPath,
|
details: binaryPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'cliproxy',
|
id: 'cliproxy-binary',
|
||||||
name: 'CLIProxy',
|
name: 'CLIProxy Binary',
|
||||||
status: 'warning',
|
status: 'info',
|
||||||
message: 'Not installed (optional)',
|
message: 'Not installed',
|
||||||
details: 'Required for gemini/codex/agy providers. Install: ccs cliproxy --latest',
|
details: 'Downloads on first use',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkCcsDirectory(): HealthCheck {
|
// Check 13: CLIProxy Config
|
||||||
const ccsDir = getCcsDir();
|
function checkCliproxyConfig(): HealthCheck {
|
||||||
|
const configPath = getCliproxyConfigPath();
|
||||||
|
|
||||||
if (!fs.existsSync(ccsDir)) {
|
if (fs.existsSync(configPath)) {
|
||||||
return {
|
return {
|
||||||
id: 'ccs-dir',
|
id: 'cliproxy-config',
|
||||||
name: 'CCS Directory',
|
name: 'CLIProxy Config',
|
||||||
status: 'warning',
|
status: 'ok',
|
||||||
message: 'Not found',
|
message: 'cliproxy/config.yaml',
|
||||||
details: ccsDir,
|
|
||||||
fixable: true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: 'ccs-dir',
|
id: 'cliproxy-config',
|
||||||
name: 'CCS Directory',
|
name: 'CLIProxy Config',
|
||||||
status: 'ok',
|
status: 'info',
|
||||||
message: 'Exists',
|
message: 'Not created',
|
||||||
details: ccsDir,
|
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}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const overviewRoutes = Router();
|
|||||||
/**
|
/**
|
||||||
* GET /api/overview
|
* GET /api/overview
|
||||||
*/
|
*/
|
||||||
overviewRoutes.get('/', (_req: Request, res: Response) => {
|
overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ overviewRoutes.get('/', (_req: Request, res: Response) => {
|
|||||||
const totalCliproxyCount = cliproxyVariantCount + authenticatedProviderCount;
|
const totalCliproxyCount = cliproxyVariantCount + authenticatedProviderCount;
|
||||||
|
|
||||||
// Get quick health summary
|
// Get quick health summary
|
||||||
const health = runHealthChecks();
|
const health = await runHealthChecks();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
version: getVersion(),
|
version: getVersion(),
|
||||||
|
|||||||
@@ -644,8 +644,8 @@ apiRoutes.post('/accounts/default', (req: Request, res: Response): void => {
|
|||||||
/**
|
/**
|
||||||
* GET /api/health - Run health checks
|
* GET /api/health - Run health checks
|
||||||
*/
|
*/
|
||||||
apiRoutes.get('/health', (_req: Request, res: Response) => {
|
apiRoutes.get('/health', async (_req: Request, res: Response) => {
|
||||||
const report = runHealthChecks();
|
const report = await runHealthChecks();
|
||||||
res.json(report);
|
res.json(report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,23 +4,36 @@ import { toast } from 'sonner';
|
|||||||
interface HealthCheck {
|
interface HealthCheck {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: 'ok' | 'warning' | 'error';
|
status: 'ok' | 'warning' | 'error' | 'info';
|
||||||
message: string;
|
message: string;
|
||||||
details?: string;
|
details?: string;
|
||||||
|
fix?: string;
|
||||||
fixable?: boolean;
|
fixable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HealthGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
checks: HealthCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
interface HealthReport {
|
interface HealthReport {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
version: string;
|
||||||
|
groups: HealthGroup[];
|
||||||
checks: HealthCheck[];
|
checks: HealthCheck[];
|
||||||
summary: {
|
summary: {
|
||||||
total: number;
|
total: number;
|
||||||
passed: number;
|
passed: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
info: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { HealthCheck, HealthGroup, HealthReport };
|
||||||
|
|
||||||
export function useHealth() {
|
export function useHealth() {
|
||||||
return useQuery<HealthReport>({
|
return useQuery<HealthReport>({
|
||||||
queryKey: ['health'],
|
queryKey: ['health'],
|
||||||
|
|||||||
+324
-33
@@ -1,7 +1,183 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { HealthCard } from '@/components/health-card';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useHealth } from '@/hooks/use-health';
|
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() {
|
export function HealthPage() {
|
||||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
||||||
@@ -10,47 +186,162 @@ export function HealthPage() {
|
|||||||
return new Date(timestamp).toLocaleTimeString();
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
{/* Hero Section */}
|
||||||
<div>
|
<div
|
||||||
<h1 className="text-2xl font-bold">Health Dashboard</h1>
|
className={cn(
|
||||||
{dataUpdatedAt && (
|
'relative overflow-hidden rounded-xl border p-6',
|
||||||
<p className="text-sm text-muted-foreground">Last check: {formatTime(dataUpdatedAt)}</p>
|
'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>
|
</div>
|
||||||
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
|
|
||||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
Refresh
|
{/* Left: Title and Status */}
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
{data && (
|
{data && (
|
||||||
<div className="flex gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="flex items-center gap-1">
|
<SummaryCard
|
||||||
<span className="font-medium text-green-600">{data.summary.passed}</span>
|
label="Passed"
|
||||||
<span className="text-muted-foreground">passed</span>
|
value={data.summary.passed}
|
||||||
</div>
|
icon={CheckCircle2}
|
||||||
<div className="flex items-center gap-1">
|
color="text-green-600"
|
||||||
<span className="font-medium text-yellow-600">{data.summary.warnings}</span>
|
/>
|
||||||
<span className="text-muted-foreground">warnings</span>
|
<SummaryCard
|
||||||
</div>
|
label="Warnings"
|
||||||
<div className="flex items-center gap-1">
|
value={data.summary.warnings}
|
||||||
<span className="font-medium text-red-600">{data.summary.errors}</span>
|
icon={AlertTriangle}
|
||||||
<span className="text-muted-foreground">errors</span>
|
color="text-yellow-500"
|
||||||
</div>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && !data && <div className="text-muted-foreground">Running health checks...</div>}
|
{/* Health Check Groups */}
|
||||||
|
{data?.groups && (
|
||||||
{data && (
|
<div className="columns-1 md:columns-2 gap-4 space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{data.groups.map((group) => (
|
||||||
{data.checks.map((check) => (
|
<div key={group.id} className="break-inside-avoid">
|
||||||
<HealthCard key={check.id} check={check} />
|
<HealthGroupSection group={group} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user