diff --git a/CLAUDE.md b/CLAUDE.md index 462bee76..cc88bcd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,12 @@ bun run format # Auto-fix formatting - `lib/` - Native shell scripts (bash, PowerShell) - `ui/` - React dashboard (Vite + React 19 + shadcn/ui) +**Development server (ALWAYS use for testing UI changes):** +```bash +bun run dev # Start dev server with hot reload (http://localhost:3000) +``` +**IMPORTANT:** Use `bun run dev` at CCS root level for always up-to-date code. Do NOT use `ccs config` during development as it uses the globally installed (outdated) version. + ## UI Quality Gates (React Dashboard) **The ui/ directory has IDENTICAL quality gates to the main project.** diff --git a/README.md b/README.md index a0973777..290bea66 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ Features a modern React 19 dashboard with real-time updates. [![License](https://img.shields.io/badge/license-MIT-C15F3C?style=for-the-badge)](LICENSE) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey?style=for-the-badge)]() +[![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) + [![npm](https://img.shields.io/npm/v/@kaitranntt/ccs?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@kaitranntt/ccs) [![React](https://img.shields.io/badge/React-19-61DAFB?style=for-the-badge&logo=react)](https://react.dev/) [![TypeScript](https://img.shields.io/badge/TypeScript-100%25-3178C6?style=for-the-badge&logo=typescript)](https://www.typescriptlang.org/) -[![PoweredBy](https://img.shields.io/badge/PoweredBy-ClaudeKit-C15F3C?style=for-the-badge)](https://claudekit.cc?ref=HMNKXOHN) **Languages**: [English](README.md) | [Tiếng Việt](docs/vi/README.md) | [日本語](docs/ja/README.md) diff --git a/VERSION b/VERSION index c68d476c..4cb144ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0 +5.11.0-dev.8 diff --git a/bun.lock b/bun.lock index 3e11076c..f541b922 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 83ec23b6..8611f590 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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. --- diff --git a/package.json b/package.json index ec80703c..8aee2756 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index e8b88b52..ae8e9530 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -48,8 +48,18 @@ export const MODEL_CATALOG: Partial> = 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> = 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: { diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 4201443f..c875b8bd 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -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: diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index fb5bbb52..50395af2 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -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')); diff --git a/src/types/external.d.ts b/src/types/external.d.ts index ba86374a..c1e87455 100644 --- a/src/types/external.d.ts +++ b/src/types/external.d.ts @@ -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; + export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise; + export function loadSessionData(options?: DataLoaderOptions): Promise; +} + +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[]; diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 00000000..ef232bd4 --- /dev/null +++ b/src/utils/version.ts @@ -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'; +} diff --git a/src/web-server/health-service.ts b/src/web-server/health-service.ts index d2f4ab7d..469d0539 100644 --- a/src/web-server/health-service.ts +++ b/src/web-server/health-service.ts @@ -1,102 +1,219 @@ /** * Health Check Service (Phase 06) * - * Runs health checks for CCS dashboard: Claude CLI, config files, CLIProxy binary. + * Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output. + * Groups: System, Configuration, Profiles & Delegation, System Health, CLIProxy */ import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import { execSync } from 'child_process'; import { getCcsDir, getConfigPath } from '../utils/config-manager'; -import { isCLIProxyInstalled, getInstalledCliproxyVersion, getCLIProxyPath } from '../cliproxy'; +import { + isCLIProxyInstalled, + getInstalledCliproxyVersion, + getCLIProxyPath, + getConfigPath as getCliproxyConfigPath, + getAllAuthStatus, + CLIPROXY_DEFAULT_PORT, +} from '../cliproxy'; +import { getClaudeCliInfo } from '../utils/claude-detector'; +import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import packageJson from '../../package.json'; export interface HealthCheck { id: string; name: string; - status: 'ok' | 'warning' | 'error'; + status: 'ok' | 'warning' | 'error' | 'info'; message: string; details?: string; + fix?: string; fixable?: boolean; } +export interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + export interface HealthReport { timestamp: number; - checks: HealthCheck[]; + version: string; + groups: HealthGroup[]; + checks: HealthCheck[]; // Flat list for backward compatibility summary: { total: number; passed: number; warnings: number; errors: number; + info: number; }; } /** * Run all health checks and return report */ -export function runHealthChecks(): HealthReport { - const checks: HealthCheck[] = []; +export async function runHealthChecks(): Promise { + const homedir = os.homedir(); + const ccsDir = getCcsDir(); + const claudeDir = path.join(homedir, '.claude'); + const version = packageJson.version; - // Check 1: Claude CLI - checks.push(checkClaudeCli()); + const groups: HealthGroup[] = []; - // Check 2: Config file - checks.push(checkConfigFile()); + // Group 1: System + const systemChecks: HealthCheck[] = []; + systemChecks.push(await checkClaudeCli()); + systemChecks.push(checkCcsDirectory(ccsDir)); + groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks }); - // Check 3: Profiles file - checks.push(checkProfilesFile()); + // Group 2: Configuration + const configChecks: HealthCheck[] = []; + configChecks.push(checkConfigFile()); + configChecks.push(...checkSettingsFiles(ccsDir)); + configChecks.push(checkClaudeSettings(claudeDir)); + groups.push({ + id: 'configuration', + name: 'Configuration', + icon: 'Settings', + checks: configChecks, + }); - // Check 4: CLIProxy binary - checks.push(checkCliproxy()); + // Group 3: Profiles & Delegation + const profileChecks: HealthCheck[] = []; + profileChecks.push(checkProfiles(ccsDir)); + profileChecks.push(checkInstances(ccsDir)); + profileChecks.push(checkDelegation(ccsDir)); + groups.push({ + id: 'profiles', + name: 'Profiles & Delegation', + icon: 'Users', + checks: profileChecks, + }); - // Check 5: CCS directory - checks.push(checkCcsDirectory()); + // Group 4: System Health + const healthChecks: HealthCheck[] = []; + healthChecks.push(checkPermissions(ccsDir)); + healthChecks.push(checkCcsSymlinks()); + healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir)); + groups.push({ + id: 'system-health', + name: 'System Health', + icon: 'Shield', + checks: healthChecks, + }); + + // Group 5: CLIProxy + const cliproxyChecks: HealthCheck[] = []; + cliproxyChecks.push(checkCliproxyBinary()); + cliproxyChecks.push(checkCliproxyConfig()); + cliproxyChecks.push(...checkOAuthProviders()); + cliproxyChecks.push(await checkCliproxyPort()); + groups.push({ + id: 'cliproxy', + name: 'CLIProxy (OAuth)', + icon: 'Zap', + checks: cliproxyChecks, + }); + + // Flatten all checks for backward compatibility + const allChecks = groups.flatMap((g) => g.checks); // Calculate summary const summary = { - total: checks.length, - passed: checks.filter((c) => c.status === 'ok').length, - warnings: checks.filter((c) => c.status === 'warning').length, - errors: checks.filter((c) => c.status === 'error').length, + total: allChecks.length, + passed: allChecks.filter((c) => c.status === 'ok').length, + warnings: allChecks.filter((c) => c.status === 'warning').length, + errors: allChecks.filter((c) => c.status === 'error').length, + info: allChecks.filter((c) => c.status === 'info').length, }; return { timestamp: Date.now(), - checks, + version, + groups, + checks: allChecks, summary, }; } -function checkClaudeCli(): HealthCheck { +// Check 1: Claude CLI +async function checkClaudeCli(): Promise { + const cliInfo = getClaudeCliInfo(); + + if (!cliInfo) { + return { + id: 'claude-cli', + name: 'Claude CLI', + status: 'error', + message: 'Not found in PATH', + fix: 'Install: npm install -g @anthropic-ai/claude-code', + }; + } + try { const version = execSync('claude --version', { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); + + const versionMatch = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = versionMatch ? versionMatch[1] : 'unknown'; + return { id: 'claude-cli', name: 'Claude CLI', status: 'ok', - message: `Installed: ${version}`, + message: `v${versionStr}`, + details: cliInfo.path, }; } catch { return { id: 'claude-cli', name: 'Claude CLI', status: 'error', - message: 'Not found in PATH', - details: 'Install: npm install -g @anthropic-ai/claude-code', + message: 'Not working', + details: cliInfo.path, + fix: 'Reinstall Claude CLI', }; } } +// Check 2: CCS Directory +function checkCcsDirectory(ccsDir: string): HealthCheck { + if (fs.existsSync(ccsDir)) { + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'ok', + message: 'Exists', + details: '~/.ccs/', + }; + } + + return { + id: 'ccs-dir', + name: 'CCS Directory', + status: 'error', + message: 'Not found', + details: ccsDir, + fix: 'Run: npm install -g @kaitranntt/ccs --force', + fixable: true, + }; +} + +// Check 3: Config file function checkConfigFile(): HealthCheck { const configPath = getConfigPath(); if (!fs.existsSync(configPath)) { return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'warning', message: 'Not found', details: configPath, @@ -109,15 +226,15 @@ function checkConfigFile(): HealthCheck { JSON.parse(content); return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'ok', - message: 'Valid JSON', + message: 'Valid', details: configPath, }; } catch { return { id: 'config-file', - name: 'Config File', + name: 'config.json', status: 'error', message: 'Invalid JSON', details: configPath, @@ -125,84 +242,508 @@ function checkConfigFile(): HealthCheck { } } -function checkProfilesFile(): HealthCheck { - const ccsDir = getCcsDir(); - const profilesPath = path.join(ccsDir, 'profiles.json'); +// Check 4: Settings files (glm, kimi) +function checkSettingsFiles(ccsDir: string): HealthCheck[] { + const checks: HealthCheck[] = []; + const files = [ + { name: 'glm.settings.json', profile: 'glm' }, + { name: 'kimi.settings.json', profile: 'kimi' }, + ]; - if (!fs.existsSync(profilesPath)) { + const { DelegationValidator } = require('../utils/delegation-validator'); + + for (const file of files) { + const filePath = path.join(ccsDir, file.name); + + if (!fs.existsSync(filePath)) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'info', + message: 'Not configured', + details: filePath, + }); + continue; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + JSON.parse(content); + + const validation = DelegationValidator.validate(file.profile); + + if (validation.valid) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Key configured', + details: filePath, + }); + } else if (validation.error && validation.error.includes('placeholder')) { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'warning', + message: 'Placeholder key', + details: filePath, + }); + } else { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'ok', + message: 'Valid JSON', + details: filePath, + }); + } + } catch { + checks.push({ + id: `settings-${file.profile}`, + name: file.name, + status: 'error', + message: 'Invalid JSON', + details: filePath, + }); + } + } + + return checks; +} + +// Check 5: Claude settings +function checkClaudeSettings(claudeDir: string): HealthCheck { + const settingsPath = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(settingsPath)) { return { - id: 'profiles-file', - name: 'Profiles Registry', + id: 'claude-settings', + name: '~/.claude/settings.json', status: 'warning', - message: 'Not found (will be created on first account)', - details: profilesPath, - fixable: true, + message: 'Not found', + fix: 'Run: claude /login', }; } try { - const content = fs.readFileSync(profilesPath, 'utf8'); + const content = fs.readFileSync(settingsPath, 'utf8'); JSON.parse(content); return { - id: 'profiles-file', - name: 'Profiles Registry', + id: 'claude-settings', + name: '~/.claude/settings.json', status: 'ok', message: 'Valid', - details: profilesPath, }; } catch { return { - id: 'profiles-file', - name: 'Profiles Registry', - status: 'error', + id: 'claude-settings', + name: '~/.claude/settings.json', + status: 'warning', message: 'Invalid JSON', - details: profilesPath, + fix: 'Run: claude /login', }; } } -function checkCliproxy(): HealthCheck { +// Check 6: Profiles +function checkProfiles(ccsDir: string): HealthCheck { + const configPath = path.join(ccsDir, 'config.json'); + + if (!fs.existsSync(configPath)) { + return { + id: 'profiles', + name: 'Profiles', + status: 'info', + message: 'config.json not found', + }; + } + + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + if (!config.profiles || typeof config.profiles !== 'object') { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: 'Missing profiles object', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const profileCount = Object.keys(config.profiles).length; + const profileNames = Object.keys(config.profiles).join(', '); + + return { + id: 'profiles', + name: 'Profiles', + status: 'ok', + message: `${profileCount} configured`, + details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames, + }; + } catch (e) { + return { + id: 'profiles', + name: 'Profiles', + status: 'error', + message: (e as Error).message, + }; + } +} + +// Check 7: Instances +function checkInstances(ccsDir: string): HealthCheck { + const instancesDir = path.join(ccsDir, 'instances'); + + if (!fs.existsSync(instancesDir)) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + if (instances.length === 0) { + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: 'No account profiles', + }; + } + + return { + id: 'instances', + name: 'Instances', + status: 'ok', + message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`, + }; +} + +// Check 8: Delegation +function checkDelegation(ccsDir: string): HealthCheck { + const ccsClaudeCommandsDir = path.join(ccsDir, '.claude', 'commands'); + const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md')); + const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md')); + + if (!hasCcsCommand || !hasContinueCommand) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'Not installed', + fix: 'Run: npm install -g @kaitranntt/ccs --force', + }; + } + + const { DelegationValidator } = require('../utils/delegation-validator'); + const readyProfiles: string[] = []; + + for (const profile of ['glm', 'kimi']) { + const validation = DelegationValidator.validate(profile); + if (validation.valid) { + readyProfiles.push(profile); + } + } + + if (readyProfiles.length === 0) { + return { + id: 'delegation', + name: 'Delegation', + status: 'warning', + message: 'No profiles ready', + fix: 'Configure profiles with valid API keys', + }; + } + + return { + id: 'delegation', + name: 'Delegation', + status: 'ok', + message: `${readyProfiles.length} profiles ready`, + details: readyProfiles.join(', '), + }; +} + +// Check 9: Permissions +function checkPermissions(ccsDir: string): HealthCheck { + const testFile = path.join(ccsDir, '.permission-test'); + + try { + fs.writeFileSync(testFile, 'test', 'utf8'); + fs.unlinkSync(testFile); + return { + id: 'permissions', + name: 'Permissions', + status: 'ok', + message: 'Write access verified', + }; + } catch { + return { + id: 'permissions', + name: 'Permissions', + status: 'error', + message: 'Cannot write to ~/.ccs/', + fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude', + }; + } +} + +// Check 10: CCS Symlinks +function checkCcsSymlinks(): HealthCheck { + try { + const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager'); + const manager = new ClaudeSymlinkManager(); + const health = manager.checkHealth(); + + if (health.healthy) { + const itemCount = manager.ccsItems.length; + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'ok', + message: `${itemCount}/${itemCount} items linked`, + }; + } + + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: `${health.issues.length} issues found`, + fix: 'Run: ccs sync', + }; + } catch (e) { + return { + id: 'ccs-symlinks', + name: 'CCS Symlinks', + status: 'warning', + message: 'Could not check', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +// Check 11: Settings Symlinks +function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck { + try { + const sharedDir = path.join(homedir, '.ccs', 'shared'); + const sharedSettings = path.join(sharedDir, 'settings.json'); + const claudeSettings = path.join(claudeDir, 'settings.json'); + + if (!fs.existsSync(sharedSettings)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Shared not found', + fix: 'Run: ccs sync', + }; + } + + const sharedStats = fs.lstatSync(sharedSettings); + if (!sharedStats.isSymbolicLink()) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Not a symlink', + fix: 'Run: ccs sync', + }; + } + + const sharedTarget = fs.readlinkSync(sharedSettings); + const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget); + + if (resolvedShared !== claudeSettings) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Wrong target', + fix: 'Run: ccs sync', + }; + } + + // Check instances + const instancesDir = path.join(ccsDir, 'instances'); + if (!fs.existsSync(instancesDir)) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: 'Shared symlink valid', + }; + } + + const instances = fs.readdirSync(instancesDir).filter((name) => { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + }); + + let broken = 0; + for (const instance of instances) { + const instanceSettings = path.join(instancesDir, instance, 'settings.json'); + if (!fs.existsSync(instanceSettings)) { + broken++; + continue; + } + try { + const stats = fs.lstatSync(instanceSettings); + if (!stats.isSymbolicLink()) { + broken++; + continue; + } + const target = fs.readlinkSync(instanceSettings); + const resolved = path.resolve(path.dirname(instanceSettings), target); + if (resolved !== sharedSettings) { + broken++; + } + } catch { + broken++; + } + } + + if (broken > 0) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: `${broken} broken instance(s)`, + fix: 'Run: ccs sync', + }; + } + + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'ok', + message: `${instances.length} instance(s) valid`, + }; + } catch (e) { + return { + id: 'settings-symlinks', + name: 'settings.json', + status: 'warning', + message: 'Check failed', + details: (e as Error).message, + fix: 'Run: ccs sync', + }; + } +} + +// Check 12: CLIProxy Binary +function checkCliproxyBinary(): HealthCheck { if (isCLIProxyInstalled()) { const version = getInstalledCliproxyVersion(); const binaryPath = getCLIProxyPath(); return { - id: 'cliproxy', - name: 'CLIProxy', + id: 'cliproxy-binary', + name: 'CLIProxy Binary', status: 'ok', - message: `Installed: ${version}`, + message: `v${version}`, details: binaryPath, }; } return { - id: 'cliproxy', - name: 'CLIProxy', - status: 'warning', - message: 'Not installed (optional)', - details: 'Required for gemini/codex/agy providers. Install: ccs cliproxy --latest', + id: 'cliproxy-binary', + name: 'CLIProxy Binary', + status: 'info', + message: 'Not installed', + details: 'Downloads on first use', }; } -function checkCcsDirectory(): HealthCheck { - const ccsDir = getCcsDir(); +// Check 13: CLIProxy Config +function checkCliproxyConfig(): HealthCheck { + const configPath = getCliproxyConfigPath(); - if (!fs.existsSync(ccsDir)) { + if (fs.existsSync(configPath)) { return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'warning', - message: 'Not found', - details: ccsDir, - fixable: true, + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'ok', + message: 'cliproxy/config.yaml', }; } return { - id: 'ccs-dir', - name: 'CCS Directory', - status: 'ok', - message: 'Exists', - details: ccsDir, + id: 'cliproxy-config', + name: 'CLIProxy Config', + status: 'info', + message: 'Not created', + details: 'Generated on first use', + }; +} + +// Check 14: OAuth Providers +function checkOAuthProviders(): HealthCheck[] { + const authStatuses = getAllAuthStatus(); + const checks: HealthCheck[] = []; + + for (const status of authStatuses) { + const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1); + + if (status.authenticated) { + const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : ''; + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'ok', + message: 'Authenticated', + details: lastAuth, + }); + } else { + checks.push({ + id: `oauth-${status.provider}`, + name: `${providerName} Auth`, + status: 'info', + message: 'Not authenticated', + fix: `Run: ccs ${status.provider} --auth`, + }); + } + } + + return checks; +} + +// Check 15: CLIProxy Port +async function checkCliproxyPort(): Promise { + const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT); + + if (!portProcess) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'info', + message: `${CLIPROXY_DEFAULT_PORT} free`, + details: 'Proxy not running', + }; + } + + if (isCLIProxyProcess(portProcess)) { + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'ok', + message: 'CLIProxy running', + details: `PID ${portProcess.pid}`, + }; + } + + return { + id: 'cliproxy-port', + name: 'CLIProxy Port', + status: 'warning', + message: `Occupied by ${portProcess.processName}`, + details: `PID ${portProcess.pid}`, + fix: `Kill process: kill ${portProcess.pid}`, }; } diff --git a/src/web-server/index.ts b/src/web-server/index.ts index b98668a5..5eb5c87e 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise((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 }); }); }); diff --git a/src/web-server/overview-routes.ts b/src/web-server/overview-routes.ts index 9b9cdaef..0ddaf1da 100644 --- a/src/web-server/overview-routes.ts +++ b/src/web-server/overview-routes.ts @@ -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 }, }); diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 26aedfdf..5b48c420 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -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); }); diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index fc7d768b..6c56c993 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -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); diff --git a/src/web-server/usage-routes.ts b/src/web-server/usage-routes.ts new file mode 100644 index 00000000..dfc6e794 --- /dev/null +++ b/src/web-server/usage-routes.ts @@ -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 { + 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>(); + +// Pending requests for coalescing (prevents duplicate concurrent calls) +const pendingRequests = new Map>(); + +/** + * 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(key: string, ttl: number, loader: () => Promise): Promise { + const cached = cache.get(key) as CacheEntry | 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 | 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 { + return getCachedData('daily', CACHE_TTL.daily, async () => { + return (await loadDailyUsageData()) as DailyUsage[]; + }); +} + +/** Cached loader for monthly usage data */ +async function getCachedMonthlyData(): Promise { + return getCachedData('monthly', CACHE_TTL.monthly, async () => { + return (await loadMonthlyUsageData()) as MonthlyUsage[]; + }); +} + +/** Cached loader for session data */ +async function getCachedSessionData(): Promise { + 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( + 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, 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, 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, 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, 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, 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, + }, + }); +}); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index ae39a1bc..e583345b 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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', () => { diff --git a/ui/bun.lock b/ui/bun.lock index 59c1bef1..e1e67280 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -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=="], } } diff --git a/ui/index.html b/ui/index.html index bea1d424..e36ed302 100644 --- a/ui/index.html +++ b/ui/index.html @@ -16,6 +16,14 @@ + + + + +
diff --git a/ui/package.json b/ui/package.json index ef229add..6462722c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 52f572ba..02a0f239 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -15,6 +15,7 @@ import { SettingsPage, HealthPage, SharedPage, + AnalyticsPage, } from '@/pages'; function Layout() { @@ -42,6 +43,7 @@ export default function App() { }> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/analytics/date-range-filter.tsx b/ui/src/components/analytics/date-range-filter.tsx new file mode 100644 index 00000000..e2dca7f1 --- /dev/null +++ b/ui/src/components/analytics/date-range-filter.tsx @@ -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) => { + const from = e.target.value ? new Date(e.target.value) : undefined; + onChange({ from, to: value?.to }); + }; + + const handleToChange = (e: React.ChangeEvent) => { + const to = e.target.value ? new Date(e.target.value) : undefined; + onChange({ from: value?.from, to }); + }; + + return ( +
+ {/* Preset Buttons */} + {presets.map((preset, index) => ( + + ))} + + {/* Custom Date Range Inputs */} +
+
+ + +
+ to + +
+
+ ); +} + +// 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; +} diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx new file mode 100644 index 00000000..cfc02294 --- /dev/null +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -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 ; + } + + if (!data || data.length === 0) { + return ( +
+

