feat!: web dashboard, analytics, and CLIProxy multi-account support

This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-08 23:26:55 -05:00
committed by GitHub
49 changed files with 3910 additions and 259 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)
+1 -1
View File
@@ -1 +1 @@
5.11.0
5.11.0-dev.8
+3
View File
@@ -4,6 +4,7 @@
"": {
"name": "@kaitranntt/ccs",
"dependencies": {
"better-ccusage": "^1.2.6",
"boxen": "^8.0.1",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
@@ -445,6 +446,8 @@
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-ccusage": ["better-ccusage@1.2.6", "", { "bin": { "better-ccusage": "dist/index.js" } }, "sha512-IZCYBX1kF0IfJ6ho9JMwLKn2o820WRiVGZ+2tVS2olODU5J7Np5mJ1j1i5HtazZPNo2S9wKU9C9iysc0f8Cjqw=="],
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
+13 -4
View File
@@ -440,6 +440,7 @@ src/types/
- Intelligent profile selection algorithms
- Cost estimation with typed calculation models
- Performance optimization through type-aware caching
- Recently implemented significant UI improvements for analytics dashboard, enhancing data presentation.
2. **Enhanced Session Management**
- Type-safe session persistence with serialization
@@ -584,21 +585,29 @@ src/types/
- **User Experience**: One-command channel switching without data loss
- **Backward Compatibility**: Zero breaking changes, existing workflows preserved
### Version 4.5.1 - UI Layout Improvements
### Version 4.5.1 - UI Quality Gate Fixes & Layout Improvements
**Release Date**: 2025-12-08
#### UI Fixes & Improvements
-**Auto-formatting**: 31 UI files auto-formatted for consistent styling.
-**Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files.
-**React Hooks Issues**: Fixed `react-hooks/purity` (`Math.random()` in `useMemo` for `sidebar.tsx`) and `react-hooks/set-state-in-effect` (`use-theme.ts`, `settings.tsx`).
-**useWebSocket Hook Restructure**: Addressed `react-hooks/immutability` errors and dependency array warnings in `use-websocket.ts`.
-**TypeScript Strict Mode**: Implemented null-check for `document.getElementById('root')` in `src/main.tsx` for strict mode compliance.
-**Duplicate Directory Removal**: Cleaned up extraneous `ui/@/` directory.
-**CLIProxy Card Padding**: Removed excessive padding from CLIProxy cards for better visual integration.
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard for enhanced user experience.
-**Dropdown Styling**: Refined dropdown component styling for consistency and readability.
-**CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard.
-**Dropdown Styling**: Refined dropdown component styling.
-**Model Usage Card**: Corrected icon display and refined donut chart styling.
#### Technical Improvements
- **Improved UI Responsiveness**: Adjustments ensure better display across various screen sizes.
- **Enhanced User Experience**: Minor visual tweaks lead to a more polished and intuitive interface.
#### Validation Results
- **UI Rendering**: ✅ All UI components render correctly after layout adjustments.
- **UI Rendering**: ✅ All UI components render correctly after adjustments and fixes.
- **Functional Impact**: ✅ No regressions introduced, core functionality remains stable.
- **Code Quality**: ✅ All ESLint and TypeScript quality gates passed after fixes.
---
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "5.11.0",
"version": "5.11.0-dev.8",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -79,6 +79,7 @@
"postinstall": "node scripts/postinstall.js"
},
"dependencies": {
"better-ccusage": "^1.2.6",
"boxen": "^8.0.1",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
+11 -17
View File
@@ -48,8 +48,18 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
agy: {
provider: 'agy',
displayName: 'Antigravity',
defaultModel: 'gemini-3-pro-preview',
defaultModel: 'gemini-claude-opus-4-5-thinking',
models: [
{
id: 'gemini-claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking',
description: 'Most capable, extended thinking',
},
{
id: 'gemini-claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking',
description: 'Balanced with extended thinking',
},
{
id: 'gemini-claude-sonnet-4-5',
name: 'Claude Sonnet 4.5',
@@ -60,22 +70,6 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
name: 'Gemini 3 Pro',
description: 'Google latest model via Antigravity',
},
{
id: 'gemini-claude-opus-4-5-thinking',
name: 'Claude Opus 4.5 Thinking',
description: 'Most capable, extended thinking',
deprecated: true,
deprecationReason:
'Thinking models are deprecated due to compatibility issues with Antigravity',
},
{
id: 'gemini-claude-sonnet-4-5-thinking',
name: 'Claude Sonnet 4.5 Thinking',
description: 'Balanced with extended thinking',
deprecated: true,
deprecationReason:
'Thinking models are deprecated due to compatibility issues with Antigravity',
},
],
},
gemini: {
+7 -12
View File
@@ -6,11 +6,10 @@
*/
import { spawn } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { colored } from '../utils/helpers';
import { detectInstallationMethod, detectPackageManager } from '../utils/package-manager-detector';
import { compareVersionsWithPrerelease } from '../utils/update-checker';
import { getVersion } from '../utils/version';
/**
* Options for the update command
@@ -20,10 +19,8 @@ export interface UpdateOptions {
beta?: boolean;
}
// Version (sync with package.json)
const CCS_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
).version;
// Version (from centralized utility)
const CCS_VERSION = getVersion();
/**
* Handle the update command
@@ -169,19 +166,17 @@ function handleCheckFailed(
* Handle no update available
*/
function handleNoUpdate(reason: string | undefined): void {
const CCS_VERSION_LOCAL = JSON.parse(
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
).version;
const version = getVersion();
let message = `You are already on the latest version (${CCS_VERSION_LOCAL})`;
let message = `You are already on the latest version (${version})`;
switch (reason) {
case 'dismissed':
message = `Update dismissed. You are on version ${CCS_VERSION_LOCAL}`;
message = `Update dismissed. You are on version ${version}`;
console.log(colored(`[i] ${message}`, 'yellow'));
break;
case 'cached':
message = `No updates available (cached result). You are on version ${CCS_VERSION_LOCAL}`;
message = `No updates available (cached result). You are on version ${version}`;
console.log(colored(`[i] ${message}`, 'cyan'));
break;
default:
+2 -6
View File
@@ -9,17 +9,13 @@ import * as fs from 'fs';
import * as os from 'os';
import { colored } from '../utils/helpers';
import { getConfigPath } from '../utils/config-manager';
// Get version from package.json
const CCS_VERSION = JSON.parse(
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')
).version;
import { getVersion } from '../utils/version';
/**
* Handle version command
*/
export function handleVersionCommand(): void {
console.log(colored(`CCS (Claude Code Switch) v${CCS_VERSION}`, 'bold'));
console.log(colored(`CCS (Claude Code Switch) v${getVersion()}`, 'bold'));
console.log('');
console.log(colored('Installation:', 'cyan'));
+75
View File
@@ -2,6 +2,81 @@
* Type shims for incomplete external dependencies
*/
// better-ccusage types (package has JS exports but incomplete TS subpath support)
declare module 'better-ccusage/data-loader' {
export interface ModelBreakdown {
modelName: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
cost: number;
}
export interface DailyUsage {
date: string;
source: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
cost: number;
totalCost: number;
modelsUsed: string[];
modelBreakdowns: ModelBreakdown[];
}
export interface MonthlyUsage {
month: string;
source: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
totalCost: number;
modelsUsed: string[];
modelBreakdowns: ModelBreakdown[];
}
export interface SessionUsage {
sessionId: string;
projectPath: string;
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
cost: number;
totalCost: number;
lastActivity: string;
versions: string[];
modelsUsed: string[];
modelBreakdowns: ModelBreakdown[];
source: string;
}
export interface DataLoaderOptions {
mode?: 'calculate' | 'cached';
claudePaths?: string[];
}
export function loadDailyUsageData(options?: DataLoaderOptions): Promise<DailyUsage[]>;
export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise<MonthlyUsage[]>;
export function loadSessionData(options?: DataLoaderOptions): Promise<SessionUsage[]>;
}
declare module 'better-ccusage/calculate-cost' {
export interface Totals {
inputTokens: number;
outputTokens: number;
cacheCreationTokens: number;
cacheReadTokens: number;
costUSD: number;
}
export function calculateTotals(entries: unknown[]): Totals;
export function getTotalTokens(entries: unknown[]): number;
}
declare module 'cli-table3' {
interface TableOptions {
head?: string[];
+26
View File
@@ -0,0 +1,26 @@
/**
* Version Utility
*
* Centralized version management for CCS.
* Reads version from package.json at runtime.
*/
import * as path from 'path';
import * as fs from 'fs';
// Get version from package.json (relative to dist/ at runtime)
let cachedVersion: string | null = null;
export function getVersion(): string {
if (cachedVersion) return cachedVersion;
try {
const packageJsonPath = path.join(__dirname, '../../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
cachedVersion = packageJson.version || '0.0.0';
} catch {
cachedVersion = '0.0.0';
}
return cachedVersion ?? '0.0.0';
}
+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}`,
};
}
+11
View File
@@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { overviewRoutes } = await import('./overview-routes');
app.use('/api/overview', overviewRoutes);
// Usage analytics routes
const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes);
// Dev mode: use Vite middleware for HMR
if (options.dev) {
const { createServer: createViteServer } = await import('vite');
@@ -73,6 +77,13 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((resolve) => {
server.listen(options.port, () => {
// Non-blocking prewarm: load usage cache in background
import('./usage-routes').then(({ prewarmUsageCache }) => {
prewarmUsageCache().catch(() => {
// Error already logged in prewarmUsageCache
});
});
resolve({ server, wss, cleanup });
});
});
+20 -4
View File
@@ -9,25 +9,38 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfig } from '../utils/config-manager';
import { runHealthChecks } from './health-service';
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
import { getVersion } from '../utils/version';
export const overviewRoutes = Router();
/**
* GET /api/overview
*/
overviewRoutes.get('/', (_req: Request, res: Response) => {
overviewRoutes.get('/', async (_req: Request, res: Response) => {
try {
const config = loadConfig();
const profileCount = Object.keys(config.profiles).length;
const cliproxyCount = Object.keys(config.cliproxy || {}).length;
const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length;
// Count authenticated built-in providers (gemini, codex, agy, qwen, iflow)
initializeAccounts();
const authStatuses = getAllAuthStatus();
const authenticatedProviderCount = authStatuses.filter((s) => s.authenticated).length;
// Total CLIProxy = custom variants + authenticated providers
const totalCliproxyCount = cliproxyVariantCount + authenticatedProviderCount;
// Get quick health summary
const health = runHealthChecks();
const health = await runHealthChecks();
res.json({
version: getVersion(),
profiles: profileCount,
cliproxy: cliproxyCount,
cliproxy: totalCliproxyCount,
cliproxyVariants: cliproxyVariantCount,
cliproxyProviders: authenticatedProviderCount,
accounts: getAccountCount(),
health: {
status:
@@ -38,8 +51,11 @@ overviewRoutes.get('/', (_req: Request, res: Response) => {
});
} catch {
res.json({
version: getVersion(),
profiles: 0,
cliproxy: 0,
cliproxyVariants: 0,
cliproxyProviders: 0,
accounts: 0,
health: { status: 'error', passed: 0, total: 0 },
});
+11 -4
View File
@@ -478,10 +478,17 @@ function maskApiKeys(settings: Settings): Settings {
if (!settings.env) return settings;
const masked = { ...settings, env: { ...settings.env } };
const sensitiveKeys = ['ANTHROPIC_AUTH_TOKEN', 'API_KEY', 'AUTH_TOKEN'];
// Pattern-based matching for sensitive keys
const sensitivePatterns = [
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
/_API_KEY$/, // Keys ending with _API_KEY
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
/^API_KEY$/, // Exact match for API_KEY
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
];
for (const key of Object.keys(masked.env)) {
if (sensitiveKeys.some((sensitive) => key.includes(sensitive))) {
if (sensitivePatterns.some((pattern) => pattern.test(key))) {
const value = masked.env[key];
if (value && value.length > 8) {
masked.env[key] =
@@ -644,8 +651,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);
});
+3 -2
View File
@@ -75,8 +75,9 @@ function getSharedItems(type: 'commands' | 'skills' | 'agents'): SharedItem[] {
for (const entry of entries) {
if (entry.isDirectory()) {
// Skill/Agent: look for prompt.md
const promptPath = path.join(sharedDir, entry.name, 'prompt.md');
// Skill/Agent: look for SKILL.md for skills, prompt.md for agents
const markdownFile = type === 'skills' ? 'SKILL.md' : 'prompt.md';
const promptPath = path.join(sharedDir, entry.name, markdownFile);
if (fs.existsSync(promptPath)) {
const content = fs.readFileSync(promptPath, 'utf8');
const description = extractDescription(content);
+569
View File
@@ -0,0 +1,569 @@
/**
* Usage Analytics API Routes
*
* Provides REST endpoints for Claude Code usage analytics using better-ccusage library.
* Supports daily, monthly, and session-based usage data aggregation.
*
* Performance optimizations:
* - TTL-based caching to reduce better-ccusage library calls
* - Request coalescing to prevent duplicate concurrent requests
*/
import { Router, Request, Response } from 'express';
import {
loadDailyUsageData,
loadMonthlyUsageData,
loadSessionData,
type DailyUsage,
type MonthlyUsage,
type SessionUsage,
} from 'better-ccusage/data-loader';
export const usageRoutes = Router();
/** Query parameters for usage endpoints */
interface UsageQuery {
since?: string; // YYYYMMDD format
until?: string; // YYYYMMDD format
limit?: string;
offset?: string;
}
// Constants for validation
const MAX_LIMIT = 1000;
const DEFAULT_LIMIT = 50;
const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format
// ============================================================================
// Caching Layer - Reduces better-ccusage library calls
// ============================================================================
interface CacheEntry<T> {
data: T;
timestamp: number;
}
// Cache TTLs (milliseconds)
const CACHE_TTL = {
daily: 60 * 1000, // 1 minute - changes frequently
monthly: 5 * 60 * 1000, // 5 minutes - aggregated data
session: 60 * 1000, // 1 minute - user may refresh
};
// Stale-while-revalidate: max age for stale data (1 hour)
const STALE_TTL = 60 * 60 * 1000;
// Track when data was last fetched (for UI indicator)
let lastFetchTimestamp: number | null = null;
/** Get timestamp of last successful data fetch */
export function getLastFetchTimestamp(): number | null {
return lastFetchTimestamp;
}
// In-memory cache
const cache = new Map<string, CacheEntry<unknown>>();
// Pending requests for coalescing (prevents duplicate concurrent calls)
const pendingRequests = new Map<string, Promise<unknown>>();
/**
* Get cached data or fetch from loader with TTL
* Also coalesces concurrent requests to prevent duplicate library calls
* Implements stale-while-revalidate pattern for instant responses
*/
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
const cached = cache.get(key) as CacheEntry<T> | undefined;
const now = Date.now();
// Fresh cache - return immediately
if (cached && now - cached.timestamp < ttl) {
return cached.data;
}
// Stale cache - return immediately, refresh in background (SWR pattern)
if (cached && now - cached.timestamp < STALE_TTL) {
// Fire and forget background refresh if not already pending
if (!pendingRequests.has(key)) {
const promise = loader()
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
})
.catch((err) => {
console.error(`[!] Background refresh failed for ${key}:`, err);
})
.finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
}
return cached.data;
}
// No usable cache - check if request is already pending (coalesce)
const pending = pendingRequests.get(key) as Promise<T> | undefined;
if (pending) {
return pending;
}
// Create new request
const promise = loader()
.then((data) => {
cache.set(key, { data, timestamp: Date.now() });
lastFetchTimestamp = Date.now();
return data;
})
.finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
return promise;
}
/** Cached loader for daily usage data */
async function getCachedDailyData(): Promise<DailyUsage[]> {
return getCachedData('daily', CACHE_TTL.daily, async () => {
return (await loadDailyUsageData()) as DailyUsage[];
});
}
/** Cached loader for monthly usage data */
async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return (await loadMonthlyUsageData()) as MonthlyUsage[];
});
}
/** Cached loader for session data */
async function getCachedSessionData(): Promise<SessionUsage[]> {
return getCachedData('session', CACHE_TTL.session, async () => {
return (await loadSessionData()) as SessionUsage[];
});
}
/**
* Clear all cached data (useful for manual refresh)
*/
export function clearUsageCache(): void {
cache.clear();
}
/**
* Pre-warm usage caches on server startup
* Loads all usage data into cache so first user request is instant
* Returns timestamp when cache was populated
*/
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
const start = Date.now();
console.log('[i] Pre-warming usage cache...');
try {
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
const elapsed = Date.now() - start;
lastFetchTimestamp = Date.now();
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
return { timestamp: lastFetchTimestamp, elapsed };
} catch (err) {
console.error('[!] Failed to prewarm usage cache:', err);
throw err;
}
}
// ============================================================================
// Validation Helpers
// ============================================================================
/**
* Validate date string in YYYYMMDD format
*/
function validateDate(dateString?: string): string | undefined {
if (!dateString) return undefined;
if (!DATE_REGEX.test(dateString)) {
throw new Error('Invalid date format. Use YYYYMMDD');
}
// Basic range check
const year = parseInt(dateString.substring(0, 4), 10);
const month = parseInt(dateString.substring(4, 6), 10);
const day = parseInt(dateString.substring(6, 8), 10);
if (year < 2024 || year > 2100) throw new Error('Year out of valid range');
if (month < 1 || month > 12) throw new Error('Month out of valid range');
if (day < 1 || day > 31) throw new Error('Day out of valid range');
return dateString;
}
/**
* Validate and parse limit parameter
*/
function validateLimit(limit?: string): number {
if (!limit) return DEFAULT_LIMIT;
const num = parseInt(limit, 10);
if (isNaN(num) || num < 1 || num > MAX_LIMIT) {
throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`);
}
return num;
}
/**
* Validate and parse offset parameter
*/
function validateOffset(offset?: string): number {
if (!offset) return 0;
const num = parseInt(offset, 10);
if (isNaN(num) || num < 0) {
throw new Error('Offset must be a non-negative number');
}
return num;
}
/**
* Filter data by date range
*/
function filterByDateRange<T extends { date?: string; month?: string; lastActivity?: string }>(
data: T[],
since?: string,
until?: string
): T[] {
if (!since && !until) return data;
return data.filter((item) => {
// Get the date field (prioritize date, then month, then lastActivity)
const itemDate =
item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, '');
if (!itemDate) return true;
// Normalize to YYYYMMDD for comparison
const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8);
if (since && normalizedDate < since) return false;
if (until && normalizedDate > until) return false;
return true;
});
}
/**
* Create standard error response
*/
function errorResponse(res: Response, error: unknown, defaultMessage: string): void {
console.error(defaultMessage + ':', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const isValidationError =
errorMessage.includes('Invalid') ||
errorMessage.includes('format') ||
errorMessage.includes('range') ||
errorMessage.includes('must be');
const statusCode = isValidationError ? 400 : 500;
res.status(statusCode).json({
success: false,
error: isValidationError ? errorMessage : defaultMessage,
});
}
/**
* GET /api/usage/summary
*
* Returns usage summary data for quick dashboard display.
* Query: ?since=YYYYMMDD&until=YYYYMMDD
*/
usageRoutes.get(
'/summary',
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
// Calculate totals
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheTokens = 0;
let totalCost = 0;
for (const day of filtered) {
totalInputTokens += day.inputTokens;
totalOutputTokens += day.outputTokens;
totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens;
totalCost += day.totalCost;
}
const totalTokens = totalInputTokens + totalOutputTokens;
res.json({
success: true,
data: {
totalTokens,
totalInputTokens,
totalOutputTokens,
totalCacheTokens,
totalCost: Math.round(totalCost * 100) / 100,
totalDays: filtered.length,
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
averageCostPerDay:
filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0,
},
});
} catch (error) {
errorResponse(res, error, 'Failed to fetch usage summary');
}
}
);
/**
* GET /api/usage/daily
*
* Returns daily usage trends for chart visualization.
* Query: ?since=YYYYMMDD&until=YYYYMMDD
*/
usageRoutes.get(
'/daily',
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
// Transform for chart consumption
const trends = filtered.map((day) => ({
date: day.date,
tokens: day.inputTokens + day.outputTokens,
inputTokens: day.inputTokens,
outputTokens: day.outputTokens,
cacheTokens: day.cacheCreationTokens + day.cacheReadTokens,
cost: Math.round(day.totalCost * 100) / 100,
modelsUsed: day.modelsUsed.length,
}));
res.json({
success: true,
data: trends,
});
} catch (error) {
errorResponse(res, error, 'Failed to fetch daily usage');
}
}
);
/**
* GET /api/usage/models
*
* Returns usage breakdown by model for pie/bar charts.
* Query: ?since=YYYYMMDD&until=YYYYMMDD
*/
usageRoutes.get(
'/models',
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const dailyData = await getCachedDailyData();
const filtered = filterByDateRange(dailyData, since, until);
// Aggregate model usage across all days
const modelMap = new Map<
string,
{
model: string;
inputTokens: number;
outputTokens: number;
cacheTokens: number;
cost: number;
}
>();
for (const day of filtered) {
for (const breakdown of day.modelBreakdowns) {
const existing = modelMap.get(breakdown.modelName) || {
model: breakdown.modelName,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
cost: 0,
};
existing.inputTokens += breakdown.inputTokens;
existing.outputTokens += breakdown.outputTokens;
existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens;
existing.cost += breakdown.cost;
modelMap.set(breakdown.modelName, existing);
}
}
// Calculate totals for percentage
const models = Array.from(modelMap.values());
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
// Add percentage and sort by tokens
const result = models
.map((m) => ({
...m,
tokens: m.inputTokens + m.outputTokens,
cost: Math.round(m.cost * 100) / 100,
percentage:
totalTokens > 0
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
: 0,
}))
.sort((a, b) => b.tokens - a.tokens);
res.json({
success: true,
data: result,
});
} catch (error) {
errorResponse(res, error, 'Failed to fetch model usage');
}
}
);
/**
* GET /api/usage/sessions
*
* Returns paginated list of sessions.
* Query: ?since=YYYYMMDD&until=YYYYMMDD&limit=50&offset=0
*/
usageRoutes.get(
'/sessions',
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const limit = validateLimit(req.query.limit);
const offset = validateOffset(req.query.offset);
const sessionData = await getCachedSessionData();
// Filter by date range using lastActivity
const filtered = filterByDateRange(sessionData, since, until);
// Sort by lastActivity descending
const sorted = [...filtered].sort(
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
);
// Paginate
const paginated = sorted.slice(offset, offset + limit);
// Transform for frontend
const sessions = paginated.map((s) => ({
sessionId: s.sessionId,
projectPath: s.projectPath,
tokens: s.inputTokens + s.outputTokens,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
cost: Math.round(s.totalCost * 100) / 100,
lastActivity: s.lastActivity,
modelsUsed: s.modelsUsed,
}));
res.json({
success: true,
data: {
sessions,
total: filtered.length,
limit,
offset,
hasMore: offset + limit < filtered.length,
},
});
} catch (error) {
errorResponse(res, error, 'Failed to fetch sessions');
}
}
);
/**
* GET /api/usage/monthly
*
* Returns monthly usage summary for charts.
* Query: ?since=YYYYMMDD&until=YYYYMMDD
*/
usageRoutes.get(
'/monthly',
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
try {
const since = validateDate(req.query.since);
const until = validateDate(req.query.until);
const monthlyData = await getCachedMonthlyData();
// Filter by date range (convert month YYYY-MM to YYYYMM01 for comparison)
const filtered =
since || until
? monthlyData.filter((m) => {
const monthDate = m.month.replace('-', '') + '01';
if (since && monthDate < since) return false;
if (until && monthDate > until) return false;
return true;
})
: monthlyData;
// Transform for charts
const result = filtered.map((m) => ({
month: m.month,
tokens: m.inputTokens + m.outputTokens,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
cost: Math.round(m.totalCost * 100) / 100,
modelsUsed: m.modelsUsed.length,
}));
res.json({
success: true,
data: result.sort((a, b) => a.month.localeCompare(b.month)),
});
} catch (error) {
errorResponse(res, error, 'Failed to fetch monthly usage');
}
}
);
/**
* POST /api/usage/refresh
*
* Clears the usage cache to force fresh data fetch.
* Useful when user wants to see latest data immediately.
*/
usageRoutes.post('/refresh', (_req: Request, res: Response) => {
clearUsageCache();
res.json({
success: true,
message: 'Usage cache cleared',
});
});
/**
* GET /api/usage/status
*
* Returns cache status including last fetch timestamp.
* Used by UI to show "Last updated: X ago" indicator.
*/
usageRoutes.get('/status', (_req: Request, res: Response) => {
res.json({
success: true,
data: {
lastFetch: lastFetchTimestamp,
cacheSize: cache.size,
},
});
});
+22 -25
View File
@@ -33,7 +33,7 @@ describe('Model Catalog', () => {
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-3-pro-preview');
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-5-thinking');
});
it('includes Claude Opus 4.5 Thinking', () => {
@@ -215,60 +215,58 @@ describe('Model Catalog', () => {
});
});
describe('Deprecated models', () => {
it('Claude Opus 4.5 Thinking is marked as deprecated', () => {
describe('Thinking models ordering', () => {
it('Claude Opus 4.5 Thinking is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
);
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.deprecated, true, 'Should be marked as deprecated');
assert(opus.deprecationReason, 'Should have deprecation reason');
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
});
it('Claude Sonnet 4.5 Thinking is marked as deprecated', () => {
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.deprecated, true, 'Should be marked as deprecated');
assert(sonnetThinking.deprecationReason, 'Should have deprecation reason');
assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated');
});
it('deprecated models are at the bottom of the list', () => {
it('thinking models are at the top of the list', () => {
const { MODEL_CATALOG } = modelCatalog;
const models = MODEL_CATALOG.agy.models;
// Find indices of deprecated models
// Find indices of thinking models
const opusIdx = models.findIndex((m) => m.id === 'gemini-claude-opus-4-5-thinking');
const sonnetThinkingIdx = models.findIndex(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
// Find indices of non-deprecated models
// Find indices of non-thinking models
const sonnetIdx = models.findIndex((m) => m.id === 'gemini-claude-sonnet-4-5');
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
// Deprecated models should come after non-deprecated models
assert(opusIdx > sonnetIdx, 'Opus Thinking should be below non-deprecated Sonnet');
assert(opusIdx > geminiIdx, 'Opus Thinking should be below non-deprecated Gemini');
// Thinking models should come before non-thinking models
assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet');
assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini');
assert(
sonnetThinkingIdx > sonnetIdx,
'Sonnet Thinking should be below non-deprecated Sonnet'
sonnetThinkingIdx < sonnetIdx,
'Sonnet Thinking should be above non-thinking Sonnet'
);
assert(
sonnetThinkingIdx > geminiIdx,
'Sonnet Thinking should be below non-deprecated Gemini'
sonnetThinkingIdx < geminiIdx,
'Sonnet Thinking should be above non-thinking Gemini'
);
});
});
describe('isModelDeprecated', () => {
it('returns true for deprecated models', () => {
it('returns false for thinking models (no longer deprecated)', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), true);
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), true);
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), false);
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), false);
});
it('returns false for non-deprecated models', () => {
@@ -284,11 +282,10 @@ describe('Model Catalog', () => {
});
describe('getModelDeprecationReason', () => {
it('returns deprecation reason for deprecated models', () => {
it('returns undefined for thinking models (no longer deprecated)', () => {
const { getModelDeprecationReason } = modelCatalog;
const reason = getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking');
assert(reason, 'Should have deprecation reason');
assert(typeof reason === 'string', 'Reason should be a string');
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5-thinking'), undefined);
});
it('returns undefined for non-deprecated models', () => {
+94
View File
@@ -20,11 +20,14 @@
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.556.0",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-router-dom": "^7.10.1",
"recharts": "^2.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13",
@@ -35,6 +38,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/recharts": "^1.8.29",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
@@ -82,12 +86,16 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -352,6 +360,24 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -362,6 +388,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/recharts": ["@types/recharts@1.8.29", "", { "dependencies": { "@types/d3-shape": "^1", "@types/react": "*" } }, "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="],
@@ -430,14 +458,44 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
@@ -470,8 +528,12 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.3.3", "", {}, "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
@@ -512,6 +574,8 @@
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -564,8 +628,12 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.556.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A=="],
@@ -582,6 +650,8 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
@@ -604,14 +674,20 @@
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="],
"react-day-picker": ["react-day-picker@9.12.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA=="],
"react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -622,10 +698,18 @@
"react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
@@ -654,6 +738,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
@@ -676,6 +762,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -728,6 +816,12 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
}
}
+8
View File
@@ -16,6 +16,14 @@
<!-- Theme color for mobile browsers -->
<meta name="theme-color" content="#0a0a0a" />
<!-- Google Fonts - JetBrains Mono (terminal) + IBM Plex Sans (UI) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+4
View File
@@ -31,11 +31,14 @@
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.556.0",
"react": "^19.2.0",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"react-router-dom": "^7.10.1",
"recharts": "^2.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13"
@@ -46,6 +49,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/recharts": "^1.8.29",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
+2
View File
@@ -15,6 +15,7 @@ import {
SettingsPage,
HealthPage,
SharedPage,
AnalyticsPage,
} from '@/pages';
function Layout() {
@@ -42,6 +43,7 @@ export default function App() {
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/accounts" element={<AccountsPage />} />
@@ -0,0 +1,95 @@
/**
* Date Range Filter Component
*
* Provides date range selection with preset options for analytics.
* Uses react-day-picker for date selection UI.
*/
import React from 'react';
import { format } from 'date-fns';
import type { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { CalendarIcon } from 'lucide-react';
interface DateRangeFilterProps {
value?: DateRange;
onChange: (dateRange: DateRange | undefined) => void;
presets?: Array<{
label: string;
range: DateRange;
}>;
className?: string;
}
export function DateRangeFilter({
value,
onChange,
presets = [],
className,
}: DateRangeFilterProps) {
const handlePresetClick = (range: DateRange) => {
onChange(range);
};
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const from = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from, to: value?.to });
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const to = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from: value?.from, to });
};
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{/* Preset Buttons */}
{presets.map((preset, index) => (
<Button
key={index}
variant={isSameRange(value, preset.range) ? 'default' : 'outline'}
size="sm"
onClick={() => handlePresetClick(preset.range)}
>
{preset.label}
</Button>
))}
{/* Custom Date Range Inputs */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<Input
type="date"
value={value?.from ? format(value.from, 'yyyy-MM-dd') : ''}
onChange={handleFromChange}
placeholder="From"
className="w-40"
/>
</div>
<span className="text-muted-foreground">to</span>
<Input
type="date"
value={value?.to ? format(value.to, 'yyyy-MM-dd') : ''}
onChange={handleToChange}
placeholder="To"
className="w-40"
/>
</div>
</div>
);
}
// Helper to compare date ranges
function isSameRange(a?: DateRange, b?: DateRange): boolean {
if (!a || !b) return a === b;
const fromA = a.from?.getTime() ?? 0;
const fromB = b.from?.getTime() ?? 0;
const toA = a.to?.getTime() ?? 0;
const toB = b.to?.getTime() ?? 0;
return fromA === fromB && toA === toB;
}
@@ -0,0 +1,119 @@
/**
* Model Breakdown Chart Component
*
* Displays usage distribution by model using pie chart.
* Shows tokens, cost, and percentage breakdown.
*/
import { useMemo } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Skeleton } from '@/components/ui/skeleton';
import type { ModelUsage } from '@/hooks/use-usage';
import { cn } from '@/lib/utils';
interface ModelBreakdownChartProps {
data: ModelUsage[];
isLoading?: boolean;
className?: string;
}
const COLORS = [
'#0080FF',
'#00C49F',
'#FFBB28',
'#FF8042',
'#8884D8',
'#82CA9D',
'#FFC658',
'#8DD1E1',
'#D084D0',
'#87D068',
];
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item, index) => ({
name: item.model,
value: item.tokens,
cost: item.cost,
requests: item.requests,
percentage: item.percentage,
fill: COLORS[index % COLORS.length],
}));
}, [data]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No model data available</p>
</div>
);
}
const renderTooltip = ({ active, payload }: { active?: boolean; payload?: unknown }) => {
if (!active || !payload) return null;
const payloadArray = payload as Array<{
payload: { name: string; value: number; cost: number; requests: number; percentage: number };
}>;
if (!payloadArray.length) return null;
const data = payloadArray[0].payload;
return (
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
<p className="font-medium mb-1">{data.name}</p>
<p className="text-muted-foreground">
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
</p>
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
</div>
);
};
const renderLabel = (entry: { percentage: number }) => {
return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : '';
};
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={renderLabel}
innerRadius={60}
outerRadius={80}
paddingAngle={2}
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} strokeWidth={1} />
))}
</Pie>
<Tooltip content={renderTooltip} />
{/* Legend removed from here, moved to AnalyticsPage for better layout control */}
</PieChart>
</ResponsiveContainer>
</div>
);
}
// Helper function to format large numbers
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
@@ -0,0 +1,269 @@
/**
* Sessions Table Component
*
* Displays session history with pagination and filtering.
* Shows session duration, tokens, cost, and metadata.
*/
import { useState, useMemo } from 'react';
import { formatDistanceToNow } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ChevronLeft, ChevronRight, Search, Clock, Zap, DollarSign } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PaginatedSessions } from '@/hooks/use-usage';
interface SessionsTableProps {
data?: PaginatedSessions;
isLoading?: boolean;
}
export function SessionsTable({ data, isLoading }: SessionsTableProps) {
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(0);
// Get sessions array (stable reference for memoization)
const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
// Filter sessions based on search term
const filteredSessions = useMemo(() => {
if (!searchTerm) return sessions;
const term = searchTerm.toLowerCase();
return sessions.filter(
(session) =>
session.profile.toLowerCase().includes(term) ||
session.model.toLowerCase().includes(term) ||
session.id.toLowerCase().includes(term)
);
}, [sessions, searchTerm]);
// Pagination for filtered data
const pageSize = 10;
const paginatedSessions = useMemo(() => {
if (!filteredSessions) return [];
const start = currentPage * pageSize;
return filteredSessions.slice(start, start + pageSize);
}, [filteredSessions, currentPage]);
const totalPages = Math.ceil((filteredSessions?.length || 0) / pageSize);
if (isLoading) {
return <SessionsTableSkeleton />;
}
if (!data || data.sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-1">No sessions found</h3>
<p className="text-muted-foreground">Start using Claude Code to see session history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by profile, model, or session ID..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(0);
}}
className="pl-8"
/>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedSessions.map((session) => (
<TableRow key={session.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">{session.id.slice(0, 8)}...</TableCell>
<TableCell>
<Badge variant="secondary">{session.profile}</Badge>
</TableCell>
<TableCell className="font-medium">{session.model}</TableCell>
<TableCell>{session.duration ? formatDuration(session.duration) : '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
{formatNumber(session.tokens)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<DollarSign className="h-3 w-3 text-muted-foreground" />$
{session.cost.toFixed(4)}
</div>
</TableCell>
<TableCell className="text-right">{session.requests}</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(new Date(session.startTime), { addSuffix: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {currentPage * pageSize + 1} to{' '}
{Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '}
{filteredSessions?.length} sessions
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i;
return (
<Button
key={page}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
className={cn('w-8 h-8 p-0')}
onClick={() => setCurrentPage(page)}
>
{page + 1}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
// Helper functions
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
// Skeleton loading state
function SessionsTableSkeleton() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="h-10 flex-1" />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[80px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[70px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[80px]" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,108 @@
/**
* Usage Summary Cards Component
*
* Displays key metrics in a card grid layout.
* Shows total tokens, cost, requests, and average tokens per request.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UsageSummary } from '@/hooks/use-usage';
interface UsageSummaryCardsProps {
data?: UsageSummary;
isLoading?: boolean;
}
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-8 w-[80px]" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
const cards = [
{
title: 'Total Tokens',
value: data?.totalTokens ?? 0,
icon: FileText,
format: (v: number) => formatNumber(v),
color: 'text-blue-600',
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
},
{
title: 'Total Cost',
value: data?.totalCost ?? 0,
icon: DollarSign,
format: (v: number) => `$${v.toFixed(2)}`,
color: 'text-green-600',
bgColor: 'bg-green-100 dark:bg-green-900/20',
},
{
title: 'Total Requests',
value: data?.totalRequests ?? 0,
icon: Zap,
format: (v: number) => formatNumber(v),
color: 'text-purple-600',
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
},
{
title: 'Avg Tokens/Request',
value: data?.averageTokensPerRequest ?? 0,
icon: TrendingUp,
format: (v: number) => formatNumber(Math.round(v)),
color: 'text-orange-600',
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
},
];
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((card, index) => {
const Icon = card.icon;
return (
<Card key={index} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
</div>
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
<Icon className={cn('h-4 w-4', card.color)} />
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
// Helper to format large numbers
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
@@ -0,0 +1,171 @@
/**
* Usage Trend Chart Component
*
* Displays usage trends over time with tokens and cost.
* Supports daily and monthly granularity with interactive tooltips.
*/
import { useMemo } from 'react';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts';
import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
import type { DateRange } from 'react-day-picker';
import { cn } from '@/lib/utils';
import type { DailyUsage } from '@/hooks/use-usage';
interface UsageTrendChartProps {
data: DailyUsage[];
isLoading?: boolean;
dateRange?: DateRange;
granularity?: 'daily' | 'monthly';
className?: string;
}
export function UsageTrendChart({
data,
isLoading,
granularity = 'daily',
className,
}: Omit<UsageTrendChartProps, 'dateRange'>) {
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return [...data].reverse().map((item) => ({
...item,
dateFormatted: formatDate(item.date, granularity),
costRounded: Number(item.cost.toFixed(4)),
}));
}, [data, granularity]);
if (isLoading) {
return <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0080FF" stopOpacity={0.8} />
<stop offset="95%" stopColor="#0080FF" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00C49F" stopOpacity={0.8} />
<stop offset="95%" stopColor="#00C49F" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="dateFormatted"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
/>
<YAxis
yAxisId="left"
orientation="left"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => formatNumber(value)}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name}:{' '}
{entry.name === 'Tokens'
? formatNumber(Number(entry.value) || 0)
: `$${entry.value}`}
</p>
))}
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
</div>
);
}}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="tokens"
stroke="#0080FF"
strokeWidth={2}
fillOpacity={1}
fill="url(#tokenGradient)"
name="Tokens"
/>
<Area
yAxisId="right"
type="monotone"
dataKey="costRounded"
stroke="#00C49F"
strokeWidth={2}
fillOpacity={1}
fill="url(#costGradient)"
name="Cost"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
// Helper functions
function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string {
const date = new Date(dateStr);
if (granularity === 'monthly') {
return format(date, 'MMM yyyy');
}
// For daily, show shorter format if range is > 30 days
return format(date, 'MMM dd');
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`;
}
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}K`;
}
return num.toLocaleString();
}
+15 -2
View File
@@ -1,5 +1,15 @@
import { Link, useLocation } from 'react-router-dom';
import { Home, Key, Zap, Users, Settings, Activity, FolderOpen, ChevronRight } from 'lucide-react';
import {
Home,
Key,
Zap,
Users,
Settings,
Activity,
FolderOpen,
ChevronRight,
BarChart3,
} from 'lucide-react';
import {
Sidebar,
SidebarContent,
@@ -24,7 +34,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
const navGroups = [
{
title: 'General',
items: [{ path: '/', icon: Home, label: 'Home' }],
items: [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/analytics', icon: BarChart3, label: 'Analytics' },
],
},
{
title: 'Identity & Access',
+1 -1
View File
@@ -11,7 +11,7 @@ export function ConnectionIndicator() {
const { status } = useWebSocket();
const statusConfig = {
connected: { icon: Wifi, color: 'text-green-500', label: 'Connected' },
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' },
disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
};
+1 -1
View File
@@ -15,7 +15,7 @@ interface HealthCheck {
const statusConfig = {
ok: {
icon: CheckCircle,
color: 'text-green-500',
color: 'text-green-600',
bg: 'bg-green-50 dark:bg-green-900/20',
border: 'border-green-200 dark:border-green-800',
},
+162
View File
@@ -0,0 +1,162 @@
import { ChevronRight, Copy, Terminal, Wrench } 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';
import { toast } from 'sonner';
const statusConfig = {
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' },
error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' },
info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' },
};
export function HealthCheckItem({ check }: { check: HealthCheck }) {
const fixMutation = useFixHealth();
const config = statusConfig[check.status];
const [isOpen, setIsOpen] = useState(false);
const hasExpandableContent = check.details || check.fix;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
// Compact single-line display for items without expandable content
if (!hasExpandableContent) {
return (
<div
className={cn(
'group flex items-center gap-3 px-3 py-2 rounded-lg',
'hover:bg-muted/50 transition-colors duration-150',
'border border-transparent hover:border-border/50'
)}
>
{/* Status dot with pulse animation */}
<div className="relative flex items-center justify-center w-4 h-4">
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
{check.status !== 'ok' && (
<div
className={cn('absolute w-2 h-2 rounded-full animate-ping opacity-75', config.dot)}
/>
)}
</div>
{/* Check name */}
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
{/* Status label */}
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
[{config.label}]
</span>
{/* Fix button for fixable non-ok items */}
{check.fixable && check.status !== 'ok' && (
<Button
size="sm"
variant="ghost"
onClick={() => fixMutation.mutate(check.id)}
disabled={fixMutation.isPending}
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
<Wrench className="w-3 h-3 mr-1" />
Fix
</Button>
)}
</div>
);
}
// Expandable display for items with details or fix commands
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'group rounded-lg border transition-all duration-150',
isOpen
? 'border-border bg-muted/30'
: 'border-transparent hover:border-border/50 hover:bg-muted/50'
)}
>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center gap-3 px-3 py-2 text-left">
{/* Status dot */}
<div className="relative flex items-center justify-center w-4 h-4">
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
{check.status !== 'ok' && (
<div
className={cn(
'absolute w-2 h-2 rounded-full animate-ping opacity-75',
config.dot
)}
/>
)}
</div>
{/* Check name */}
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
{/* Status label */}
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
[{config.label}]
</span>
{/* Chevron indicator */}
<ChevronRight
className={cn(
'w-4 h-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-90'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-3 pb-3 pt-1 space-y-2 ml-7">
{/* Message */}
<p className="text-xs text-muted-foreground">{check.message}</p>
{/* Details block */}
{check.details && (
<pre className="text-xs font-mono text-muted-foreground bg-background/50 rounded p-2 overflow-x-auto border border-border/50">
{check.details}
</pre>
)}
{/* Fix command block */}
{check.fix && (
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 bg-background/50 rounded px-2 py-1.5 border border-border/50">
<Terminal className="w-3 h-3 text-muted-foreground shrink-0" />
<code className="text-xs font-mono flex-1 truncate">{check.fix}</code>
<Button
variant="ghost"
size="sm"
onClick={() => check.fix && copyToClipboard(check.fix)}
className="h-5 w-5 p-0"
>
<Copy className="w-3 h-3" />
</Button>
</div>
{check.fixable && check.status !== 'ok' && (
<Button
size="sm"
onClick={() => fixMutation.mutate(check.id)}
disabled={fixMutation.isPending}
className="h-7 px-3 text-xs"
>
<Wrench className="w-3 h-3 mr-1" />
Apply Fix
</Button>
)}
</div>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { cn } from '@/lib/utils';
interface HealthGaugeProps {
passed: number;
total: number;
status: 'ok' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
}
const sizeConfig = {
sm: { dimension: 80, strokeWidth: 6, fontSize: 'text-lg', labelSize: 'text-[10px]' },
md: { dimension: 120, strokeWidth: 8, fontSize: 'text-3xl', labelSize: 'text-xs' },
lg: { dimension: 160, strokeWidth: 10, fontSize: 'text-4xl', labelSize: 'text-sm' },
};
const statusColors = {
ok: { stroke: '#22C55E', glow: 'rgba(34, 197, 94, 0.4)' },
warning: { stroke: '#EAB308', glow: 'rgba(234, 179, 8, 0.4)' },
error: { stroke: '#EF4444', glow: 'rgba(239, 68, 68, 0.4)' },
};
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
const config = sizeConfig[size];
const colors = statusColors[status];
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
const radius = (config.dimension - config.strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
const center = config.dimension / 2;
return (
<div className="relative inline-flex items-center justify-center">
<svg
width={config.dimension}
height={config.dimension}
className="transform -rotate-90"
style={{ filter: `drop-shadow(0 0 8px ${colors.glow})` }}
>
{/* Background track */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={config.strokeWidth}
className="text-muted/30"
/>
{/* Progress arc */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke={colors.stroke}
strokeWidth={config.strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-1000 ease-out"
/>
{/* Animated glow dot at end of arc */}
{percentage > 0 && (
<circle
cx={center + radius * Math.cos((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
cy={center + radius * Math.sin((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
r={config.strokeWidth / 2}
fill={colors.stroke}
className="animate-pulse"
style={{ filter: `drop-shadow(0 0 4px ${colors.glow})` }}
/>
)}
</svg>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={cn('font-mono font-bold tracking-tight', config.fontSize)}>
{percentage}
</span>
<span
className={cn(
'font-mono uppercase tracking-widest text-muted-foreground',
config.labelSize
)}
>
health
</span>
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { HealthCheckItem } from '@/components/health-check-item';
import { type HealthGroup } from '@/hooks/use-health';
import { cn } from '@/lib/utils';
import { useState } from 'react';
const groupIcons: Record<string, typeof Monitor> = {
Monitor,
Settings,
Users,
Shield,
Zap,
};
interface HealthGroupSectionProps {
group: HealthGroup;
defaultOpen?: boolean;
}
export function HealthGroupSection({ group, defaultOpen = true }: HealthGroupSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const Icon = groupIcons[group.icon] || Monitor;
const passed = group.checks.filter((c) => c.status === 'ok').length;
const total = group.checks.length;
const hasErrors = group.checks.some((c) => c.status === 'error');
const hasWarnings = group.checks.some((c) => c.status === 'warning');
const percentage = Math.round((passed / total) * 100);
// Determine status color
const statusColor = hasErrors
? 'text-red-500'
: hasWarnings
? 'text-yellow-500'
: 'text-green-500';
const progressColor = hasErrors ? 'bg-red-500' : hasWarnings ? 'bg-yellow-500' : 'bg-green-500';
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'rounded-lg border transition-all duration-200',
hasErrors ? 'border-red-500/30' : hasWarnings ? 'border-yellow-500/30' : 'border-border'
)}
>
{/* Group header */}
<CollapsibleTrigger asChild>
<button
className={cn(
'w-full flex items-center gap-3 p-3 text-left rounded-lg',
'hover:bg-muted/50 transition-colors duration-150',
isOpen && 'rounded-b-none border-b border-border/50'
)}
>
{/* Group icon */}
<div
className={cn(
'p-1.5 rounded-md',
hasErrors
? 'bg-red-500/10 text-red-500'
: hasWarnings
? 'bg-yellow-500/10 text-yellow-500'
: 'bg-muted text-muted-foreground'
)}
>
<Icon className="w-4 h-4" />
</div>
{/* Group name */}
<span className="flex-1 text-sm font-semibold">{group.name}</span>
{/* Progress indicator (collapsed view) */}
{!isOpen && (
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn('h-full transition-all duration-500', progressColor)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)}
{/* Count badge */}
<span className={cn('font-mono text-xs font-semibold', statusColor)}>
{passed}/{total}
</span>
{/* Chevron */}
<ChevronDown
className={cn(
'w-4 h-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
{/* Checks list */}
<CollapsibleContent>
<div className="p-2 space-y-0.5">
{group.checks.map((check) => (
<HealthCheckItem key={check.id} check={check} />
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { cn } from '@/lib/utils';
interface HealthStatsBarProps {
total: number;
passed: number;
warnings: number;
errors: number;
info: number;
}
interface StatItemProps {
label: string;
value: number;
color: string;
bgColor: string;
}
function StatItem({ label, value, color, bgColor }: StatItemProps) {
return (
<div className="flex items-center gap-2">
<div className={cn('w-2 h-2 rounded-full animate-pulse', bgColor)} />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</span>
<span className={cn('font-mono font-bold text-sm', color)}>{value}</span>
</div>
);
}
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
// Calculate percentages for the progress bar
const passedPct = (passed / total) * 100;
const warningPct = (warnings / total) * 100;
const errorPct = (errors / total) * 100;
const infoPct = (info / total) * 100;
return (
<div className="space-y-3">
{/* Progress bar visualization */}
<div className="h-2 rounded-full overflow-hidden bg-muted/50 flex">
{errorPct > 0 && (
<div
className="h-full bg-red-500 transition-all duration-500"
style={{ width: `${errorPct}%` }}
/>
)}
{warningPct > 0 && (
<div
className="h-full bg-yellow-500 transition-all duration-500"
style={{ width: `${warningPct}%` }}
/>
)}
{infoPct > 0 && (
<div
className="h-full bg-blue-500 transition-all duration-500"
style={{ width: `${infoPct}%` }}
/>
)}
{passedPct > 0 && (
<div
className="h-full bg-green-500 transition-all duration-500"
style={{ width: `${passedPct}%` }}
/>
)}
</div>
{/* Stats row */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Checks
</span>
<span className="font-mono font-bold text-lg">{total}</span>
</div>
<div className="flex items-center gap-4 flex-wrap">
<StatItem label="OK" value={passed} color="text-green-500" bgColor="bg-green-500" />
<StatItem label="WARN" value={warnings} color="text-yellow-500" bgColor="bg-yellow-500" />
<StatItem label="ERR" value={errors} color="text-red-500" bgColor="bg-red-500" />
<StatItem label="INFO" value={info} color="text-blue-500" bgColor="bg-blue-500" />
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Badge } from '@/components/ui/badge';
import { CcsLogo } from '@/components/ccs-logo';
import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
interface HeroSectionProps {
version?: string;
healthStatus?: 'ok' | 'warning' | 'error';
healthPassed?: number;
healthTotal?: number;
}
const statusConfig = {
ok: {
icon: CheckCircle2,
label: 'All Systems Operational',
color: 'text-green-600',
badgeBg: 'bg-green-500/10 text-green-600 border-green-500/20',
},
warning: {
icon: AlertCircle,
label: 'Some Issues Detected',
color: 'text-yellow-500',
badgeBg: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
},
error: {
icon: XCircle,
label: 'Action Required',
color: 'text-red-500',
badgeBg: 'bg-red-500/10 text-red-500 border-red-500/20',
},
};
export function HeroSection({
version = '5.0.0',
healthStatus = 'ok',
healthPassed = 0,
healthTotal = 0,
}: HeroSectionProps) {
const status = statusConfig[healthStatus];
const StatusIcon = status.icon;
return (
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30 p-6">
{/* 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 className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Left: Logo and Welcome */}
<div className="flex items-center gap-4">
<CcsLogo size="lg" showText={false} />
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">CCS Config</h1>
<Badge variant="outline" className="font-mono text-xs">
v{version}
</Badge>
</div>
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
</div>
</div>
{/* Right: Health Status */}
<div className={cn('flex items-center gap-3 px-4 py-2 rounded-lg border', status.badgeBg)}>
<StatusIcon className={cn('w-5 h-5', status.color)} />
<div>
<p className={cn('text-sm font-medium', status.color)}>{status.label}</p>
{healthTotal > 0 && (
<p className="text-xs opacity-80">
{healthPassed}/{healthTotal} checks passed
</p>
)}
</div>
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Copy, Check, Terminal } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
interface CommandSnippet {
label: string;
command: string;
description: string;
}
const defaultSnippets: CommandSnippet[] = [
{
label: 'Start Default',
command: 'ccs',
description: 'Launch Claude with default profile',
},
{
label: 'GLM Profile',
command: 'ccs glm',
description: 'Switch to GLM model',
},
{
label: 'Health Check',
command: 'ccs doctor',
description: 'Run system diagnostics',
},
{
label: 'Delegate Task',
command: 'ccs glm -p "your task"',
description: 'Delegate to GLM profile',
},
];
interface QuickCommandsProps {
snippets?: CommandSnippet[];
}
export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const copyToClipboard = async (text: string, index: number) => {
await navigator.clipboard.writeText(text);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Terminal className="w-5 h-5 text-muted-foreground" />
Quick Commands
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{snippets.map((snippet, index) => (
<div
key={index}
className={cn(
'group flex items-center justify-between gap-2 px-3 py-2',
'rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors'
)}
>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground">{snippet.label}</p>
<code className="text-sm font-mono font-medium truncate block">
{snippet.command}
</code>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => copyToClipboard(snippet.command, index)}
title={snippet.description}
>
{copiedIndex === index ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}
+11 -1
View File
@@ -145,7 +145,17 @@ function SettingsDialogContent({
};
const isSensitiveKey = (key: string): boolean => {
return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET');
// Pattern-based matching for sensitive keys (same as backend)
const sensitivePatterns = [
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
/_API_KEY$/, // Keys ending with _API_KEY
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
/^API_KEY$/, // Exact match for API_KEY
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
/_SECRET$/, // Keys ending with _SECRET
/^SECRET$/, // Exact match for SECRET
];
return sensitivePatterns.some((pattern) => pattern.test(key));
};
return (
+61 -8
View File
@@ -1,33 +1,86 @@
import { Card, CardContent } from '@/components/ui/card';
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StatCardProps {
title: string;
value: number | string;
icon: LucideIcon;
color?: string;
variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
subtitle?: string;
onClick?: () => void;
}
const variantStyles = {
default: {
iconBg: 'bg-muted',
iconColor: 'text-muted-foreground',
borderHover: 'hover:border-primary',
},
success: {
iconBg: 'bg-green-500/10',
iconColor: 'text-green-600',
borderHover: 'hover:border-green-500/50',
},
warning: {
iconBg: 'bg-yellow-500/10',
iconColor: 'text-yellow-500',
borderHover: 'hover:border-yellow-500/50',
},
error: {
iconBg: 'bg-red-500/10',
iconColor: 'text-red-500',
borderHover: 'hover:border-red-500/50',
},
accent: {
iconBg: 'bg-accent/10',
iconColor: 'text-accent',
borderHover: 'hover:border-accent/50',
},
};
export function StatCard({
title,
value,
icon: Icon,
color = 'text-primary',
color,
variant = 'default',
subtitle,
onClick,
}: StatCardProps) {
const styles = variantStyles[variant];
const iconColorClass = color || styles.iconColor;
return (
<Card
className={`cursor-pointer hover:shadow-md transition-shadow ${onClick ? 'hover:border-primary' : ''}`}
className={cn(
'cursor-pointer transition-all duration-200',
'hover:shadow-lg hover:-translate-y-0.5',
styles.borderHover,
onClick && 'active:scale-[0.98]'
)}
onClick={onClick}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-4">
{/* Icon Container with background */}
<div
className={cn(
'flex items-center justify-center w-12 h-12 rounded-lg transition-transform duration-200',
styles.iconBg,
'group-hover:scale-105'
)}
>
<Icon className={cn('w-6 h-6', iconColorClass)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm text-muted-foreground truncate">{title}</p>
<p className={cn('text-2xl font-bold font-mono', iconColorClass)}>{value}</p>
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
<Icon className={`w-8 h-8 ${color} opacity-20`} />
</div>
</CardContent>
</Card>
+1 -1
View File
@@ -1,7 +1,7 @@
import { cva } from 'class-variance-authority';
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-1 focus-visible:ring-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+2 -2
View File
@@ -59,7 +59,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground focus:[&_svg:not([class*='text-'])]:text-current relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -180,7 +180,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus:[&_svg:not([class*='text-'])]:text-current flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
+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'],
+3
View File
@@ -1,8 +1,11 @@
import { useQuery } from '@tanstack/react-query';
interface Overview {
version: string;
profiles: number;
cliproxy: number;
cliproxyVariants: number;
cliproxyProviders: number;
accounts: number;
health: {
status: 'ok' | 'warning' | 'error';
+222
View File
@@ -0,0 +1,222 @@
/**
* React Query hooks for usage analytics
* Phase 01: Analytics Page Implementation
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
// Types
export interface UsageSummary {
totalTokens: number;
totalCost: number;
totalRequests: number;
averageTokensPerRequest: number;
dailyUsage: DailyUsage[];
}
export interface DailyUsage {
date: string;
tokens: number;
cost: number;
requests: number;
}
export interface ModelUsage {
model: string;
tokens: number;
cost: number;
requests: number;
percentage: number;
}
export interface Session {
id: string;
startTime: string;
endTime?: string;
duration?: number;
tokens: number;
cost: number;
requests: number;
profile: string;
model: string;
}
export interface PaginatedSessions {
sessions: Session[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface MonthlyUsage {
month: string;
tokens: number;
cost: number;
requests: number;
}
export interface UsageQueryOptions {
startDate?: Date;
endDate?: Date;
profile?: string;
limit?: number;
offset?: number;
}
export interface UsageStatus {
lastFetch: number | null;
cacheSize: number;
}
// API
const BASE_URL = '/api';
/**
* Convert Date to YYYYMMDD format for API
*/
function formatDateForApi(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
export const usageApi = {
summary: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<UsageSummary>(`/usage/summary?${params}`);
},
trends: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<DailyUsage[]>(`/usage/daily?${params}`);
},
models: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
return request<ModelUsage[]>(`/usage/models?${params}`);
},
sessions: (options?: UsageQueryOptions) => {
const params = new URLSearchParams();
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
if (options?.profile) params.append('profile', options.profile);
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
return request<PaginatedSessions>(`/usage/sessions?${params}`);
},
monthly: (months?: number, profile?: string) => {
const params = new URLSearchParams();
if (months) params.append('months', months.toString());
if (profile) params.append('profile', profile);
return request<MonthlyUsage[]>(`/usage/monthly?${params}`);
},
/** Clear server-side usage cache and force fresh data fetch */
refresh: async (): Promise<void> => {
const res = await fetch(`${BASE_URL}/usage/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
throw new Error('Failed to refresh usage cache');
}
},
/** Get cache status including last fetch timestamp */
status: () => request<UsageStatus>('/usage/status'),
};
// Helper function to match existing API client pattern
async function request<T>(url: string): Promise<T> {
const BASE_URL = '/api';
const res = await fetch(`${BASE_URL}${url}`, {
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || res.statusText);
}
const result = await res.json();
return result.data || result; // Extract data property if it exists
}
// Hooks
export function useUsageSummary(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'summary', options],
queryFn: () => usageApi.summary(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useUsageTrends(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'trends', options],
queryFn: () => usageApi.trends(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useModelUsage(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'models', options],
queryFn: () => usageApi.models(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useSessions(options?: UsageQueryOptions) {
return useQuery({
queryKey: ['usage', 'sessions', options],
queryFn: () => usageApi.sessions(options),
staleTime: 60 * 1000, // 1 minute
});
}
export function useMonthlyUsage(months?: number, profile?: string) {
return useQuery({
queryKey: ['usage', 'monthly', months, profile],
queryFn: () => usageApi.monthly(months, profile),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to refresh all usage data
* Clears server-side cache and invalidates React Query cache
*/
export function useRefreshUsage() {
const queryClient = useQueryClient();
const refresh = useCallback(async () => {
// Clear server-side cache
await usageApi.refresh();
// Invalidate all usage queries in React Query
await queryClient.invalidateQueries({ queryKey: ['usage'] });
}, [queryClient]);
return refresh;
}
/**
* Hook to get usage cache status
* Returns last fetch timestamp for "Last updated" UI indicator
*/
export function useUsageStatus() {
return useQuery({
queryKey: ['usage', 'status'],
queryFn: () => usageApi.status(),
staleTime: 10 * 1000, // 10 seconds - poll frequently for updates
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
});
}
+4 -3
View File
@@ -120,11 +120,12 @@
}
body {
@apply bg-background text-foreground;
font-family: 'Fira Sans', system-ui, sans-serif;
font-family: 'IBM Plex Sans', 'Fira Sans', system-ui, sans-serif;
margin: 0;
}
code,
pre {
font-family: 'Fira Code', monospace;
pre,
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
}
+361
View File
@@ -0,0 +1,361 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts and tables.
* Features daily/monthly views, trend charts, model breakdown, and session history.
*/
import { useState, useMemo } from 'react';
import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionsTable } from '@/components/analytics/sessions-table';
import { TrendingUp, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useModelUsage,
useSessions,
useRefreshUsage,
useUsageStatus,
} from '@/hooks/use-usage';
type ViewMode = 'daily' | 'monthly' | 'sessions';
export function AnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [viewMode, setViewMode] = useState<ViewMode>('daily');
const [isRefreshing, setIsRefreshing] = useState(false);
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
};
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
...apiOptions,
limit: 50,
});
const { data: status } = useUsageStatus();
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
return (
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-2 h-8"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs
value={viewMode}
onValueChange={(v) => setViewMode(v as ViewMode)}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="grid w-full grid-cols-3 h-9 mb-4">
<TabsTrigger value="daily" className="text-xs">
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="text-xs">
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="text-xs">
Sessions
</TabsTrigger>
</TabsList>
<div className="flex-1 min-h-0">
{/* Daily View */}
<TabsContent value="daily" className="flex flex-col gap-4 m-0 h-full overflow-hidden">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
className="h-full"
/>
</CardContent>
</Card>
{/* Bottom Row - Model Usage & Cost */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
<div className="flex w-full h-full items-center">
<div className="flex-1 h-full min-w-0">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full"
/>
</div>
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
{models?.slice(0, 8).map((model) => (
<div key={model.model} className="flex items-center gap-2 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex justify-between items-baseline gap-2">
<span className="font-medium truncate" title={model.model}>
{model.model}
</span>
<span className="text-[10px] text-muted-foreground shrink-0">
{model.percentage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
{isModelsLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-2">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<div
key={model.model}
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span
className="font-medium truncate max-w-[150px]"
title={model.model}
>
{model.model}
</span>
</div>
<span className="text-muted-foreground whitespace-nowrap font-mono">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Monthly View */}
<TabsContent value="monthly" className="m-0 h-full">
<Card className="h-full flex flex-col">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Calendar className="w-4 h-4" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
className="h-full"
/>
</CardContent>
</Card>
</TabsContent>
{/* Sessions View */}
<TabsContent value="sessions" className="m-0 h-full">
<Card className="h-full flex flex-col">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
Session History
</CardTitle>
</CardHeader>
<CardContent className="p-0 flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
</div>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
// Helper function to generate consistent colors for models
function getModelColor(model: string): string {
const colors = [
'#0080FF',
'#00C49F',
'#FFBB28',
'#FF8042',
'#8884D8',
'#82CA9D',
'#FFC658',
'#8DD1E1',
'#D084D0',
'#87D068',
];
let hash = 0;
for (let i = 0; i < model.length; i++) {
hash = model.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
export function AnalyticsSkeleton() {
return (
<div className="space-y-4 h-full overflow-hidden">
{/* Usage Trends Skeleton */}
<Card className="flex flex-col min-h-[300px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Model Usage Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex w-full h-full items-center">
<div className="flex-1 flex justify-center">
<Skeleton className="h-[180px] w-[180px] rounded-full" />
</div>
<div className="w-[140px] shrink-0 pl-2 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Cost Breakdown Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+234 -34
View File
@@ -1,56 +1,256 @@
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
import { HealthCard } from '@/components/health-card';
import { useHealth } from '@/hooks/use-health';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
import { HealthGauge } from '@/components/health-gauge';
import { HealthStatsBar } from '@/components/health-stats-bar';
import { HealthGroupSection } from '@/components/health-group-section';
import { useHealth, type HealthGroup } from '@/hooks/use-health';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { useEffect, useState } from 'react';
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
if (summary.errors > 0) return 'error';
if (summary.warnings > 0) return 'warning';
return 'ok';
}
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
return [...groups].sort((a, b) => {
const aErrors = a.checks.filter((c) => c.status === 'error').length;
const bErrors = b.checks.filter((c) => c.status === 'error').length;
const aWarnings = a.checks.filter((c) => c.status === 'warning').length;
const bWarnings = b.checks.filter((c) => c.status === 'warning').length;
if (aErrors !== bErrors) return bErrors - aErrors;
return bWarnings - aWarnings;
});
}
function TerminalHeader() {
return (
<div className="font-mono text-sm text-muted-foreground flex items-center gap-2">
<span className="text-green-500">$</span>
<span>ccs doctor</span>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
{/* Hero skeleton */}
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
<div className="flex items-center gap-6">
<Skeleton className="w-[120px] h-[120px] rounded-full" />
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
</div>
{/* Stats skeleton */}
<Skeleton className="h-16 w-full rounded-lg" />
{/* Groups skeleton */}
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-20 rounded-lg" />
))}
</div>
</div>
);
}
export function HealthPage() {
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleTimeString();
// Use dataUpdatedAt directly instead of storing in state
const lastRefresh = dataUpdatedAt;
// Update relative time display by forcing re-render every second
const [tick, setTick] = useState(0);
useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, []);
// Consume tick to prevent unused variable warning
void tick;
const copyDoctorCommand = () => {
navigator.clipboard.writeText('ccs doctor');
toast.success('Copied to clipboard');
};
const handleRefresh = () => {
refetch();
toast.info('Refreshing health checks...');
};
if (isLoading && !data) {
return <LoadingSkeleton />;
}
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
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 - Terminal-inspired control center header */}
<div
className={cn(
'relative overflow-hidden rounded-xl border p-6',
'bg-gradient-to-br from-background via-background to-muted/30'
)}
>
{/* Subtle scan lines effect */}
<div
className="absolute inset-0 opacity-[0.02] pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)`,
}}
/>
{/* Grid pattern background */}
<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 className="relative flex flex-col md:flex-row items-start md:items-center gap-6">
{/* Left: Health Gauge - excludes info from percentage */}
{data && (
<div className="shrink-0">
<HealthGauge
passed={data.summary.passed}
total={data.summary.total - data.summary.info}
status={overallStatus}
size="md"
/>
</div>
)}
{/* Center: Title and status */}
<div className="flex-1 space-y-3">
{/* Terminal prompt */}
<TerminalHeader />
{/* Main title */}
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold font-mono tracking-tight">System Health</h1>
{data?.version && (
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
build {data.version}
</Badge>
)}
</div>
{/* Status message */}
<div className="flex items-center gap-2 text-sm">
<Cpu className="w-4 h-4 text-muted-foreground" />
<span className="text-muted-foreground">Last scan:</span>
<span className="font-mono">
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'}
</span>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">Auto-refresh:</span>
<span className="font-mono text-green-500">30s</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={copyDoctorCommand}
className="gap-2 font-mono text-xs"
>
<Terminal className="w-3 h-3" />
ccs doctor
<Copy className="w-3 h-3 opacity-50" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
className="gap-2"
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
<span className="hidden sm:inline">Refresh</span>
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
R
</kbd>
</Button>
</div>
</div>
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Stats Bar */}
{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="rounded-lg border bg-card p-4">
<HealthStatsBar
total={data.summary.total}
passed={data.summary.passed}
warnings={data.summary.warnings}
errors={data.summary.errors}
info={data.summary.info}
/>
</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 - Single column layout */}
{sortedGroups.length > 0 && (
<div className="space-y-3">
{sortedGroups.map((group, index) => (
<HealthGroupSection
key={group.id}
group={group}
defaultOpen={
index < 2 ||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
}
/>
))}
</div>
)}
{/* Footer metadata */}
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
<div className="flex items-center gap-4">
<span>
Version <span className="font-mono">{data?.version ?? '--'}</span>
</span>
<span>
Platform{' '}
<span className="font-mono">
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
</span>
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span>Live monitoring active</span>
</div>
</div>
</div>
);
}
+86 -58
View File
@@ -2,6 +2,8 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatCard } from '@/components/stat-card';
import { HeroSection } from '@/components/hero-section';
import { QuickCommands } from '@/components/quick-commands';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
@@ -14,14 +16,15 @@ import {
BookOpen,
FolderOpen,
AlertTriangle,
ArrowRight,
} from 'lucide-react';
import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared';
const HEALTH_COLORS = {
ok: 'text-green-500',
warning: 'text-yellow-500',
error: 'text-red-500',
const HEALTH_VARIANTS = {
ok: 'success',
warning: 'warning',
error: 'error',
} as const;
export function HomePage() {
@@ -32,44 +35,48 @@ export function HomePage() {
if (isOverviewLoading || isSharedLoading) {
return (
<div className="p-6 space-y-6">
<div>
<Skeleton className="h-9 w-[200px] mb-2" />
<Skeleton className="h-5 w-[320px]" />
{/* 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>
<Skeleton className="h-7 w-[180px] mb-2" />
<Skeleton className="h-4 w-[220px]" />
</div>
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-12" />
</div>
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-7 w-16" />
<div key={i} className="space-y-3 p-4 border rounded-xl">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1">
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-7 w-12" />
</div>
</div>
</div>
))}
</div>
<div className="border rounded-lg p-6 space-y-4">
<Skeleton className="h-6 w-[180px]" />
{/* Quick Actions Skeleton */}
<div className="border rounded-xl p-6 space-y-4">
<Skeleton className="h-6 w-[140px]" />
<div className="flex flex-wrap gap-3">
<Skeleton className="h-10 w-[140px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[150px] rounded-md" />
</div>
</div>
<div className="border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-[140px]" />
<Skeleton className="h-8 w-[80px] rounded-md" />
</div>
<div className="flex gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-16" />
</div>
{/* Quick Commands Skeleton */}
<div className="border rounded-xl p-6 space-y-4">
<Skeleton className="h-6 w-[160px]" />
<div className="grid grid-cols-2 gap-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 rounded-lg" />
))}
</div>
</div>
@@ -77,13 +84,21 @@ export function HomePage() {
);
}
const healthVariant = overview?.health
? HEALTH_VARIANTS[overview.health.status as keyof typeof HEALTH_VARIANTS]
: undefined;
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Welcome to CCS Config</h1>
<p className="text-muted-foreground">Manage your Claude Code Switch configuration</p>
</div>
{/* Hero Section */}
<HeroSection
version={overview?.version}
healthStatus={overview?.health?.status}
healthPassed={overview?.health?.passed}
healthTotal={overview?.health?.total}
/>
{/* Configuration Warning */}
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
@@ -98,74 +113,87 @@ export function HomePage() {
title="API Profiles"
value={overview?.profiles ?? 0}
icon={Key}
variant="accent"
subtitle="Settings-based"
onClick={() => navigate('/api')}
/>
<StatCard
title="CLIProxy Variants"
title="CLIProxy"
value={overview?.cliproxy ?? 0}
icon={Zap}
variant="accent"
subtitle={`${overview?.cliproxyProviders ?? 0} auth + ${overview?.cliproxyVariants ?? 0} custom`}
onClick={() => navigate('/cliproxy')}
/>
<StatCard
title="Accounts"
value={overview?.accounts ?? 0}
icon={Users}
variant="default"
subtitle="Isolated instances"
onClick={() => navigate('/accounts')}
/>
<StatCard
title="Health"
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity}
color={
overview?.health
? HEALTH_COLORS[overview.health.status as keyof typeof HEALTH_COLORS]
: undefined
}
variant={healthVariant}
subtitle="System checks"
onClick={() => navigate('/health')}
/>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button onClick={() => navigate('/api')}>
<Plus className="w-4 h-4 mr-2" /> New Profile
<Button onClick={() => navigate('/api')} className="gap-2">
<Plus className="w-4 h-4" /> New Profile
</Button>
<Button variant="outline" onClick={() => navigate('/health')}>
<Stethoscope className="w-4 h-4 mr-2" /> Run Doctor
<Button variant="outline" onClick={() => navigate('/health')} className="gap-2">
<Stethoscope className="w-4 h-4" /> Run Doctor
</Button>
<Button variant="outline" asChild>
<Button variant="outline" asChild className="gap-2">
<a href="https://github.com/kaitranntt/ccs" target="_blank" rel="noopener noreferrer">
<BookOpen className="w-4 h-4 mr-2" /> Documentation
<BookOpen className="w-4 h-4" /> Documentation
</a>
</Button>
</CardContent>
</Card>
{/* Quick Commands */}
<QuickCommands />
{/* Shared Data Summary */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Shared Data</CardTitle>
<Button variant="ghost" size="sm" onClick={() => navigate('/shared')}>
View All
<Card className="group hover:border-primary/50 transition-colors">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-muted-foreground" />
Shared Data
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/shared')}
className="gap-1 opacity-70 group-hover:opacity-100 transition-opacity"
>
View All <ArrowRight className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent>
<div className="flex gap-6 text-sm">
<div className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">{shared?.commands ?? 0}</span>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
<span className="text-xl font-bold font-mono">{shared?.commands ?? 0}</span>
<span className="text-muted-foreground">Commands</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{shared?.skills ?? 0}</span>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
<span className="text-xl font-bold font-mono">{shared?.skills ?? 0}</span>
<span className="text-muted-foreground">Skills</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{shared?.agents ?? 0}</span>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
<span className="text-xl font-bold font-mono">{shared?.agents ?? 0}</span>
<span className="text-muted-foreground">Agents</span>
</div>
</div>
+2
View File
@@ -11,3 +11,5 @@ export { SettingsPage } from './settings';
export { HealthPage } from './health';
export { SharedPage } from './shared';
export { AnalyticsPage } from './analytics';