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

{check.name}

+ + {config.label} + +
+

{check.message}

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

+ {check.details} +

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

{value}

+

{label}

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

Health Dashboard

- {dataUpdatedAt && ( -

Last check: {formatTime(dataUpdatedAt)}

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

Health Check

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

+ Last check: {formatTime(dataUpdatedAt)} +

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

{check.name}

+

{check.message}

+ {check.fix && ( + + {check.fix} + + )} +
+
+ ))} +
+
+
+ )}
); } From 9c3004294da03167bd7281755372e992037af592 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Dec 2025 20:02:37 +0000 Subject: [PATCH 05/19] chore(release): 5.11.0-dev.2 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 6b4aeda9..cf58c92b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.1 +5.11.0-dev.2 diff --git a/package.json b/package.json index 7220fcff..27db0ab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.1", + "version": "5.11.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 4ff6f085122c20209e73fcbda457175fb47958de Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 15:17:17 -0500 Subject: [PATCH 06/19] feat(ui): add modular health dashboard components - Add health-gauge component for visual status representation - Add health-group-section for organized health checks display - Add health-stats-bar for summary statistics - Refactor health-check-item with improved structure - Update health.tsx to use new modular components --- ui/index.html | 8 + ui/src/components/health-check-item.tsx | 206 +++++------ ui/src/components/health-gauge.tsx | 91 +++++ ui/src/components/health-group-section.tsx | 111 ++++++ ui/src/components/health-stats-bar.tsx | 85 +++++ ui/src/index.css | 7 +- ui/src/pages/health.tsx | 411 ++++++++------------- 7 files changed, 549 insertions(+), 370 deletions(-) create mode 100644 ui/src/components/health-gauge.tsx create mode 100644 ui/src/components/health-group-section.tsx create mode 100644 ui/src/components/health-stats-bar.tsx 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/src/components/health-check-item.tsx b/ui/src/components/health-check-item.tsx index 7c69c119..88268d64 100644 --- a/ui/src/components/health-check-item.tsx +++ b/ui/src/components/health-check-item.tsx @@ -1,170 +1,144 @@ -import { - CheckCircle2, - AlertTriangle, - XCircle, - Info, - Wrench, - ChevronDown, - Terminal, -} from 'lucide-react'; +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: { - icon: CheckCircle2, - color: 'text-green-600', - bg: 'bg-green-500/5', - border: 'border-green-500/20', - label: '[OK]', - }, - warning: { - icon: AlertTriangle, - color: 'text-yellow-500', - bg: 'bg-yellow-500/5', - border: 'border-yellow-500/20', - label: '[!]', - }, - error: { - icon: XCircle, - color: 'text-red-500', - bg: 'bg-red-500/5', - border: 'border-red-500/20', - label: '[X]', - }, - info: { - icon: Info, - color: 'text-blue-500', - bg: 'bg-blue-500/5', - border: 'border-blue-500/20', - label: '[i]', - }, + 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 Icon = config.icon; 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 (
-
+
+ {check.status !== 'ok' && ( +
)} - > - -
-
-
-

{check.name}

- - {config.label} - -
-

{check.message}

+ + {/* 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} -

-
+
+                {check.details}
+              
)} + {/* Fix command block */} {check.fix && ( -
-
- - {check.fix} +
+
+ + {check.fix} +
{check.fixable && check.status !== 'ok' && ( @@ -172,9 +146,9 @@ export function HealthCheckItem({ check }: { check: HealthCheck }) { size="sm" onClick={() => fixMutation.mutate(check.id)} disabled={fixMutation.isPending} - className="h-auto py-3 px-6 shadow-sm shrink-0" + className="h-7 px-3 text-xs" > - + Apply Fix )} 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/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/health.tsx b/ui/src/pages/health.tsx index 2e3bcd31..7c8b5d94 100644 --- a/ui/src/pages/health.tsx +++ b/ui/src/pages/health.tsx @@ -1,58 +1,14 @@ import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { - RefreshCw, - CheckCircle2, - AlertTriangle, - XCircle, - Info, - Monitor, - Settings, - Users, - Shield, - Zap, - Stethoscope, - Copy, - Terminal, -} from 'lucide-react'; -import { HealthCheckItem } from '@/components/health-check-item'; +import { 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'; - -const groupIcons: Record = { - Monitor, - Settings, - Users, - Shield, - Zap, -}; - -const statusConfig = { - ok: { - icon: CheckCircle2, - label: 'All Systems Operational', - color: 'text-green-600', - bg: 'bg-green-500/10', - border: 'border-green-500/20', - }, - warning: { - icon: AlertTriangle, - label: 'Some Issues Detected', - color: 'text-yellow-500', - bg: 'bg-yellow-500/10', - border: 'border-yellow-500/20', - }, - error: { - icon: XCircle, - label: 'Action Required', - color: 'text-red-500', - bg: 'bg-red-500/10', - border: 'border-red-500/20', - }, -}; +import { useEffect, useState } from 'react'; function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) { if (summary.errors > 0) return 'error'; @@ -60,121 +16,60 @@ function getOverallStatus(summary: { passed: number; warnings: number; errors: n return 'ok'; } -function HealthGroupSection({ group }: { group: HealthGroup }) { - const Icon = groupIcons[group.icon] || Monitor; - - const groupPassed = group.checks.filter((c) => c.status === 'ok').length; - const groupTotal = group.checks.length; - const hasIssues = group.checks.some((c) => c.status === 'error' || c.status === 'warning'); - - return ( - - -
- -
- -
- {group.name} -
- - {groupPassed}/{groupTotal} - -
-
- -
- {group.checks.map((check) => ( - - ))} -
-
-
- ); +function 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 SummaryCard({ - label, - value, - icon: Icon, - color, -}: { - label: string; - value: number; - icon: typeof CheckCircle2; - color: string; -}) { +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 ( - - -
-
- -
-
-

{value}

-

{label}

-
-
-
-
+
+ $ + ccs doctor +
); } function LoadingSkeleton() { return (
- {/* Hero Skeleton */} -
-
- -
- - + {/* Hero skeleton */} +
+
+ +
+ + +
-
- {/* Summary Skeleton */} -
+ {/* Stats skeleton */} + + + {/* Groups skeleton */} +
{[1, 2, 3, 4].map((i) => ( ))}
- - {/* Groups Skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
- - - - - -
- {[1, 2, 3].map((j) => ( - - ))} -
-
-
-
- ))} -
); } @@ -182,33 +77,53 @@ function LoadingSkeleton() { 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 status = statusConfig[overallStatus]; - const StatusIcon = status.icon; + const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : []; return (
- {/* Hero Section */} + {/* Hero Section - Terminal-inspired control center header */}
- {/* Subtle background pattern */} + {/* Subtle scan lines effect */} +
+ + {/* Grid pattern background */}
-
- {/* Left: Title and Status */} -
-
- +
+ {/* Left: Health Gauge - excludes info from percentage */} + {data && ( +
+
-
-
-

Health Check

- {data?.version && ( - - v{data.version} - - )} -
-
- - {status.label} -
+ )} + + {/* 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 */} -
+
-
- - {/* Last check time */} - {dataUpdatedAt && ( -

- Last check: {formatTime(dataUpdatedAt)} -

- )}
- {/* Summary Stats */} + {/* Stats Bar */} {data && ( -
- + - - -
)} - {/* Health Check Groups */} - {data?.groups && ( -
- {data.groups.map((group) => ( -
- -
+ {/* Health Check Groups - Single column layout */} + {sortedGroups.length > 0 && ( +
+ {sortedGroups.map((group, index) => ( + c.status === 'error' || c.status === 'warning') + } + /> ))}
)} - {/* Issues Summary */} - {data && (data.summary.errors > 0 || data.summary.warnings > 0) && ( - - - - - Issues Detected - - - -
- {data.checks - .filter((c) => c.status === 'error' || c.status === 'warning') - .map((check) => ( -
- {check.status === 'error' ? ( - - ) : ( - - )} -
-

{check.name}

-

{check.message}

- {check.fix && ( - - {check.fix} - - )} -
-
- ))} -
-
-
- )} + {/* Footer metadata */} +
+
+ + Version {data?.version ?? '--'} + + + Platform{' '} + + {typeof navigator !== 'undefined' ? navigator.platform : 'linux'} + + +
+
+
+ Live monitoring active +
+
); } From 393992377e4a2d31b4f9fe787e8a0ac56b36b8b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Dec 2025 20:20:26 +0000 Subject: [PATCH 07/19] chore(release): 5.11.0-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index cf58c92b..0e6a4695 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.2 +5.11.0-dev.3 diff --git a/package.json b/package.json index 27db0ab3..a29cdffd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.2", + "version": "5.11.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ed5c3fc83ab4117263e74aaf29a4df8d63a8e5c1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 16:08:15 -0500 Subject: [PATCH 08/19] fix(ui): update dropdown menu item SVG color on focus - Add focus:[&_svg:not([class*='text-'])]:text-current to DropdownMenuItem - Add focus:[&_svg:not([class*='text-'])]:text-current to DropdownMenuSubTrigger - Ensures SVG icons inherit text color on focus for better visibility --- ui/src/components/ui/dropdown-menu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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} From 13194fecbe575e83bd6f366e2aca1d92922ccd24 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 16:14:30 -0500 Subject: [PATCH 09/19] fix(web): correct skill detection to look for SKILL.md instead of prompt.md The Skills page was only showing 4 skills instead of 38 because it was looking for prompt.md files instead of SKILL.md files in the skills directory. Updated the detection logic to check for SKILL.md for skills and prompt.md for agents. --- src/web-server/shared-routes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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); From 792c5244ac27e2e7cdcdf7ed4dbd63f3880c2a2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Dec 2025 21:16:56 +0000 Subject: [PATCH 10/19] chore(release): 5.11.0-dev.4 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0e6a4695..e8b4bb03 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.3 +5.11.0-dev.4 diff --git a/package.json b/package.json index a29cdffd..055b3df7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.3", + "version": "5.11.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 639eec7930c4f34dacd0fb2326de87ed640d8e74 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 16:19:39 -0500 Subject: [PATCH 11/19] fix(ui): reduce focus ring size to prevent overlapping content --- ui/src/components/ui/button-variants.ts | 2 +- ui/src/components/ui/input.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 )} From efb42ba8f6adfa5128c4974d43140fd640b826a1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 16:32:08 -0500 Subject: [PATCH 12/19] fix(security): improve API key detection patterns to prevent false positives - Change from substring to pattern-based matching for sensitive keys - Prevents ANTHROPIC_MAX_TOKENS from being incorrectly censored - Synchronize backend and UI detection logic for consistency --- src/web-server/routes.ts | 11 +++++++++-- ui/src/components/settings-dialog.tsx | 12 +++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 36e682b3..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] = 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 ( From 57032eec5e9d1a14cd043c12330c2f0e62b16b3f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Dec 2025 21:34:41 +0000 Subject: [PATCH 13/19] chore(release): 5.11.0-dev.5 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index e8b4bb03..cfe3172d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.4 +5.11.0-dev.5 diff --git a/package.json b/package.json index 055b3df7..41454141 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.4", + "version": "5.11.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a721af3cf3ff618603e982aa2fda47980251c4e4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 21:47:36 -0500 Subject: [PATCH 14/19] feat(analytics): add usage analytics page with caching layer - Add Analytics page with usage trends, model breakdown, sessions table - Add server-side caching layer for better-ccusage data (TTL-based) - Add request coalescing to prevent duplicate concurrent API calls - Add /api/usage/refresh endpoint to manually clear cache - Add date-range filter, summary cards, trend charts components - Fix API parameter mismatch (since/until in YYYYMMDD format) - Wire up Refresh button with loading state animation --- bun.lock | 3 + package.json | 1 + src/types/external.d.ts | 75 +++ src/web-server/index.ts | 4 + src/web-server/usage-routes.ts | 496 ++++++++++++++++++ ui/bun.lock | 94 ++++ ui/package.json | 4 + ui/src/App.tsx | 2 + .../analytics/date-range-filter.tsx | 95 ++++ .../analytics/model-breakdown-chart.tsx | 123 +++++ .../components/analytics/sessions-table.tsx | 269 ++++++++++ .../analytics/usage-summary-cards.tsx | 108 ++++ .../analytics/usage-trend-chart.tsx | 171 ++++++ ui/src/components/app-sidebar.tsx | 17 +- ui/src/hooks/use-usage.ts | 202 +++++++ ui/src/pages/analytics.tsx | 297 +++++++++++ ui/src/pages/index.tsx | 2 + 17 files changed, 1961 insertions(+), 2 deletions(-) create mode 100644 src/web-server/usage-routes.ts create mode 100644 ui/src/components/analytics/date-range-filter.tsx create mode 100644 ui/src/components/analytics/model-breakdown-chart.tsx create mode 100644 ui/src/components/analytics/sessions-table.tsx create mode 100644 ui/src/components/analytics/usage-summary-cards.tsx create mode 100644 ui/src/components/analytics/usage-trend-chart.tsx create mode 100644 ui/src/hooks/use-usage.ts create mode 100644 ui/src/pages/analytics.tsx 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/package.json b/package.json index 41454141..fd640734 100644 --- a/package.json +++ b/package.json @@ -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/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/web-server/index.ts b/src/web-server/index.ts index b98668a5..6d084f20 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise { + 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 +}; + +// 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 + */ +async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { + // Check cache first + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && Date.now() - cached.timestamp < ttl) { + return cached.data; + } + + // 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() }); + 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(); +} + +// ============================================================================ +// 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', + }); +}); 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/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..81fb9576 --- /dev/null +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -0,0 +1,123 @@ +/** + * 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, Legend } 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}

+

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

+

Cost: ${data.cost.toFixed(4)}

+

Requests: {data.requests}

+
+ ); + }; + + const renderLabel = (entry: { percentage: number }) => { + return `${entry.percentage.toFixed(1)}%`; + }; + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + + {value}} + /> + + +
+ ); +} + +// 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..7385dab3 --- /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 = 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..d76aad93 --- /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..01e8bc7f --- /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.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/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts new file mode 100644 index 00000000..8486e3d5 --- /dev/null +++ b/ui/src/hooks/use-usage.ts @@ -0,0 +1,202 @@ +/** + * 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; +} + +// 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'); + } + }, +}; + +// 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; +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx new file mode 100644 index 00000000..bf98651d --- /dev/null +++ b/ui/src/pages/analytics.tsx @@ -0,0 +1,297 @@ +/** + * Analytics Page + * + * Displays Claude Code usage analytics with charts and tables. + * Features daily/monthly views, trend charts, model breakdown, and session history. + */ + +import { useState } from 'react'; +import type { DateRange } from 'react-day-picker'; +import { startOfMonth, subDays } 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, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react'; +import { + useUsageSummary, + useUsageTrends, + useModelUsage, + useSessions, + useRefreshUsage, +} 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, + }); + + // Loading state + if (isSummaryLoading || isTrendsLoading || isModelsLoading) { + return ; + } + + return ( +
+ {/* Header */} +
+
+