No model data available

+
+ ); + } + + 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 ( +
+

{data.name}

+

+ {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) +

+

${data.cost.toFixed(4)}

+
+ ); + }; + + const renderLabel = (entry: { percentage: number }) => { + return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : ''; + }; + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + + {/* Legend removed from here, moved to AnalyticsPage for better layout control */} + + +
+ ); +} + +// 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(); +} diff --git a/ui/src/components/analytics/sessions-table.tsx b/ui/src/components/analytics/sessions-table.tsx new file mode 100644 index 00000000..956ac4a6 --- /dev/null +++ b/ui/src/components/analytics/sessions-table.tsx @@ -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 ; + } + + if (!data || data.sessions.length === 0) { + return ( +
+ +

No sessions found

+

Start using Claude Code to see session history

+
+ ); + } + + return ( +
+ {/* Search Bar */} +
+
+ + { + setSearchTerm(e.target.value); + setCurrentPage(0); + }} + className="pl-8" + /> +
+
+ + {/* Table */} +
+ + + + Session ID + Profile + Model + Duration + Tokens + Cost + Requests + Last Used + + + + {paginatedSessions.map((session) => ( + + {session.id.slice(0, 8)}... + + {session.profile} + + {session.model} + {session.duration ? formatDuration(session.duration) : '-'} + +
+ + {formatNumber(session.tokens)} +
+
+ +
+ $ + {session.cost.toFixed(4)} +
+
+ {session.requests} + + {formatDistanceToNow(new Date(session.startTime), { addSuffix: true })} + +
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {currentPage * pageSize + 1} to{' '} + {Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '} + {filteredSessions?.length} sessions +

+
+ +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const page = i; + return ( + + ); + })} +
+ +
+
+ )} +
+ ); +} + +// 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 ( +
+
+ +
+
+ + + + Session ID + Profile + Model + Duration + Tokens + Cost + Requests + Last Used + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+
+ ); +} diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx new file mode 100644 index 00000000..9be107c5 --- /dev/null +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -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 ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+
+ + +
+ +
+
+
+ ))} +
+ ); + } + + 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 ( +
+ {cards.map((card, index) => { + const Icon = card.icon; + return ( + + +
+
+

{card.title}

+

{card.format(card.value)}

+
+
+ +
+
+
+
+ ); + })} +
+ ); +} + +// 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(); +} diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx new file mode 100644 index 00000000..7bdb8343 --- /dev/null +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -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) { + 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 ; + } + + if (!data || data.length === 0) { + return ( +
+

No usage data available

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + + + + formatNumber(value)} + /> + + `$${value}`} + /> + + { + if (!active || !payload || !payload.length) return null; + + const data = payload[0].payload; + return ( +
+

{label}

+ {payload.map((entry, index) => ( +

+ {entry.name}:{' '} + {entry.name === 'Tokens' + ? formatNumber(Number(entry.value) || 0) + : `$${entry.value}`} +

+ ))} +

Requests: {data.requests}

+
+ ); + }} + /> + + + + +
+
+
+ ); +} + +// 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(); +} diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx index 670570e6..d1bd4cc3 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/app-sidebar.tsx @@ -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', diff --git a/ui/src/components/connection-indicator.tsx b/ui/src/components/connection-indicator.tsx index f19ecbe6..eb00487d 100644 --- a/ui/src/components/connection-indicator.tsx +++ b/ui/src/components/connection-indicator.tsx @@ -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' }, }; diff --git a/ui/src/components/health-card.tsx b/ui/src/components/health-card.tsx index 520e2083..c3097596 100644 --- a/ui/src/components/health-card.tsx +++ b/ui/src/components/health-card.tsx @@ -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', }, diff --git a/ui/src/components/health-check-item.tsx b/ui/src/components/health-check-item.tsx new file mode 100644 index 00000000..88268d64 --- /dev/null +++ b/ui/src/components/health-check-item.tsx @@ -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 ( +
+ {/* Status dot with pulse animation */} +
+
+ {check.status !== 'ok' && ( +
+ )} +
+ + {/* Check name */} + {check.name} + + {/* Status label */} + + [{config.label}] + + + {/* Fix button for fixable non-ok items */} + {check.fixable && check.status !== 'ok' && ( + + )} +
+ ); + } + + // Expandable display for items with details or fix commands + return ( + +
+ + + + + +
+ {/* Message */} +