Analytics

+

Track your Claude Code usage and insights

+
+
+ + +
+
+ + {/* Date Range Filter */} + + + {/* Summary Cards */} + + + {/* Main Content Tabs */} + setViewMode(v as ViewMode)}> + + + + Daily + + + + Monthly + + + + Sessions + + + + {/* Daily View */} + +
+ {/* Usage Trend Chart */} + + + + + Usage Trends + + + + + + + + {/* Model Distribution */} + + + + + Model Usage + + + + + + + + {/* Cost Breakdown */} + + + Cost by Model + + + {isModelsLoading ? ( + + ) : ( +
+ {models?.slice(0, 5).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]; +} + +// Skeleton loading state +function AnalyticsSkeleton() { + return ( +
+ {/* Header */} +
+ + +
+ + {/* Date Filter */} + + + {/* Summary Cards */} +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + + ))} +
+ + {/* Charts */} +
+ + + + + + + + + + + + + + + + +
+
+ ); +} 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'; From 382150f31223d681247b8a8ee034c4f896ce0923 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 02:50:22 +0000 Subject: [PATCH 15/19] chore(release): 5.11.0-dev.6 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index cfe3172d..04907d8e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.5 +5.11.0-dev.6 diff --git a/package.json b/package.json index fd640734..8269d788 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.5", + "version": "5.11.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 69e6a322248d3952156784520a9e264b7f24c0e8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 23:09:01 -0500 Subject: [PATCH 16/19] perf(analytics): add cache pre-warming and SWR pattern for instant page load - Add server-side cache pre-warming on startup (non-blocking) - Implement stale-while-revalidate pattern (1hr stale window) - Add /api/usage/status endpoint for cache status - Remove full-page blocking skeleton, use per-component loading - Add "Updated X ago" timestamp indicator in UI header - Export AnalyticsSkeleton component for potential future use --- docs/project-roadmap.md | 17 +- src/web-server/index.ts | 7 + src/web-server/usage-routes.ts | 79 +++- .../analytics/model-breakdown-chart.tsx | 30 +- .../components/analytics/sessions-table.tsx | 2 +- .../analytics/usage-summary-cards.tsx | 16 +- .../analytics/usage-trend-chart.tsx | 2 +- ui/src/hooks/use-usage.ts | 20 + ui/src/pages/analytics.tsx | 444 ++++++++++-------- 9 files changed, 393 insertions(+), 224 deletions(-) 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/src/web-server/index.ts b/src/web-server/index.ts index 6d084f20..5eb5c87e 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -77,6 +77,13 @@ 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/usage-routes.ts b/src/web-server/usage-routes.ts index 6c8da9cb..dfc6e794 100644 --- a/src/web-server/usage-routes.ts +++ b/src/web-server/usage-routes.ts @@ -50,6 +50,17 @@ const CACHE_TTL = { 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>(); @@ -59,15 +70,38 @@ 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 { - // Check cache first const cached = cache.get(key) as CacheEntry | undefined; - if (cached && Date.now() - cached.timestamp < ttl) { + const now = Date.now(); + + // Fresh cache - return immediately + if (cached && now - cached.timestamp < ttl) { return cached.data; } - // Check if request is already pending (coalesce) + // 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; @@ -77,6 +111,7 @@ async function getCachedData(key: string, ttl: number, loader: () => Promise< const promise = loader() .then((data) => { cache.set(key, { data, timestamp: Date.now() }); + lastFetchTimestamp = Date.now(); return data; }) .finally(() => { @@ -115,6 +150,28 @@ 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 // ============================================================================ @@ -494,3 +551,19 @@ usageRoutes.post('/refresh', (_req: Request, res: Response) => { 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/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx index 81fb9576..cfc02294 100644 --- a/ui/src/components/analytics/model-breakdown-chart.tsx +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +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'; @@ -66,24 +66,23 @@ export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdo const data = payloadArray[0].payload; return ( -
-

{data.name}

-

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

+

{data.name}

+

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

-

Cost: ${data.cost.toFixed(4)}

-

Requests: {data.requests}

+

${data.cost.toFixed(4)}

); }; const renderLabel = (entry: { percentage: number }) => { - return `${entry.percentage.toFixed(1)}%`; + return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : ''; }; return (
- + {chartData.map((entry, index) => ( - + ))} - {value}} - /> + {/* Legend removed from here, moved to AnalyticsPage for better layout control */}
diff --git a/ui/src/components/analytics/sessions-table.tsx b/ui/src/components/analytics/sessions-table.tsx index 7385dab3..956ac4a6 100644 --- a/ui/src/components/analytics/sessions-table.tsx +++ b/ui/src/components/analytics/sessions-table.tsx @@ -33,7 +33,7 @@ export function SessionsTable({ data, isLoading }: SessionsTableProps) { const [currentPage, setCurrentPage] = useState(0); // Get sessions array (stable reference for memoization) - const sessions = data?.sessions ?? []; + const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]); // Filter sessions based on search term const filteredSessions = useMemo(() => { diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index d76aad93..9be107c5 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -73,19 +73,19 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { ]; return ( -
+
{cards.map((card, index) => { const Icon = card.icon; return ( - -
-
-

{card.title}

-

{card.format(card.value)}

+ +
+
+

{card.title}

+

{card.format(card.value)}

-
- +
+
diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 01e8bc7f..7bdb8343 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -38,7 +38,7 @@ export function UsageTrendChart({ const chartData = useMemo(() => { if (!data || data.length === 0) return []; - return data.map((item) => ({ + return [...data].reverse().map((item) => ({ ...item, dateFormatted: formatDate(item.date, granularity), costRounded: Number(item.cost.toFixed(4)), diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 8486e3d5..2a19800a 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -65,6 +65,11 @@ export interface UsageQueryOptions { offset?: number; } +export interface UsageStatus { + lastFetch: number | null; + cacheSize: number; +} + // API const BASE_URL = '/api'; @@ -125,6 +130,8 @@ export const usageApi = { 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 @@ -200,3 +207,16 @@ export function useRefreshUsage() { 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/pages/analytics.tsx b/ui/src/pages/analytics.tsx index bf98651d..410ae7fa 100644 --- a/ui/src/pages/analytics.tsx +++ b/ui/src/pages/analytics.tsx @@ -5,9 +5,9 @@ * Features daily/monthly views, trend charts, model breakdown, and session history. */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import type { DateRange } from 'react-day-picker'; -import { startOfMonth, subDays } from 'date-fns'; +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'; @@ -17,13 +17,14 @@ 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, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react'; +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'; @@ -63,164 +64,214 @@ export function AnalyticsPage() { ...apiOptions, limit: 50, }); + const { data: status } = useUsageStatus(); - // Loading state - if (isSummaryLoading || isTrendsLoading || isModelsLoading) { - return ; - } + // 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 your Claude Code usage and insights

-
-
- - -
-
- - {/* Date Range Filter */} - - - {/* Summary Cards */} - - - {/* Main Content Tabs */} - setViewMode(v as ViewMode)}> - - - - Daily - - - - Monthly - - - - Sessions - - - - {/* Daily View */} - -
- {/* Usage Trend Chart */} - - - - - Usage Trends - - - - - - - - {/* Model Distribution */} - - - - - Model Usage - - - - - - - - {/* Cost Breakdown */} - - - Cost by Model - - - {isModelsLoading ? ( - - ) : ( -
- {models?.slice(0, 5).map((model) => ( -
-
-
- {model.model} -
- - ${model.cost.toFixed(4)} - -
- ))} -
- )} - - +
+
+ {/* Header */} +
+
+

Analytics

+

Track usage & insights

- +
+ + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
- {/* Monthly View */} - - - - - - Monthly Overview - - - - - - - + {/* Summary Cards */} + - {/* Sessions View */} - - - - - - Session History - - - - - - - - + {/* 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 + + + +
+ +
+
+
+
+
+ +
); } @@ -248,47 +299,60 @@ function getModelColor(model: string): string { return colors[Math.abs(hash) % colors.length]; } -// Skeleton loading state -function AnalyticsSkeleton() { +export function AnalyticsSkeleton() { return ( -
- {/* Header */} -
- - -
+
+ {/* Usage Trends Skeleton */} + + + + + + + + - {/* Date Filter */} - - - {/* Summary Cards */} -
- {[1, 2, 3, 4].map((i) => ( - - - - - - - ))} -
- - {/* Charts */} -
- - - + {/* Bottom Row Skeletons */} +
+ {/* Model Usage Skeleton */} + + + - - + +
+
+ +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+
- - - + + {/* Cost Breakdown Skeleton */} + + + - - + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +
From f15754d30ff071ff25b1a1ecc7b1ffd741bb2dfa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 04:11:45 +0000 Subject: [PATCH 17/19] chore(release): 5.11.0-dev.7 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 04907d8e..f0ef422e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.6 +5.11.0-dev.7 diff --git a/package.json b/package.json index 8269d788..8b9e480e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.6", + "version": "5.11.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 1475adb61649fc9ac5d7e66845649f3eb63f88b0 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 8 Dec 2025 23:20:36 -0500 Subject: [PATCH 18/19] feat(cliproxy): promote thinking models as default for agy provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove deprecated flag from Claude Opus 4.5 Thinking and Sonnet 4.5 Thinking - Reorder models with thinking models at top (Opus → Sonnet Thinking → Sonnet → Gemini) - Change default model to gemini-claude-opus-4-5-thinking - Update tests to reflect new ordering and non-deprecated status --- src/cliproxy/model-catalog.ts | 28 ++++++-------- tests/unit/cliproxy/model-catalog.test.js | 47 +++++++++++------------ 2 files changed, 33 insertions(+), 42 deletions(-) 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/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', () => { From 0075afa48f363ee058f502daffa3b9817ccc18af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Dec 2025 04:23:15 +0000 Subject: [PATCH 19/19] chore(release): 5.11.0-dev.8 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index f0ef422e..4cb144ac 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.0-dev.7 +5.11.0-dev.8 diff --git a/package.json b/package.json index 8b9e480e..8aee2756 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "5.11.0-dev.7", + "version": "5.11.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",