{check.message}

+ + {/* Details block */} + {check.details && ( +
+                {check.details}
+              
+ )} + + {/* Fix command block */} + {check.fix && ( +
+
+ + {check.fix} + +
+ + {check.fixable && check.status !== 'ok' && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/ui/src/components/health-gauge.tsx b/ui/src/components/health-gauge.tsx new file mode 100644 index 00000000..2936b726 --- /dev/null +++ b/ui/src/components/health-gauge.tsx @@ -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 ( +
+ + {/* Background track */} + + {/* Progress arc */} + + {/* Animated glow dot at end of arc */} + {percentage > 0 && ( + + )} + + {/* Center content */} +
+ + {percentage} + + + health + +
+
+ ); +} diff --git a/ui/src/components/health-group-section.tsx b/ui/src/components/health-group-section.tsx new file mode 100644 index 00000000..9b0e4d30 --- /dev/null +++ b/ui/src/components/health-group-section.tsx @@ -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 = { + 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 ( + +
+ {/* Group header */} + + + + + {/* Checks list */} + +
+ {group.checks.map((check) => ( + + ))} +
+
+
+
+ ); +} diff --git a/ui/src/components/health-stats-bar.tsx b/ui/src/components/health-stats-bar.tsx new file mode 100644 index 00000000..042a3f89 --- /dev/null +++ b/ui/src/components/health-stats-bar.tsx @@ -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 ( +
+
+ + {label} + + {value} +
+ ); +} + +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 ( +
+ {/* Progress bar visualization */} +
+ {errorPct > 0 && ( +
+ )} + {warningPct > 0 && ( +
+ )} + {infoPct > 0 && ( +
+ )} + {passedPct > 0 && ( +
+ )} +
+ + {/* Stats row */} +
+
+ + Checks + + {total} +
+ +
+ + + + +
+
+
+ ); +} diff --git a/ui/src/components/hero-section.tsx b/ui/src/components/hero-section.tsx new file mode 100644 index 00000000..17418826 --- /dev/null +++ b/ui/src/components/hero-section.tsx @@ -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 ( +
+ {/* Subtle background pattern */} +
+
+
+ +
+ {/* Left: Logo and Welcome */} +
+ +
+
+

CCS Config

+ + v{version} + +
+

Claude Code Switch Dashboard

+
+
+ + {/* Right: Health Status */} +
+ +
+

{status.label}

+ {healthTotal > 0 && ( +

+ {healthPassed}/{healthTotal} checks passed +

+ )} +
+
+
+
+ ); +} diff --git a/ui/src/components/quick-commands.tsx b/ui/src/components/quick-commands.tsx new file mode 100644 index 00000000..6ab4d6f8 --- /dev/null +++ b/ui/src/components/quick-commands.tsx @@ -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(null); + + const copyToClipboard = async (text: string, index: number) => { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout(() => setCopiedIndex(null), 2000); + }; + + return ( + + + + + Quick Commands + + + +
+ {snippets.map((snippet, index) => ( +
+
+

{snippet.label}

+ + {snippet.command} + +
+ +
+ ))} +
+
+
+ ); +} diff --git a/ui/src/components/settings-dialog.tsx b/ui/src/components/settings-dialog.tsx index 1e87683c..d4b0f3b9 100644 --- a/ui/src/components/settings-dialog.tsx +++ b/ui/src/components/settings-dialog.tsx @@ -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 ( diff --git a/ui/src/components/stat-card.tsx b/ui/src/components/stat-card.tsx index b2f6f991..8ef7f37b 100644 --- a/ui/src/components/stat-card.tsx +++ b/ui/src/components/stat-card.tsx @@ -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 ( - -
-
-

{title}

-

{value}

+ +
+ {/* Icon Container with background */} +
+ +
+ + {/* Content */} +
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

}
-
diff --git a/ui/src/components/ui/button-variants.ts b/ui/src/components/ui/button-variants.ts index e87ad6f1..56fbdc5a 100644 --- a/ui/src/components/ui/button-variants.ts +++ b/ui/src/components/ui/button-variants.ts @@ -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: { diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx index 8e2f660d..5bd8813d 100644 --- a/ui/src/components/ui/dropdown-menu.tsx +++ b/ui/src/components/ui/dropdown-menu.tsx @@ -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} diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx index d2f33820..1db90447 100644 --- a/ui/src/components/ui/input.tsx +++ b/ui/src/components/ui/input.tsx @@ -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 )} diff --git a/ui/src/hooks/use-health.ts b/ui/src/hooks/use-health.ts index f25e5bbc..77e7ce92 100644 --- a/ui/src/hooks/use-health.ts +++ b/ui/src/hooks/use-health.ts @@ -4,23 +4,36 @@ import { toast } from 'sonner'; interface HealthCheck { id: string; name: string; - status: 'ok' | 'warning' | 'error'; + status: 'ok' | 'warning' | 'error' | 'info'; message: string; details?: string; + fix?: string; fixable?: boolean; } +interface HealthGroup { + id: string; + name: string; + icon: string; + checks: HealthCheck[]; +} + interface HealthReport { timestamp: number; + version: string; + groups: HealthGroup[]; checks: HealthCheck[]; summary: { total: number; passed: number; warnings: number; errors: number; + info: number; }; } +export type { HealthCheck, HealthGroup, HealthReport }; + export function useHealth() { return useQuery({ queryKey: ['health'], diff --git a/ui/src/hooks/use-overview.ts b/ui/src/hooks/use-overview.ts index e395d006..09bab967 100644 --- a/ui/src/hooks/use-overview.ts +++ b/ui/src/hooks/use-overview.ts @@ -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'; diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts new file mode 100644 index 00000000..2a19800a --- /dev/null +++ b/ui/src/hooks/use-usage.ts @@ -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(`/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(`/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(`/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(`/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(`/usage/monthly?${params}`); + }, + /** Clear server-side usage cache and force fresh data fetch */ + refresh: async (): Promise => { + 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('/usage/status'), +}; + +// Helper function to match existing API client pattern +async function request(url: string): Promise { + 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 + }); +} diff --git a/ui/src/index.css b/ui/src/index.css index 62db957d..1cdd21ed 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; } } diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx new file mode 100644 index 00000000..410ae7fa --- /dev/null +++ b/ui/src/pages/analytics.tsx @@ -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({ + from: subDays(new Date(), 30), + to: new Date(), + }); + const [viewMode, setViewMode] = useState('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 ( +
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

+
+
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
+ + {/* Summary Cards */} + + + {/* Main Content Tabs */} + setViewMode(v as ViewMode)} + className="flex-1 flex flex-col min-h-0" + > + + + Daily + + + Monthly + + + Sessions + + + +
+ {/* Daily View */} + + {/* Usage Trend Chart - Full Width */} + + + + + Usage Trends + + + + + + + + {/* Bottom Row - Model Usage & Cost */} +
+ {/* Model Distribution */} + + + + + Model Usage + + + +
+
+ +
+
+ {models?.slice(0, 8).map((model) => ( +
+
+
+
+ + {model.model} + + + {model.percentage.toFixed(1)}% + +
+
+
+ ))} +
+
+ + + + {/* Cost Breakdown */} + + + Cost by Model + + + {isModelsLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( +
+
+
+ + {model.model} + +
+ + ${model.cost.toFixed(4)} + +
+ ))} +
+ )} + + +
+ + + {/* Monthly View */} + + + + + + Monthly Overview + + + + + + + + + {/* Sessions View */} + + + + + + Session History + + + +
+ +
+
+
+
+
+ +
+
+ ); +} + +// 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 ( +
+ {/* Usage Trends Skeleton */} + + + + + + + + + + {/* Bottom Row Skeletons */} +
+ {/* Model Usage Skeleton */} + + + + + +
+
+ +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+
+
+
+ + {/* Cost Breakdown Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/health.tsx b/ui/src/pages/health.tsx index d623725a..7c8b5d94 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -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 ( +
+ $ + ccs doctor +
+ ); +} + +function LoadingSkeleton() { + return ( +
+ {/* Hero skeleton */} +
+
+ +
+ + + +
+
+
+ + {/* Stats skeleton */} + + + {/* Groups skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ ); +} 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 ; + } + + const overallStatus = data ? getOverallStatus(data.summary) : 'ok'; + const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : []; + return ( -
-
-
-

Health Dashboard

- {dataUpdatedAt && ( -

Last check: {formatTime(dataUpdatedAt)}

+
+ {/* Hero Section - Terminal-inspired control center header */} +
+ {/* Subtle scan lines effect */} +
+ + {/* Grid pattern background */} +
+
+
+ +
+ {/* Left: Health Gauge - excludes info from percentage */} + {data && ( +
+ +
)} + + {/* Center: Title and status */} +
+ {/* Terminal prompt */} + + + {/* Main title */} +
+

System Health

+ {data?.version && ( + + build {data.version} + + )} +
+ + {/* Status message */} +
+ + Last scan: + + {lastRefresh ? formatRelativeTime(lastRefresh) : '--'} + + | + Auto-refresh: + 30s +
+
+ + {/* Right: Actions */} +
+ + +
-
+ {/* Stats Bar */} {data && ( -
-
- {data.summary.passed} - passed -
-
- {data.summary.warnings} - warnings -
-
- {data.summary.errors} - errors -
+
+
)} - {isLoading && !data &&
Running health checks...
} - - {data && ( -
- {data.checks.map((check) => ( - + {/* Health Check Groups - Single column layout */} + {sortedGroups.length > 0 && ( +
+ {sortedGroups.map((group, index) => ( + c.status === 'error' || c.status === 'warning') + } + /> ))}
)} + + {/* Footer metadata */} +
+
+ + Version {data?.version ?? '--'} + + + Platform{' '} + + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} + + +
+
+
+ Live monitoring active +
+
); } diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index 2c28ca53..0e2077dc 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -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 (
-
- - + {/* Hero Skeleton */} +
+
+ +
+ + +
+
+ + {/* Stats Skeleton */}
{[1, 2, 3, 4].map((i) => ( -
-
- - -
-
- - +
+
+ +
+ + +
))}
-
- + + {/* Quick Actions Skeleton */} +
+
-
-
- - -
-
- {[1, 2, 3].map((i) => ( -
- - - -
+ + {/* Quick Commands Skeleton */} +
+ +
+ {[1, 2, 3, 4].map((i) => ( + ))}
@@ -77,13 +84,21 @@ export function HomePage() { ); } + const healthVariant = overview?.health + ? HEALTH_VARIANTS[overview.health.status as keyof typeof HEALTH_VARIANTS] + : undefined; + return (
-
-

Welcome to CCS Config

-

Manage your Claude Code Switch configuration

-
+ {/* Hero Section */} + + {/* Configuration Warning */} {shared?.symlinkStatus && !shared.symlinkStatus.valid && ( @@ -98,74 +113,87 @@ export function HomePage() { title="API Profiles" value={overview?.profiles ?? 0} icon={Key} + variant="accent" + subtitle="Settings-based" onClick={() => navigate('/api')} /> navigate('/cliproxy')} /> navigate('/accounts')} /> navigate('/health')} />
{/* Quick Actions */} - + Quick Actions - - - + {/* Quick Commands */} + + {/* Shared Data Summary */} - - - Shared Data -
-
- - {shared?.commands ?? 0} +
+ {shared?.commands ?? 0} Commands
-
- {shared?.skills ?? 0} +
+ {shared?.skills ?? 0} Skills
-
- {shared?.agents ?? 0} +
+ {shared?.agents ?? 0} Agents
diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 52297c6b..d8d447d3 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -11,3 +11,5 @@ export { SettingsPage } from './settings'; export { HealthPage } from './health'; export { SharedPage } from './shared'; + +export { AnalyticsPage } from './analytics';