mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
feat!: web dashboard, analytics, and CLIProxy multi-account support
This commit is contained in:
@@ -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.**
|
||||
|
||||
@@ -12,10 +12,11 @@ Features a modern React 19 dashboard with real-time updates.
|
||||
|
||||
[](LICENSE)
|
||||
[]()
|
||||
[](https://claudekit.cc?ref=HMNKXOHN)
|
||||
|
||||
[](https://www.npmjs.com/package/@kaitranntt/ccs)
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://claudekit.cc?ref=HMNKXOHN)
|
||||
|
||||
**Languages**: [English](README.md) | [Tiếng Việt](docs/vi/README.md) | [日本語](docs/ja/README.md)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "@kaitranntt/ccs",
|
||||
"dependencies": {
|
||||
"better-ccusage": "^1.2.6",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
@@ -445,6 +446,8 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-ccusage": ["better-ccusage@1.2.6", "", { "bin": { "better-ccusage": "dist/index.js" } }, "sha512-IZCYBX1kF0IfJ6ho9JMwLKn2o820WRiVGZ+2tVS2olODU5J7Np5mJ1j1i5HtazZPNo2S9wKU9C9iysc0f8Cjqw=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
|
||||
|
||||
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
|
||||
|
||||
+13
-4
@@ -440,6 +440,7 @@ src/types/
|
||||
- Intelligent profile selection algorithms
|
||||
- Cost estimation with typed calculation models
|
||||
- Performance optimization through type-aware caching
|
||||
- Recently implemented significant UI improvements for analytics dashboard, enhancing data presentation.
|
||||
|
||||
2. **Enhanced Session Management**
|
||||
- Type-safe session persistence with serialization
|
||||
@@ -584,21 +585,29 @@ src/types/
|
||||
- **User Experience**: One-command channel switching without data loss
|
||||
- **Backward Compatibility**: Zero breaking changes, existing workflows preserved
|
||||
|
||||
### Version 4.5.1 - UI Layout Improvements
|
||||
### Version 4.5.1 - UI Quality Gate Fixes & Layout Improvements
|
||||
**Release Date**: 2025-12-08
|
||||
|
||||
#### UI Fixes & Improvements
|
||||
- ✅ **Auto-formatting**: 31 UI files auto-formatted for consistent styling.
|
||||
- ✅ **Fast Refresh Exports**: Resolved `react-refresh/only-export-components` by extracting `buttonVariants`, `useSidebar`, and `useWebSocketContext` to separate files.
|
||||
- ✅ **React Hooks Issues**: Fixed `react-hooks/purity` (`Math.random()` in `useMemo` for `sidebar.tsx`) and `react-hooks/set-state-in-effect` (`use-theme.ts`, `settings.tsx`).
|
||||
- ✅ **useWebSocket Hook Restructure**: Addressed `react-hooks/immutability` errors and dependency array warnings in `use-websocket.ts`.
|
||||
- ✅ **TypeScript Strict Mode**: Implemented null-check for `document.getElementById('root')` in `src/main.tsx` for strict mode compliance.
|
||||
- ✅ **Duplicate Directory Removal**: Cleaned up extraneous `ui/@/` directory.
|
||||
- ✅ **CLIProxy Card Padding**: Removed excessive padding from CLIProxy cards for better visual integration.
|
||||
- ✅ **CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard for enhanced user experience.
|
||||
- ✅ **Dropdown Styling**: Refined dropdown component styling for consistency and readability.
|
||||
- ✅ **CLIProxy Dashboard Layout**: Improved overall layout and styling of the CLIProxy dashboard.
|
||||
- ✅ **Dropdown Styling**: Refined dropdown component styling.
|
||||
- ✅ **Model Usage Card**: Corrected icon display and refined donut chart styling.
|
||||
|
||||
#### Technical Improvements
|
||||
- **Improved UI Responsiveness**: Adjustments ensure better display across various screen sizes.
|
||||
- **Enhanced User Experience**: Minor visual tweaks lead to a more polished and intuitive interface.
|
||||
|
||||
#### Validation Results
|
||||
- **UI Rendering**: ✅ All UI components render correctly after layout adjustments.
|
||||
- **UI Rendering**: ✅ All UI components render correctly after adjustments and fixes.
|
||||
- **Functional Impact**: ✅ No regressions introduced, core functionality remains stable.
|
||||
- **Code Quality**: ✅ All ESLint and TypeScript quality gates passed after fixes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "5.11.0",
|
||||
"version": "5.11.0-dev.8",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -79,6 +79,7 @@
|
||||
"postinstall": "node scripts/postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-ccusage": "^1.2.6",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "^5.6.2",
|
||||
"chokidar": "^5.0.0",
|
||||
|
||||
@@ -48,8 +48,18 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
defaultModel: 'gemini-3-pro-preview',
|
||||
defaultModel: 'gemini-claude-opus-4-5-thinking',
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-claude-opus-4-5-thinking',
|
||||
name: 'Claude Opus 4.5 Thinking',
|
||||
description: 'Most capable, extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
||||
name: 'Claude Sonnet 4.5 Thinking',
|
||||
description: 'Balanced with extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
@@ -60,22 +70,6 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
name: 'Gemini 3 Pro',
|
||||
description: 'Google latest model via Antigravity',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-opus-4-5-thinking',
|
||||
name: 'Claude Opus 4.5 Thinking',
|
||||
description: 'Most capable, extended thinking',
|
||||
deprecated: true,
|
||||
deprecationReason:
|
||||
'Thinking models are deprecated due to compatibility issues with Antigravity',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
||||
name: 'Claude Sonnet 4.5 Thinking',
|
||||
description: 'Balanced with extended thinking',
|
||||
deprecated: true,
|
||||
deprecationReason:
|
||||
'Thinking models are deprecated due to compatibility issues with Antigravity',
|
||||
},
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Vendored
+75
@@ -2,6 +2,81 @@
|
||||
* Type shims for incomplete external dependencies
|
||||
*/
|
||||
|
||||
// better-ccusage types (package has JS exports but incomplete TS subpath support)
|
||||
declare module 'better-ccusage/data-loader' {
|
||||
export interface ModelBreakdown {
|
||||
modelName: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
totalCost: number;
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
}
|
||||
|
||||
export interface MonthlyUsage {
|
||||
month: string;
|
||||
source: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
totalCost: number;
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
}
|
||||
|
||||
export interface SessionUsage {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cost: number;
|
||||
totalCost: number;
|
||||
lastActivity: string;
|
||||
versions: string[];
|
||||
modelsUsed: string[];
|
||||
modelBreakdowns: ModelBreakdown[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface DataLoaderOptions {
|
||||
mode?: 'calculate' | 'cached';
|
||||
claudePaths?: string[];
|
||||
}
|
||||
|
||||
export function loadDailyUsageData(options?: DataLoaderOptions): Promise<DailyUsage[]>;
|
||||
export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise<MonthlyUsage[]>;
|
||||
export function loadSessionData(options?: DataLoaderOptions): Promise<SessionUsage[]>;
|
||||
}
|
||||
|
||||
declare module 'better-ccusage/calculate-cost' {
|
||||
export interface Totals {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
cacheReadTokens: number;
|
||||
costUSD: number;
|
||||
}
|
||||
|
||||
export function calculateTotals(entries: unknown[]): Totals;
|
||||
export function getTotalTokens(entries: unknown[]): number;
|
||||
}
|
||||
|
||||
declare module 'cli-table3' {
|
||||
interface TableOptions {
|
||||
head?: string[];
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,102 +1,219 @@
|
||||
/**
|
||||
* Health Check Service (Phase 06)
|
||||
*
|
||||
* Runs health checks for CCS dashboard: Claude CLI, config files, CLIProxy binary.
|
||||
* Runs comprehensive health checks for CCS dashboard matching `ccs doctor` output.
|
||||
* Groups: System, Configuration, Profiles & Delegation, System Health, CLIProxy
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { getCcsDir, getConfigPath } from '../utils/config-manager';
|
||||
import { isCLIProxyInstalled, getInstalledCliproxyVersion, getCLIProxyPath } from '../cliproxy';
|
||||
import {
|
||||
isCLIProxyInstalled,
|
||||
getInstalledCliproxyVersion,
|
||||
getCLIProxyPath,
|
||||
getConfigPath as getCliproxyConfigPath,
|
||||
getAllAuthStatus,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from '../cliproxy';
|
||||
import { getClaudeCliInfo } from '../utils/claude-detector';
|
||||
import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils';
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
status: 'ok' | 'warning' | 'error' | 'info';
|
||||
message: string;
|
||||
details?: string;
|
||||
fix?: string;
|
||||
fixable?: boolean;
|
||||
}
|
||||
|
||||
export interface HealthGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
checks: HealthCheck[];
|
||||
}
|
||||
|
||||
export interface HealthReport {
|
||||
timestamp: number;
|
||||
checks: HealthCheck[];
|
||||
version: string;
|
||||
groups: HealthGroup[];
|
||||
checks: HealthCheck[]; // Flat list for backward compatibility
|
||||
summary: {
|
||||
total: number;
|
||||
passed: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
info: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all health checks and return report
|
||||
*/
|
||||
export function runHealthChecks(): HealthReport {
|
||||
const checks: HealthCheck[] = [];
|
||||
export async function runHealthChecks(): Promise<HealthReport> {
|
||||
const homedir = os.homedir();
|
||||
const ccsDir = getCcsDir();
|
||||
const claudeDir = path.join(homedir, '.claude');
|
||||
const version = packageJson.version;
|
||||
|
||||
// Check 1: Claude CLI
|
||||
checks.push(checkClaudeCli());
|
||||
const groups: HealthGroup[] = [];
|
||||
|
||||
// Check 2: Config file
|
||||
checks.push(checkConfigFile());
|
||||
// Group 1: System
|
||||
const systemChecks: HealthCheck[] = [];
|
||||
systemChecks.push(await checkClaudeCli());
|
||||
systemChecks.push(checkCcsDirectory(ccsDir));
|
||||
groups.push({ id: 'system', name: 'System', icon: 'Monitor', checks: systemChecks });
|
||||
|
||||
// Check 3: Profiles file
|
||||
checks.push(checkProfilesFile());
|
||||
// Group 2: Configuration
|
||||
const configChecks: HealthCheck[] = [];
|
||||
configChecks.push(checkConfigFile());
|
||||
configChecks.push(...checkSettingsFiles(ccsDir));
|
||||
configChecks.push(checkClaudeSettings(claudeDir));
|
||||
groups.push({
|
||||
id: 'configuration',
|
||||
name: 'Configuration',
|
||||
icon: 'Settings',
|
||||
checks: configChecks,
|
||||
});
|
||||
|
||||
// Check 4: CLIProxy binary
|
||||
checks.push(checkCliproxy());
|
||||
// Group 3: Profiles & Delegation
|
||||
const profileChecks: HealthCheck[] = [];
|
||||
profileChecks.push(checkProfiles(ccsDir));
|
||||
profileChecks.push(checkInstances(ccsDir));
|
||||
profileChecks.push(checkDelegation(ccsDir));
|
||||
groups.push({
|
||||
id: 'profiles',
|
||||
name: 'Profiles & Delegation',
|
||||
icon: 'Users',
|
||||
checks: profileChecks,
|
||||
});
|
||||
|
||||
// Check 5: CCS directory
|
||||
checks.push(checkCcsDirectory());
|
||||
// Group 4: System Health
|
||||
const healthChecks: HealthCheck[] = [];
|
||||
healthChecks.push(checkPermissions(ccsDir));
|
||||
healthChecks.push(checkCcsSymlinks());
|
||||
healthChecks.push(checkSettingsSymlinks(homedir, ccsDir, claudeDir));
|
||||
groups.push({
|
||||
id: 'system-health',
|
||||
name: 'System Health',
|
||||
icon: 'Shield',
|
||||
checks: healthChecks,
|
||||
});
|
||||
|
||||
// Group 5: CLIProxy
|
||||
const cliproxyChecks: HealthCheck[] = [];
|
||||
cliproxyChecks.push(checkCliproxyBinary());
|
||||
cliproxyChecks.push(checkCliproxyConfig());
|
||||
cliproxyChecks.push(...checkOAuthProviders());
|
||||
cliproxyChecks.push(await checkCliproxyPort());
|
||||
groups.push({
|
||||
id: 'cliproxy',
|
||||
name: 'CLIProxy (OAuth)',
|
||||
icon: 'Zap',
|
||||
checks: cliproxyChecks,
|
||||
});
|
||||
|
||||
// Flatten all checks for backward compatibility
|
||||
const allChecks = groups.flatMap((g) => g.checks);
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
total: checks.length,
|
||||
passed: checks.filter((c) => c.status === 'ok').length,
|
||||
warnings: checks.filter((c) => c.status === 'warning').length,
|
||||
errors: checks.filter((c) => c.status === 'error').length,
|
||||
total: allChecks.length,
|
||||
passed: allChecks.filter((c) => c.status === 'ok').length,
|
||||
warnings: allChecks.filter((c) => c.status === 'warning').length,
|
||||
errors: allChecks.filter((c) => c.status === 'error').length,
|
||||
info: allChecks.filter((c) => c.status === 'info').length,
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
checks,
|
||||
version,
|
||||
groups,
|
||||
checks: allChecks,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function checkClaudeCli(): HealthCheck {
|
||||
// Check 1: Claude CLI
|
||||
async function checkClaudeCli(): Promise<HealthCheck> {
|
||||
const cliInfo = getClaudeCliInfo();
|
||||
|
||||
if (!cliInfo) {
|
||||
return {
|
||||
id: 'claude-cli',
|
||||
name: 'Claude CLI',
|
||||
status: 'error',
|
||||
message: 'Not found in PATH',
|
||||
fix: 'Install: npm install -g @anthropic-ai/claude-code',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const version = execSync('claude --version', {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
const versionMatch = version.match(/(\d+\.\d+\.\d+)/);
|
||||
const versionStr = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
return {
|
||||
id: 'claude-cli',
|
||||
name: 'Claude CLI',
|
||||
status: 'ok',
|
||||
message: `Installed: ${version}`,
|
||||
message: `v${versionStr}`,
|
||||
details: cliInfo.path,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: 'claude-cli',
|
||||
name: 'Claude CLI',
|
||||
status: 'error',
|
||||
message: 'Not found in PATH',
|
||||
details: 'Install: npm install -g @anthropic-ai/claude-code',
|
||||
message: 'Not working',
|
||||
details: cliInfo.path,
|
||||
fix: 'Reinstall Claude CLI',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2: CCS Directory
|
||||
function checkCcsDirectory(ccsDir: string): HealthCheck {
|
||||
if (fs.existsSync(ccsDir)) {
|
||||
return {
|
||||
id: 'ccs-dir',
|
||||
name: 'CCS Directory',
|
||||
status: 'ok',
|
||||
message: 'Exists',
|
||||
details: '~/.ccs/',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'ccs-dir',
|
||||
name: 'CCS Directory',
|
||||
status: 'error',
|
||||
message: 'Not found',
|
||||
details: ccsDir,
|
||||
fix: 'Run: npm install -g @kaitranntt/ccs --force',
|
||||
fixable: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check 3: Config file
|
||||
function checkConfigFile(): HealthCheck {
|
||||
const configPath = getConfigPath();
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'Config File',
|
||||
name: 'config.json',
|
||||
status: 'warning',
|
||||
message: 'Not found',
|
||||
details: configPath,
|
||||
@@ -109,15 +226,15 @@ function checkConfigFile(): HealthCheck {
|
||||
JSON.parse(content);
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'Config File',
|
||||
name: 'config.json',
|
||||
status: 'ok',
|
||||
message: 'Valid JSON',
|
||||
message: 'Valid',
|
||||
details: configPath,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: 'config-file',
|
||||
name: 'Config File',
|
||||
name: 'config.json',
|
||||
status: 'error',
|
||||
message: 'Invalid JSON',
|
||||
details: configPath,
|
||||
@@ -125,84 +242,508 @@ function checkConfigFile(): HealthCheck {
|
||||
}
|
||||
}
|
||||
|
||||
function checkProfilesFile(): HealthCheck {
|
||||
const ccsDir = getCcsDir();
|
||||
const profilesPath = path.join(ccsDir, 'profiles.json');
|
||||
// Check 4: Settings files (glm, kimi)
|
||||
function checkSettingsFiles(ccsDir: string): HealthCheck[] {
|
||||
const checks: HealthCheck[] = [];
|
||||
const files = [
|
||||
{ name: 'glm.settings.json', profile: 'glm' },
|
||||
{ name: 'kimi.settings.json', profile: 'kimi' },
|
||||
];
|
||||
|
||||
if (!fs.existsSync(profilesPath)) {
|
||||
const { DelegationValidator } = require('../utils/delegation-validator');
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(ccsDir, file.name);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
checks.push({
|
||||
id: `settings-${file.profile}`,
|
||||
name: file.name,
|
||||
status: 'info',
|
||||
message: 'Not configured',
|
||||
details: filePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
JSON.parse(content);
|
||||
|
||||
const validation = DelegationValidator.validate(file.profile);
|
||||
|
||||
if (validation.valid) {
|
||||
checks.push({
|
||||
id: `settings-${file.profile}`,
|
||||
name: file.name,
|
||||
status: 'ok',
|
||||
message: 'Key configured',
|
||||
details: filePath,
|
||||
});
|
||||
} else if (validation.error && validation.error.includes('placeholder')) {
|
||||
checks.push({
|
||||
id: `settings-${file.profile}`,
|
||||
name: file.name,
|
||||
status: 'warning',
|
||||
message: 'Placeholder key',
|
||||
details: filePath,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: `settings-${file.profile}`,
|
||||
name: file.name,
|
||||
status: 'ok',
|
||||
message: 'Valid JSON',
|
||||
details: filePath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
checks.push({
|
||||
id: `settings-${file.profile}`,
|
||||
name: file.name,
|
||||
status: 'error',
|
||||
message: 'Invalid JSON',
|
||||
details: filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
// Check 5: Claude settings
|
||||
function checkClaudeSettings(claudeDir: string): HealthCheck {
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
return {
|
||||
id: 'profiles-file',
|
||||
name: 'Profiles Registry',
|
||||
id: 'claude-settings',
|
||||
name: '~/.claude/settings.json',
|
||||
status: 'warning',
|
||||
message: 'Not found (will be created on first account)',
|
||||
details: profilesPath,
|
||||
fixable: true,
|
||||
message: 'Not found',
|
||||
fix: 'Run: claude /login',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(profilesPath, 'utf8');
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
JSON.parse(content);
|
||||
return {
|
||||
id: 'profiles-file',
|
||||
name: 'Profiles Registry',
|
||||
id: 'claude-settings',
|
||||
name: '~/.claude/settings.json',
|
||||
status: 'ok',
|
||||
message: 'Valid',
|
||||
details: profilesPath,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: 'profiles-file',
|
||||
name: 'Profiles Registry',
|
||||
status: 'error',
|
||||
id: 'claude-settings',
|
||||
name: '~/.claude/settings.json',
|
||||
status: 'warning',
|
||||
message: 'Invalid JSON',
|
||||
details: profilesPath,
|
||||
fix: 'Run: claude /login',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCliproxy(): HealthCheck {
|
||||
// Check 6: Profiles
|
||||
function checkProfiles(ccsDir: string): HealthCheck {
|
||||
const configPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'info',
|
||||
message: 'config.json not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
if (!config.profiles || typeof config.profiles !== 'object') {
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'error',
|
||||
message: 'Missing profiles object',
|
||||
fix: 'Run: npm install -g @kaitranntt/ccs --force',
|
||||
};
|
||||
}
|
||||
|
||||
const profileCount = Object.keys(config.profiles).length;
|
||||
const profileNames = Object.keys(config.profiles).join(', ');
|
||||
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'ok',
|
||||
message: `${profileCount} configured`,
|
||||
details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
status: 'error',
|
||||
message: (e as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7: Instances
|
||||
function checkInstances(ccsDir: string): HealthCheck {
|
||||
const instancesDir = path.join(ccsDir, 'instances');
|
||||
|
||||
if (!fs.existsSync(instancesDir)) {
|
||||
return {
|
||||
id: 'instances',
|
||||
name: 'Instances',
|
||||
status: 'ok',
|
||||
message: 'No account profiles',
|
||||
};
|
||||
}
|
||||
|
||||
const instances = fs.readdirSync(instancesDir).filter((name) => {
|
||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
||||
});
|
||||
|
||||
if (instances.length === 0) {
|
||||
return {
|
||||
id: 'instances',
|
||||
name: 'Instances',
|
||||
status: 'ok',
|
||||
message: 'No account profiles',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'instances',
|
||||
name: 'Instances',
|
||||
status: 'ok',
|
||||
message: `${instances.length} account profile${instances.length !== 1 ? 's' : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check 8: Delegation
|
||||
function checkDelegation(ccsDir: string): HealthCheck {
|
||||
const ccsClaudeCommandsDir = path.join(ccsDir, '.claude', 'commands');
|
||||
const hasCcsCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs.md'));
|
||||
const hasContinueCommand = fs.existsSync(path.join(ccsClaudeCommandsDir, 'ccs', 'continue.md'));
|
||||
|
||||
if (!hasCcsCommand || !hasContinueCommand) {
|
||||
return {
|
||||
id: 'delegation',
|
||||
name: 'Delegation',
|
||||
status: 'warning',
|
||||
message: 'Not installed',
|
||||
fix: 'Run: npm install -g @kaitranntt/ccs --force',
|
||||
};
|
||||
}
|
||||
|
||||
const { DelegationValidator } = require('../utils/delegation-validator');
|
||||
const readyProfiles: string[] = [];
|
||||
|
||||
for (const profile of ['glm', 'kimi']) {
|
||||
const validation = DelegationValidator.validate(profile);
|
||||
if (validation.valid) {
|
||||
readyProfiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyProfiles.length === 0) {
|
||||
return {
|
||||
id: 'delegation',
|
||||
name: 'Delegation',
|
||||
status: 'warning',
|
||||
message: 'No profiles ready',
|
||||
fix: 'Configure profiles with valid API keys',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'delegation',
|
||||
name: 'Delegation',
|
||||
status: 'ok',
|
||||
message: `${readyProfiles.length} profiles ready`,
|
||||
details: readyProfiles.join(', '),
|
||||
};
|
||||
}
|
||||
|
||||
// Check 9: Permissions
|
||||
function checkPermissions(ccsDir: string): HealthCheck {
|
||||
const testFile = path.join(ccsDir, '.permission-test');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(testFile, 'test', 'utf8');
|
||||
fs.unlinkSync(testFile);
|
||||
return {
|
||||
id: 'permissions',
|
||||
name: 'Permissions',
|
||||
status: 'ok',
|
||||
message: 'Write access verified',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: 'permissions',
|
||||
name: 'Permissions',
|
||||
status: 'error',
|
||||
message: 'Cannot write to ~/.ccs/',
|
||||
fix: 'sudo chown -R $USER ~/.ccs ~/.claude && chmod 755 ~/.ccs ~/.claude',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check 10: CCS Symlinks
|
||||
function checkCcsSymlinks(): HealthCheck {
|
||||
try {
|
||||
const { ClaudeSymlinkManager } = require('../utils/claude-symlink-manager');
|
||||
const manager = new ClaudeSymlinkManager();
|
||||
const health = manager.checkHealth();
|
||||
|
||||
if (health.healthy) {
|
||||
const itemCount = manager.ccsItems.length;
|
||||
return {
|
||||
id: 'ccs-symlinks',
|
||||
name: 'CCS Symlinks',
|
||||
status: 'ok',
|
||||
message: `${itemCount}/${itemCount} items linked`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'ccs-symlinks',
|
||||
name: 'CCS Symlinks',
|
||||
status: 'warning',
|
||||
message: `${health.issues.length} issues found`,
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: 'ccs-symlinks',
|
||||
name: 'CCS Symlinks',
|
||||
status: 'warning',
|
||||
message: 'Could not check',
|
||||
details: (e as Error).message,
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check 11: Settings Symlinks
|
||||
function checkSettingsSymlinks(homedir: string, ccsDir: string, claudeDir: string): HealthCheck {
|
||||
try {
|
||||
const sharedDir = path.join(homedir, '.ccs', 'shared');
|
||||
const sharedSettings = path.join(sharedDir, 'settings.json');
|
||||
const claudeSettings = path.join(claudeDir, 'settings.json');
|
||||
|
||||
if (!fs.existsSync(sharedSettings)) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'warning',
|
||||
message: 'Shared not found',
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
|
||||
const sharedStats = fs.lstatSync(sharedSettings);
|
||||
if (!sharedStats.isSymbolicLink()) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'warning',
|
||||
message: 'Not a symlink',
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
|
||||
const sharedTarget = fs.readlinkSync(sharedSettings);
|
||||
const resolvedShared = path.resolve(path.dirname(sharedSettings), sharedTarget);
|
||||
|
||||
if (resolvedShared !== claudeSettings) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'warning',
|
||||
message: 'Wrong target',
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
|
||||
// Check instances
|
||||
const instancesDir = path.join(ccsDir, 'instances');
|
||||
if (!fs.existsSync(instancesDir)) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'ok',
|
||||
message: 'Shared symlink valid',
|
||||
};
|
||||
}
|
||||
|
||||
const instances = fs.readdirSync(instancesDir).filter((name) => {
|
||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
||||
});
|
||||
|
||||
let broken = 0;
|
||||
for (const instance of instances) {
|
||||
const instanceSettings = path.join(instancesDir, instance, 'settings.json');
|
||||
if (!fs.existsSync(instanceSettings)) {
|
||||
broken++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stats = fs.lstatSync(instanceSettings);
|
||||
if (!stats.isSymbolicLink()) {
|
||||
broken++;
|
||||
continue;
|
||||
}
|
||||
const target = fs.readlinkSync(instanceSettings);
|
||||
const resolved = path.resolve(path.dirname(instanceSettings), target);
|
||||
if (resolved !== sharedSettings) {
|
||||
broken++;
|
||||
}
|
||||
} catch {
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
|
||||
if (broken > 0) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'warning',
|
||||
message: `${broken} broken instance(s)`,
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'ok',
|
||||
message: `${instances.length} instance(s) valid`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: 'settings-symlinks',
|
||||
name: 'settings.json',
|
||||
status: 'warning',
|
||||
message: 'Check failed',
|
||||
details: (e as Error).message,
|
||||
fix: 'Run: ccs sync',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check 12: CLIProxy Binary
|
||||
function checkCliproxyBinary(): HealthCheck {
|
||||
if (isCLIProxyInstalled()) {
|
||||
const version = getInstalledCliproxyVersion();
|
||||
const binaryPath = getCLIProxyPath();
|
||||
return {
|
||||
id: 'cliproxy',
|
||||
name: 'CLIProxy',
|
||||
id: 'cliproxy-binary',
|
||||
name: 'CLIProxy Binary',
|
||||
status: 'ok',
|
||||
message: `Installed: ${version}`,
|
||||
message: `v${version}`,
|
||||
details: binaryPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cliproxy',
|
||||
name: 'CLIProxy',
|
||||
status: 'warning',
|
||||
message: 'Not installed (optional)',
|
||||
details: 'Required for gemini/codex/agy providers. Install: ccs cliproxy --latest',
|
||||
id: 'cliproxy-binary',
|
||||
name: 'CLIProxy Binary',
|
||||
status: 'info',
|
||||
message: 'Not installed',
|
||||
details: 'Downloads on first use',
|
||||
};
|
||||
}
|
||||
|
||||
function checkCcsDirectory(): HealthCheck {
|
||||
const ccsDir = getCcsDir();
|
||||
// Check 13: CLIProxy Config
|
||||
function checkCliproxyConfig(): HealthCheck {
|
||||
const configPath = getCliproxyConfigPath();
|
||||
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
if (fs.existsSync(configPath)) {
|
||||
return {
|
||||
id: 'ccs-dir',
|
||||
name: 'CCS Directory',
|
||||
status: 'warning',
|
||||
message: 'Not found',
|
||||
details: ccsDir,
|
||||
fixable: true,
|
||||
id: 'cliproxy-config',
|
||||
name: 'CLIProxy Config',
|
||||
status: 'ok',
|
||||
message: 'cliproxy/config.yaml',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'ccs-dir',
|
||||
name: 'CCS Directory',
|
||||
status: 'ok',
|
||||
message: 'Exists',
|
||||
details: ccsDir,
|
||||
id: 'cliproxy-config',
|
||||
name: 'CLIProxy Config',
|
||||
status: 'info',
|
||||
message: 'Not created',
|
||||
details: 'Generated on first use',
|
||||
};
|
||||
}
|
||||
|
||||
// Check 14: OAuth Providers
|
||||
function checkOAuthProviders(): HealthCheck[] {
|
||||
const authStatuses = getAllAuthStatus();
|
||||
const checks: HealthCheck[] = [];
|
||||
|
||||
for (const status of authStatuses) {
|
||||
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
|
||||
|
||||
if (status.authenticated) {
|
||||
const lastAuth = status.lastAuth ? status.lastAuth.toLocaleDateString() : '';
|
||||
checks.push({
|
||||
id: `oauth-${status.provider}`,
|
||||
name: `${providerName} Auth`,
|
||||
status: 'ok',
|
||||
message: 'Authenticated',
|
||||
details: lastAuth,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
id: `oauth-${status.provider}`,
|
||||
name: `${providerName} Auth`,
|
||||
status: 'info',
|
||||
message: 'Not authenticated',
|
||||
fix: `Run: ccs ${status.provider} --auth`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
// Check 15: CLIProxy Port
|
||||
async function checkCliproxyPort(): Promise<HealthCheck> {
|
||||
const portProcess = await getPortProcess(CLIPROXY_DEFAULT_PORT);
|
||||
|
||||
if (!portProcess) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'info',
|
||||
message: `${CLIPROXY_DEFAULT_PORT} free`,
|
||||
details: 'Proxy not running',
|
||||
};
|
||||
}
|
||||
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'ok',
|
||||
message: 'CLIProxy running',
|
||||
details: `PID ${portProcess.pid}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'cliproxy-port',
|
||||
name: 'CLIProxy Port',
|
||||
status: 'warning',
|
||||
message: `Occupied by ${portProcess.processName}`,
|
||||
details: `PID ${portProcess.pid}`,
|
||||
fix: `Kill process: kill ${portProcess.pid}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const { overviewRoutes } = await import('./overview-routes');
|
||||
app.use('/api/overview', overviewRoutes);
|
||||
|
||||
// Usage analytics routes
|
||||
const { usageRoutes } = await import('./usage-routes');
|
||||
app.use('/api/usage', usageRoutes);
|
||||
|
||||
// Dev mode: use Vite middleware for HMR
|
||||
if (options.dev) {
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
@@ -73,6 +77,13 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
// Start listening
|
||||
return new Promise<ServerInstance>((resolve) => {
|
||||
server.listen(options.port, () => {
|
||||
// Non-blocking prewarm: load usage cache in background
|
||||
import('./usage-routes').then(({ prewarmUsageCache }) => {
|
||||
prewarmUsageCache().catch(() => {
|
||||
// Error already logged in prewarmUsageCache
|
||||
});
|
||||
});
|
||||
|
||||
resolve({ server, wss, cleanup });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,25 +9,38 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfig } from '../utils/config-manager';
|
||||
import { runHealthChecks } from './health-service';
|
||||
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import { getVersion } from '../utils/version';
|
||||
|
||||
export const overviewRoutes = Router();
|
||||
|
||||
/**
|
||||
* GET /api/overview
|
||||
*/
|
||||
overviewRoutes.get('/', (_req: Request, res: Response) => {
|
||||
overviewRoutes.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
|
||||
const profileCount = Object.keys(config.profiles).length;
|
||||
const cliproxyCount = Object.keys(config.cliproxy || {}).length;
|
||||
const cliproxyVariantCount = Object.keys(config.cliproxy || {}).length;
|
||||
|
||||
// Count authenticated built-in providers (gemini, codex, agy, qwen, iflow)
|
||||
initializeAccounts();
|
||||
const authStatuses = getAllAuthStatus();
|
||||
const authenticatedProviderCount = authStatuses.filter((s) => s.authenticated).length;
|
||||
|
||||
// Total CLIProxy = custom variants + authenticated providers
|
||||
const totalCliproxyCount = cliproxyVariantCount + authenticatedProviderCount;
|
||||
|
||||
// Get quick health summary
|
||||
const health = runHealthChecks();
|
||||
const health = await runHealthChecks();
|
||||
|
||||
res.json({
|
||||
version: getVersion(),
|
||||
profiles: profileCount,
|
||||
cliproxy: cliproxyCount,
|
||||
cliproxy: totalCliproxyCount,
|
||||
cliproxyVariants: cliproxyVariantCount,
|
||||
cliproxyProviders: authenticatedProviderCount,
|
||||
accounts: getAccountCount(),
|
||||
health: {
|
||||
status:
|
||||
@@ -38,8 +51,11 @@ overviewRoutes.get('/', (_req: Request, res: Response) => {
|
||||
});
|
||||
} catch {
|
||||
res.json({
|
||||
version: getVersion(),
|
||||
profiles: 0,
|
||||
cliproxy: 0,
|
||||
cliproxyVariants: 0,
|
||||
cliproxyProviders: 0,
|
||||
accounts: 0,
|
||||
health: { status: 'error', passed: 0, total: 0 },
|
||||
});
|
||||
|
||||
@@ -478,10 +478,17 @@ function maskApiKeys(settings: Settings): Settings {
|
||||
if (!settings.env) return settings;
|
||||
|
||||
const masked = { ...settings, env: { ...settings.env } };
|
||||
const sensitiveKeys = ['ANTHROPIC_AUTH_TOKEN', 'API_KEY', 'AUTH_TOKEN'];
|
||||
// Pattern-based matching for sensitive keys
|
||||
const sensitivePatterns = [
|
||||
/^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
|
||||
/_API_KEY$/, // Keys ending with _API_KEY
|
||||
/_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
|
||||
/^API_KEY$/, // Exact match for API_KEY
|
||||
/^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
|
||||
];
|
||||
|
||||
for (const key of Object.keys(masked.env)) {
|
||||
if (sensitiveKeys.some((sensitive) => key.includes(sensitive))) {
|
||||
if (sensitivePatterns.some((pattern) => pattern.test(key))) {
|
||||
const value = masked.env[key];
|
||||
if (value && value.length > 8) {
|
||||
masked.env[key] =
|
||||
@@ -644,8 +651,8 @@ apiRoutes.post('/accounts/default', (req: Request, res: Response): void => {
|
||||
/**
|
||||
* GET /api/health - Run health checks
|
||||
*/
|
||||
apiRoutes.get('/health', (_req: Request, res: Response) => {
|
||||
const report = runHealthChecks();
|
||||
apiRoutes.get('/health', async (_req: Request, res: Response) => {
|
||||
const report = await runHealthChecks();
|
||||
res.json(report);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* Usage Analytics API Routes
|
||||
*
|
||||
* Provides REST endpoints for Claude Code usage analytics using better-ccusage library.
|
||||
* Supports daily, monthly, and session-based usage data aggregation.
|
||||
*
|
||||
* Performance optimizations:
|
||||
* - TTL-based caching to reduce better-ccusage library calls
|
||||
* - Request coalescing to prevent duplicate concurrent requests
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import {
|
||||
loadDailyUsageData,
|
||||
loadMonthlyUsageData,
|
||||
loadSessionData,
|
||||
type DailyUsage,
|
||||
type MonthlyUsage,
|
||||
type SessionUsage,
|
||||
} from 'better-ccusage/data-loader';
|
||||
|
||||
export const usageRoutes = Router();
|
||||
|
||||
/** Query parameters for usage endpoints */
|
||||
interface UsageQuery {
|
||||
since?: string; // YYYYMMDD format
|
||||
until?: string; // YYYYMMDD format
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
// Constants for validation
|
||||
const MAX_LIMIT = 1000;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format
|
||||
|
||||
// ============================================================================
|
||||
// Caching Layer - Reduces better-ccusage library calls
|
||||
// ============================================================================
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Cache TTLs (milliseconds)
|
||||
const CACHE_TTL = {
|
||||
daily: 60 * 1000, // 1 minute - changes frequently
|
||||
monthly: 5 * 60 * 1000, // 5 minutes - aggregated data
|
||||
session: 60 * 1000, // 1 minute - user may refresh
|
||||
};
|
||||
|
||||
// Stale-while-revalidate: max age for stale data (1 hour)
|
||||
const STALE_TTL = 60 * 60 * 1000;
|
||||
|
||||
// Track when data was last fetched (for UI indicator)
|
||||
let lastFetchTimestamp: number | null = null;
|
||||
|
||||
/** Get timestamp of last successful data fetch */
|
||||
export function getLastFetchTimestamp(): number | null {
|
||||
return lastFetchTimestamp;
|
||||
}
|
||||
|
||||
// In-memory cache
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
// Pending requests for coalescing (prevents duplicate concurrent calls)
|
||||
const pendingRequests = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* Get cached data or fetch from loader with TTL
|
||||
* Also coalesces concurrent requests to prevent duplicate library calls
|
||||
* Implements stale-while-revalidate pattern for instant responses
|
||||
*/
|
||||
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
|
||||
const cached = cache.get(key) as CacheEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
|
||||
// Fresh cache - return immediately
|
||||
if (cached && now - cached.timestamp < ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Stale cache - return immediately, refresh in background (SWR pattern)
|
||||
if (cached && now - cached.timestamp < STALE_TTL) {
|
||||
// Fire and forget background refresh if not already pending
|
||||
if (!pendingRequests.has(key)) {
|
||||
const promise = loader()
|
||||
.then((data) => {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
lastFetchTimestamp = Date.now();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[!] Background refresh failed for ${key}:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(key);
|
||||
});
|
||||
pendingRequests.set(key, promise);
|
||||
}
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// No usable cache - check if request is already pending (coalesce)
|
||||
const pending = pendingRequests.get(key) as Promise<T> | undefined;
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Create new request
|
||||
const promise = loader()
|
||||
.then((data) => {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
lastFetchTimestamp = Date.now();
|
||||
return data;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequests.delete(key);
|
||||
});
|
||||
|
||||
pendingRequests.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Cached loader for daily usage data */
|
||||
async function getCachedDailyData(): Promise<DailyUsage[]> {
|
||||
return getCachedData('daily', CACHE_TTL.daily, async () => {
|
||||
return (await loadDailyUsageData()) as DailyUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/** Cached loader for monthly usage data */
|
||||
async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
|
||||
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
|
||||
return (await loadMonthlyUsageData()) as MonthlyUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/** Cached loader for session data */
|
||||
async function getCachedSessionData(): Promise<SessionUsage[]> {
|
||||
return getCachedData('session', CACHE_TTL.session, async () => {
|
||||
return (await loadSessionData()) as SessionUsage[];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data (useful for manual refresh)
|
||||
*/
|
||||
export function clearUsageCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm usage caches on server startup
|
||||
* Loads all usage data into cache so first user request is instant
|
||||
* Returns timestamp when cache was populated
|
||||
*/
|
||||
export async function prewarmUsageCache(): Promise<{ timestamp: number; elapsed: number }> {
|
||||
const start = Date.now();
|
||||
console.log('[i] Pre-warming usage cache...');
|
||||
|
||||
try {
|
||||
await Promise.all([getCachedDailyData(), getCachedMonthlyData(), getCachedSessionData()]);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
lastFetchTimestamp = Date.now();
|
||||
console.log(`[OK] Usage cache ready (${elapsed}ms)`);
|
||||
return { timestamp: lastFetchTimestamp, elapsed };
|
||||
} catch (err) {
|
||||
console.error('[!] Failed to prewarm usage cache:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate date string in YYYYMMDD format
|
||||
*/
|
||||
function validateDate(dateString?: string): string | undefined {
|
||||
if (!dateString) return undefined;
|
||||
|
||||
if (!DATE_REGEX.test(dateString)) {
|
||||
throw new Error('Invalid date format. Use YYYYMMDD');
|
||||
}
|
||||
|
||||
// Basic range check
|
||||
const year = parseInt(dateString.substring(0, 4), 10);
|
||||
const month = parseInt(dateString.substring(4, 6), 10);
|
||||
const day = parseInt(dateString.substring(6, 8), 10);
|
||||
|
||||
if (year < 2024 || year > 2100) throw new Error('Year out of valid range');
|
||||
if (month < 1 || month > 12) throw new Error('Month out of valid range');
|
||||
if (day < 1 || day > 31) throw new Error('Day out of valid range');
|
||||
|
||||
return dateString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse limit parameter
|
||||
*/
|
||||
function validateLimit(limit?: string): number {
|
||||
if (!limit) return DEFAULT_LIMIT;
|
||||
|
||||
const num = parseInt(limit, 10);
|
||||
if (isNaN(num) || num < 1 || num > MAX_LIMIT) {
|
||||
throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`);
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and parse offset parameter
|
||||
*/
|
||||
function validateOffset(offset?: string): number {
|
||||
if (!offset) return 0;
|
||||
|
||||
const num = parseInt(offset, 10);
|
||||
if (isNaN(num) || num < 0) {
|
||||
throw new Error('Offset must be a non-negative number');
|
||||
}
|
||||
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter data by date range
|
||||
*/
|
||||
function filterByDateRange<T extends { date?: string; month?: string; lastActivity?: string }>(
|
||||
data: T[],
|
||||
since?: string,
|
||||
until?: string
|
||||
): T[] {
|
||||
if (!since && !until) return data;
|
||||
|
||||
return data.filter((item) => {
|
||||
// Get the date field (prioritize date, then month, then lastActivity)
|
||||
const itemDate =
|
||||
item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, '');
|
||||
if (!itemDate) return true;
|
||||
|
||||
// Normalize to YYYYMMDD for comparison
|
||||
const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8);
|
||||
|
||||
if (since && normalizedDate < since) return false;
|
||||
if (until && normalizedDate > until) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create standard error response
|
||||
*/
|
||||
function errorResponse(res: Response, error: unknown, defaultMessage: string): void {
|
||||
console.error(defaultMessage + ':', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isValidationError =
|
||||
errorMessage.includes('Invalid') ||
|
||||
errorMessage.includes('format') ||
|
||||
errorMessage.includes('range') ||
|
||||
errorMessage.includes('must be');
|
||||
|
||||
const statusCode = isValidationError ? 400 : 500;
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: isValidationError ? errorMessage : defaultMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/usage/summary
|
||||
*
|
||||
* Returns usage summary data for quick dashboard display.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/summary',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
// Calculate totals
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
let totalCacheTokens = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
for (const day of filtered) {
|
||||
totalInputTokens += day.inputTokens;
|
||||
totalOutputTokens += day.outputTokens;
|
||||
totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens;
|
||||
totalCost += day.totalCost;
|
||||
}
|
||||
|
||||
const totalTokens = totalInputTokens + totalOutputTokens;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalTokens,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
totalCacheTokens,
|
||||
totalCost: Math.round(totalCost * 100) / 100,
|
||||
totalDays: filtered.length,
|
||||
averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0,
|
||||
averageCostPerDay:
|
||||
filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch usage summary');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/daily
|
||||
*
|
||||
* Returns daily usage trends for chart visualization.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/daily',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
// Transform for chart consumption
|
||||
const trends = filtered.map((day) => ({
|
||||
date: day.date,
|
||||
tokens: day.inputTokens + day.outputTokens,
|
||||
inputTokens: day.inputTokens,
|
||||
outputTokens: day.outputTokens,
|
||||
cacheTokens: day.cacheCreationTokens + day.cacheReadTokens,
|
||||
cost: Math.round(day.totalCost * 100) / 100,
|
||||
modelsUsed: day.modelsUsed.length,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trends,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch daily usage');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/models
|
||||
*
|
||||
* Returns usage breakdown by model for pie/bar charts.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/models',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const dailyData = await getCachedDailyData();
|
||||
const filtered = filterByDateRange(dailyData, since, until);
|
||||
|
||||
// Aggregate model usage across all days
|
||||
const modelMap = new Map<
|
||||
string,
|
||||
{
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const day of filtered) {
|
||||
for (const breakdown of day.modelBreakdowns) {
|
||||
const existing = modelMap.get(breakdown.modelName) || {
|
||||
model: breakdown.modelName,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
existing.inputTokens += breakdown.inputTokens;
|
||||
existing.outputTokens += breakdown.outputTokens;
|
||||
existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens;
|
||||
existing.cost += breakdown.cost;
|
||||
|
||||
modelMap.set(breakdown.modelName, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals for percentage
|
||||
const models = Array.from(modelMap.values());
|
||||
const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0);
|
||||
|
||||
// Add percentage and sort by tokens
|
||||
const result = models
|
||||
.map((m) => ({
|
||||
...m,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
cost: Math.round(m.cost * 100) / 100,
|
||||
percentage:
|
||||
totalTokens > 0
|
||||
? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10
|
||||
: 0,
|
||||
}))
|
||||
.sort((a, b) => b.tokens - a.tokens);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch model usage');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/sessions
|
||||
*
|
||||
* Returns paginated list of sessions.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD&limit=50&offset=0
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/sessions',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
const limit = validateLimit(req.query.limit);
|
||||
const offset = validateOffset(req.query.offset);
|
||||
|
||||
const sessionData = await getCachedSessionData();
|
||||
|
||||
// Filter by date range using lastActivity
|
||||
const filtered = filterByDateRange(sessionData, since, until);
|
||||
|
||||
// Sort by lastActivity descending
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
||||
);
|
||||
|
||||
// Paginate
|
||||
const paginated = sorted.slice(offset, offset + limit);
|
||||
|
||||
// Transform for frontend
|
||||
const sessions = paginated.map((s) => ({
|
||||
sessionId: s.sessionId,
|
||||
projectPath: s.projectPath,
|
||||
tokens: s.inputTokens + s.outputTokens,
|
||||
inputTokens: s.inputTokens,
|
||||
outputTokens: s.outputTokens,
|
||||
cost: Math.round(s.totalCost * 100) / 100,
|
||||
lastActivity: s.lastActivity,
|
||||
modelsUsed: s.modelsUsed,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessions,
|
||||
total: filtered.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < filtered.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch sessions');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/usage/monthly
|
||||
*
|
||||
* Returns monthly usage summary for charts.
|
||||
* Query: ?since=YYYYMMDD&until=YYYYMMDD
|
||||
*/
|
||||
usageRoutes.get(
|
||||
'/monthly',
|
||||
async (req: Request<object, object, object, UsageQuery>, res: Response) => {
|
||||
try {
|
||||
const since = validateDate(req.query.since);
|
||||
const until = validateDate(req.query.until);
|
||||
|
||||
const monthlyData = await getCachedMonthlyData();
|
||||
|
||||
// Filter by date range (convert month YYYY-MM to YYYYMM01 for comparison)
|
||||
const filtered =
|
||||
since || until
|
||||
? monthlyData.filter((m) => {
|
||||
const monthDate = m.month.replace('-', '') + '01';
|
||||
if (since && monthDate < since) return false;
|
||||
if (until && monthDate > until) return false;
|
||||
return true;
|
||||
})
|
||||
: monthlyData;
|
||||
|
||||
// Transform for charts
|
||||
const result = filtered.map((m) => ({
|
||||
month: m.month,
|
||||
tokens: m.inputTokens + m.outputTokens,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
cacheTokens: m.cacheCreationTokens + m.cacheReadTokens,
|
||||
cost: Math.round(m.totalCost * 100) / 100,
|
||||
modelsUsed: m.modelsUsed.length,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.sort((a, b) => a.month.localeCompare(b.month)),
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 'Failed to fetch monthly usage');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/usage/refresh
|
||||
*
|
||||
* Clears the usage cache to force fresh data fetch.
|
||||
* Useful when user wants to see latest data immediately.
|
||||
*/
|
||||
usageRoutes.post('/refresh', (_req: Request, res: Response) => {
|
||||
clearUsageCache();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Usage cache cleared',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/usage/status
|
||||
*
|
||||
* Returns cache status including last fetch timestamp.
|
||||
* Used by UI to show "Last updated: X ago" indicator.
|
||||
*/
|
||||
usageRoutes.get('/status', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
lastFetch: lastFetchTimestamp,
|
||||
cacheSize: cache.size,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ describe('Model Catalog', () => {
|
||||
describe('AGY models', () => {
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-3-pro-preview');
|
||||
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-5-thinking');
|
||||
});
|
||||
|
||||
it('includes Claude Opus 4.5 Thinking', () => {
|
||||
@@ -215,60 +215,58 @@ describe('Model Catalog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deprecated models', () => {
|
||||
it('Claude Opus 4.5 Thinking is marked as deprecated', () => {
|
||||
describe('Thinking models ordering', () => {
|
||||
it('Claude Opus 4.5 Thinking is not deprecated', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const opus = MODEL_CATALOG.agy.models.find(
|
||||
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
|
||||
);
|
||||
assert(opus, 'Should include Claude Opus 4.5 Thinking');
|
||||
assert.strictEqual(opus.deprecated, true, 'Should be marked as deprecated');
|
||||
assert(opus.deprecationReason, 'Should have deprecation reason');
|
||||
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
|
||||
});
|
||||
|
||||
it('Claude Sonnet 4.5 Thinking is marked as deprecated', () => {
|
||||
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
||||
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
|
||||
);
|
||||
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
|
||||
assert.strictEqual(sonnetThinking.deprecated, true, 'Should be marked as deprecated');
|
||||
assert(sonnetThinking.deprecationReason, 'Should have deprecation reason');
|
||||
assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated');
|
||||
});
|
||||
|
||||
it('deprecated models are at the bottom of the list', () => {
|
||||
it('thinking models are at the top of the list', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const models = MODEL_CATALOG.agy.models;
|
||||
|
||||
// Find indices of deprecated models
|
||||
// Find indices of thinking models
|
||||
const opusIdx = models.findIndex((m) => m.id === 'gemini-claude-opus-4-5-thinking');
|
||||
const sonnetThinkingIdx = models.findIndex(
|
||||
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
|
||||
);
|
||||
|
||||
// Find indices of non-deprecated models
|
||||
// Find indices of non-thinking models
|
||||
const sonnetIdx = models.findIndex((m) => m.id === 'gemini-claude-sonnet-4-5');
|
||||
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
|
||||
|
||||
// Deprecated models should come after non-deprecated models
|
||||
assert(opusIdx > sonnetIdx, 'Opus Thinking should be below non-deprecated Sonnet');
|
||||
assert(opusIdx > geminiIdx, 'Opus Thinking should be below non-deprecated Gemini');
|
||||
// Thinking models should come before non-thinking models
|
||||
assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet');
|
||||
assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini');
|
||||
assert(
|
||||
sonnetThinkingIdx > sonnetIdx,
|
||||
'Sonnet Thinking should be below non-deprecated Sonnet'
|
||||
sonnetThinkingIdx < sonnetIdx,
|
||||
'Sonnet Thinking should be above non-thinking Sonnet'
|
||||
);
|
||||
assert(
|
||||
sonnetThinkingIdx > geminiIdx,
|
||||
'Sonnet Thinking should be below non-deprecated Gemini'
|
||||
sonnetThinkingIdx < geminiIdx,
|
||||
'Sonnet Thinking should be above non-thinking Gemini'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isModelDeprecated', () => {
|
||||
it('returns true for deprecated models', () => {
|
||||
it('returns false for thinking models (no longer deprecated)', () => {
|
||||
const { isModelDeprecated } = modelCatalog;
|
||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), true);
|
||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), true);
|
||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), false);
|
||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), false);
|
||||
});
|
||||
|
||||
it('returns false for non-deprecated models', () => {
|
||||
@@ -284,11 +282,10 @@ describe('Model Catalog', () => {
|
||||
});
|
||||
|
||||
describe('getModelDeprecationReason', () => {
|
||||
it('returns deprecation reason for deprecated models', () => {
|
||||
it('returns undefined for thinking models (no longer deprecated)', () => {
|
||||
const { getModelDeprecationReason } = modelCatalog;
|
||||
const reason = getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking');
|
||||
assert(reason, 'Should have deprecation reason');
|
||||
assert(typeof reason === 'string', 'Reason should be a string');
|
||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking'), undefined);
|
||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5-thinking'), undefined);
|
||||
});
|
||||
|
||||
it('returns undefined for non-deprecated models', () => {
|
||||
|
||||
+94
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
<!-- Theme color for mobile browsers -->
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
|
||||
<!-- Google Fonts - JetBrains Mono (terminal) + IBM Plex Sans (UI) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SettingsPage,
|
||||
HealthPage,
|
||||
SharedPage,
|
||||
AnalyticsPage,
|
||||
} from '@/pages';
|
||||
|
||||
function Layout() {
|
||||
@@ -42,6 +43,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/api" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Date Range Filter Component
|
||||
*
|
||||
* Provides date range selection with preset options for analytics.
|
||||
* Uses react-day-picker for date selection UI.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
value?: DateRange;
|
||||
onChange: (dateRange: DateRange | undefined) => void;
|
||||
presets?: Array<{
|
||||
label: string;
|
||||
range: DateRange;
|
||||
}>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DateRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
presets = [],
|
||||
className,
|
||||
}: DateRangeFilterProps) {
|
||||
const handlePresetClick = (range: DateRange) => {
|
||||
onChange(range);
|
||||
};
|
||||
|
||||
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const from = e.target.value ? new Date(e.target.value) : undefined;
|
||||
onChange({ from, to: value?.to });
|
||||
};
|
||||
|
||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const to = e.target.value ? new Date(e.target.value) : undefined;
|
||||
onChange({ from: value?.from, to });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
||||
{/* Preset Buttons */}
|
||||
{presets.map((preset, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={isSameRange(value, preset.range) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset.range)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* Custom Date Range Inputs */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="date"
|
||||
value={value?.from ? format(value.from, 'yyyy-MM-dd') : ''}
|
||||
onChange={handleFromChange}
|
||||
placeholder="From"
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground">to</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={value?.to ? format(value.to, 'yyyy-MM-dd') : ''}
|
||||
onChange={handleToChange}
|
||||
placeholder="To"
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to compare date ranges
|
||||
function isSameRange(a?: DateRange, b?: DateRange): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
|
||||
const fromA = a.from?.getTime() ?? 0;
|
||||
const fromB = b.from?.getTime() ?? 0;
|
||||
const toA = a.to?.getTime() ?? 0;
|
||||
const toB = b.to?.getTime() ?? 0;
|
||||
|
||||
return fromA === fromB && toA === toB;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Model Breakdown Chart Component
|
||||
*
|
||||
* Displays usage distribution by model using pie chart.
|
||||
* Shows tokens, cost, and percentage breakdown.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { ModelUsage } from '@/hooks/use-usage';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ModelBreakdownChartProps {
|
||||
data: ModelUsage[];
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#0080FF',
|
||||
'#00C49F',
|
||||
'#FFBB28',
|
||||
'#FF8042',
|
||||
'#8884D8',
|
||||
'#82CA9D',
|
||||
'#FFC658',
|
||||
'#8DD1E1',
|
||||
'#D084D0',
|
||||
'#87D068',
|
||||
];
|
||||
|
||||
export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
return data.map((item, index) => ({
|
||||
name: item.model,
|
||||
value: item.tokens,
|
||||
cost: item.cost,
|
||||
requests: item.requests,
|
||||
percentage: item.percentage,
|
||||
fill: COLORS[index % COLORS.length],
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-[300px] w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={cn('h-[300px] flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No model data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderTooltip = ({ active, payload }: { active?: boolean; payload?: unknown }) => {
|
||||
if (!active || !payload) return null;
|
||||
|
||||
const payloadArray = payload as Array<{
|
||||
payload: { name: string; value: number; cost: number; requests: number; percentage: number };
|
||||
}>;
|
||||
if (!payloadArray.length) return null;
|
||||
|
||||
const data = payloadArray[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-2 shadow-lg text-xs">
|
||||
<p className="font-medium mb-1">{data.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
|
||||
</p>
|
||||
<p className="text-muted-foreground">${data.cost.toFixed(4)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLabel = (entry: { percentage: number }) => {
|
||||
return entry.percentage > 5 ? `${entry.percentage.toFixed(1)}%` : '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} strokeWidth={1} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={renderTooltip} />
|
||||
{/* Legend removed from here, moved to AnalyticsPage for better layout control */}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format large numbers
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Sessions Table Component
|
||||
*
|
||||
* Displays session history with pagination and filtering.
|
||||
* Shows session duration, tokens, cost, and metadata.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ChevronLeft, ChevronRight, Search, Clock, Zap, DollarSign } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { PaginatedSessions } from '@/hooks/use-usage';
|
||||
|
||||
interface SessionsTableProps {
|
||||
data?: PaginatedSessions;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SessionsTable({ data, isLoading }: SessionsTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// Get sessions array (stable reference for memoization)
|
||||
const sessions = useMemo(() => data?.sessions ?? [], [data?.sessions]);
|
||||
|
||||
// Filter sessions based on search term
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!searchTerm) return sessions;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return sessions.filter(
|
||||
(session) =>
|
||||
session.profile.toLowerCase().includes(term) ||
|
||||
session.model.toLowerCase().includes(term) ||
|
||||
session.id.toLowerCase().includes(term)
|
||||
);
|
||||
}, [sessions, searchTerm]);
|
||||
|
||||
// Pagination for filtered data
|
||||
const pageSize = 10;
|
||||
const paginatedSessions = useMemo(() => {
|
||||
if (!filteredSessions) return [];
|
||||
const start = currentPage * pageSize;
|
||||
return filteredSessions.slice(start, start + pageSize);
|
||||
}, [filteredSessions, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil((filteredSessions?.length || 0) / pageSize);
|
||||
|
||||
if (isLoading) {
|
||||
return <SessionsTableSkeleton />;
|
||||
}
|
||||
|
||||
if (!data || data.sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-1">No sessions found</h3>
|
||||
<p className="text-muted-foreground">Start using Claude Code to see session history</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by profile, model, or session ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(0);
|
||||
}}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Profile</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Tokens</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedSessions.map((session) => (
|
||||
<TableRow key={session.id} className="hover:bg-muted/50">
|
||||
<TableCell className="font-mono text-xs">{session.id.slice(0, 8)}...</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{session.profile}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{session.model}</TableCell>
|
||||
<TableCell>{session.duration ? formatDuration(session.duration) : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||||
{formatNumber(session.tokens)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<DollarSign className="h-3 w-3 text-muted-foreground" />$
|
||||
{session.cost.toFixed(4)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{session.requests}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(session.startTime), { addSuffix: true })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {currentPage * pageSize + 1} to{' '}
|
||||
{Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '}
|
||||
{filteredSessions?.length} sessions
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i;
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn('w-8 h-8 p-0')}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page + 1}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
// Skeleton loading state
|
||||
function SessionsTableSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 flex-1" />
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Profile</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Tokens</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Requests</TableHead>
|
||||
<TableHead>Last Used</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[70px]" />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Skeleton className="h-4 w-[60px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Usage Summary Cards Component
|
||||
*
|
||||
* Displays key metrics in a card grid layout.
|
||||
* Shows total tokens, cost, requests, and average tokens per request.
|
||||
*/
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UsageSummary } from '@/hooks/use-usage';
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
data?: UsageSummary;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-8 w-[80px]" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Tokens',
|
||||
value: data?.totalTokens ?? 0,
|
||||
icon: FileText,
|
||||
format: (v: number) => formatNumber(v),
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/20',
|
||||
},
|
||||
{
|
||||
title: 'Total Cost',
|
||||
value: data?.totalCost ?? 0,
|
||||
icon: DollarSign,
|
||||
format: (v: number) => `$${v.toFixed(2)}`,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/20',
|
||||
},
|
||||
{
|
||||
title: 'Total Requests',
|
||||
value: data?.totalRequests ?? 0,
|
||||
icon: Zap,
|
||||
format: (v: number) => formatNumber(v),
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-900/20',
|
||||
},
|
||||
{
|
||||
title: 'Avg Tokens/Request',
|
||||
value: data?.averageTokensPerRequest ?? 0,
|
||||
icon: TrendingUp,
|
||||
format: (v: number) => formatNumber(Math.round(v)),
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/20',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={index} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground truncate">{card.title}</p>
|
||||
<p className="text-xl font-bold truncate">{card.format(card.value)}</p>
|
||||
</div>
|
||||
<div className={cn('p-2 rounded-lg shrink-0', card.bgColor)}>
|
||||
<Icon className={cn('h-4 w-4', card.color)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to format large numbers
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Usage Trend Chart Component
|
||||
*
|
||||
* Displays usage trends over time with tokens and cost.
|
||||
* Supports daily and monthly granularity with interactive tooltips.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
AreaChart,
|
||||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DailyUsage } from '@/hooks/use-usage';
|
||||
|
||||
interface UsageTrendChartProps {
|
||||
data: DailyUsage[];
|
||||
isLoading?: boolean;
|
||||
dateRange?: DateRange;
|
||||
granularity?: 'daily' | 'monthly';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UsageTrendChart({
|
||||
data,
|
||||
isLoading,
|
||||
granularity = 'daily',
|
||||
className,
|
||||
}: Omit<UsageTrendChartProps, 'dateRange'>) {
|
||||
const chartData = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
return [...data].reverse().map((item) => ({
|
||||
...item,
|
||||
dateFormatted: formatDate(item.date, granularity),
|
||||
costRounded: Number(item.cost.toFixed(4)),
|
||||
}));
|
||||
}, [data, granularity]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-[300px] w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className={cn('h-[300px] flex items-center justify-center', className)}>
|
||||
<p className="text-muted-foreground">No usage data available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#0080FF" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#0080FF" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#00C49F" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#00C49F" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
|
||||
<XAxis
|
||||
dataKey="dateFormatted"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
orientation="left"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
tickFormatter={(value) => formatNumber(value)}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={{ className: 'stroke-muted' }}
|
||||
tickFormatter={(value) => `$${value}`}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-lg">
|
||||
<p className="font-medium mb-2">{label}</p>
|
||||
{payload.map((entry, index) => (
|
||||
<p key={index} className="text-sm" style={{ color: entry.color }}>
|
||||
{entry.name}:{' '}
|
||||
{entry.name === 'Tokens'
|
||||
? formatNumber(Number(entry.value) || 0)
|
||||
: `$${entry.value}`}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="tokens"
|
||||
stroke="#0080FF"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#tokenGradient)"
|
||||
name="Tokens"
|
||||
/>
|
||||
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="costRounded"
|
||||
stroke="#00C49F"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#costGradient)"
|
||||
name="Cost"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (granularity === 'monthly') {
|
||||
return format(date, 'MMM yyyy');
|
||||
}
|
||||
|
||||
// For daily, show shorter format if range is > 30 days
|
||||
return format(date, 'MMM dd');
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ChevronRight, Copy, Terminal, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useFixHealth, type HealthCheck } from '@/hooks/use-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const statusConfig = {
|
||||
ok: { dot: 'bg-green-500', label: 'OK', labelColor: 'text-green-500' },
|
||||
warning: { dot: 'bg-yellow-500', label: 'WARN', labelColor: 'text-yellow-500' },
|
||||
error: { dot: 'bg-red-500', label: 'ERR', labelColor: 'text-red-500' },
|
||||
info: { dot: 'bg-blue-500', label: 'INFO', labelColor: 'text-blue-500' },
|
||||
};
|
||||
|
||||
export function HealthCheckItem({ check }: { check: HealthCheck }) {
|
||||
const fixMutation = useFixHealth();
|
||||
const config = statusConfig[check.status];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasExpandableContent = check.details || check.fix;
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
// Compact single-line display for items without expandable content
|
||||
if (!hasExpandableContent) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-3 px-3 py-2 rounded-lg',
|
||||
'hover:bg-muted/50 transition-colors duration-150',
|
||||
'border border-transparent hover:border-border/50'
|
||||
)}
|
||||
>
|
||||
{/* Status dot with pulse animation */}
|
||||
<div className="relative flex items-center justify-center w-4 h-4">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||
{check.status !== 'ok' && (
|
||||
<div
|
||||
className={cn('absolute w-2 h-2 rounded-full animate-ping opacity-75', config.dot)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check name */}
|
||||
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
|
||||
|
||||
{/* Status label */}
|
||||
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
|
||||
[{config.label}]
|
||||
</span>
|
||||
|
||||
{/* Fix button for fixable non-ok items */}
|
||||
{check.fixable && check.status !== 'ok' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expandable display for items with details or fix commands
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border transition-all duration-150',
|
||||
isOpen
|
||||
? 'border-border bg-muted/30'
|
||||
: 'border-transparent hover:border-border/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="w-full flex items-center gap-3 px-3 py-2 text-left">
|
||||
{/* Status dot */}
|
||||
<div className="relative flex items-center justify-center w-4 h-4">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.dot)} />
|
||||
{check.status !== 'ok' && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-2 h-2 rounded-full animate-ping opacity-75',
|
||||
config.dot
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check name */}
|
||||
<span className="flex-1 text-sm font-medium truncate">{check.name}</span>
|
||||
|
||||
{/* Status label */}
|
||||
<span className={cn('font-mono text-xs font-semibold', config.labelColor)}>
|
||||
[{config.label}]
|
||||
</span>
|
||||
|
||||
{/* Chevron indicator */}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pb-3 pt-1 space-y-2 ml-7">
|
||||
{/* Message */}
|
||||
<p className="text-xs text-muted-foreground">{check.message}</p>
|
||||
|
||||
{/* Details block */}
|
||||
{check.details && (
|
||||
<pre className="text-xs font-mono text-muted-foreground bg-background/50 rounded p-2 overflow-x-auto border border-border/50">
|
||||
{check.details}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Fix command block */}
|
||||
{check.fix && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-background/50 rounded px-2 py-1.5 border border-border/50">
|
||||
<Terminal className="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<code className="text-xs font-mono flex-1 truncate">{check.fix}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => check.fix && copyToClipboard(check.fix)}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{check.fixable && check.status !== 'ok' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fixMutation.mutate(check.id)}
|
||||
disabled={fixMutation.isPending}
|
||||
className="h-7 px-3 text-xs"
|
||||
>
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
Apply Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HealthGaugeProps {
|
||||
passed: number;
|
||||
total: number;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { dimension: 80, strokeWidth: 6, fontSize: 'text-lg', labelSize: 'text-[10px]' },
|
||||
md: { dimension: 120, strokeWidth: 8, fontSize: 'text-3xl', labelSize: 'text-xs' },
|
||||
lg: { dimension: 160, strokeWidth: 10, fontSize: 'text-4xl', labelSize: 'text-sm' },
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
ok: { stroke: '#22C55E', glow: 'rgba(34, 197, 94, 0.4)' },
|
||||
warning: { stroke: '#EAB308', glow: 'rgba(234, 179, 8, 0.4)' },
|
||||
error: { stroke: '#EF4444', glow: 'rgba(239, 68, 68, 0.4)' },
|
||||
};
|
||||
|
||||
export function HealthGauge({ passed, total, status, size = 'md' }: HealthGaugeProps) {
|
||||
const config = sizeConfig[size];
|
||||
const colors = statusColors[status];
|
||||
const percentage = total > 0 ? Math.round((passed / total) * 100) : 0;
|
||||
|
||||
const radius = (config.dimension - config.strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||
const center = config.dimension / 2;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg
|
||||
width={config.dimension}
|
||||
height={config.dimension}
|
||||
className="transform -rotate-90"
|
||||
style={{ filter: `drop-shadow(0 0 8px ${colors.glow})` }}
|
||||
>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={config.strokeWidth}
|
||||
className="text-muted/30"
|
||||
/>
|
||||
{/* Progress arc */}
|
||||
<circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.stroke}
|
||||
strokeWidth={config.strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
className="transition-all duration-1000 ease-out"
|
||||
/>
|
||||
{/* Animated glow dot at end of arc */}
|
||||
{percentage > 0 && (
|
||||
<circle
|
||||
cx={center + radius * Math.cos((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
|
||||
cy={center + radius * Math.sin((percentage / 100) * 2 * Math.PI - Math.PI / 2)}
|
||||
r={config.strokeWidth / 2}
|
||||
fill={colors.stroke}
|
||||
className="animate-pulse"
|
||||
style={{ filter: `drop-shadow(0 0 4px ${colors.glow})` }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn('font-mono font-bold tracking-tight', config.fontSize)}>
|
||||
{percentage}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono uppercase tracking-widest text-muted-foreground',
|
||||
config.labelSize
|
||||
)}
|
||||
>
|
||||
health
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ChevronDown, Monitor, Settings, Users, Shield, Zap } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { HealthCheckItem } from '@/components/health-check-item';
|
||||
import { type HealthGroup } from '@/hooks/use-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
const groupIcons: Record<string, typeof Monitor> = {
|
||||
Monitor,
|
||||
Settings,
|
||||
Users,
|
||||
Shield,
|
||||
Zap,
|
||||
};
|
||||
|
||||
interface HealthGroupSectionProps {
|
||||
group: HealthGroup;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function HealthGroupSection({ group, defaultOpen = true }: HealthGroupSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const Icon = groupIcons[group.icon] || Monitor;
|
||||
|
||||
const passed = group.checks.filter((c) => c.status === 'ok').length;
|
||||
const total = group.checks.length;
|
||||
const hasErrors = group.checks.some((c) => c.status === 'error');
|
||||
const hasWarnings = group.checks.some((c) => c.status === 'warning');
|
||||
const percentage = Math.round((passed / total) * 100);
|
||||
|
||||
// Determine status color
|
||||
const statusColor = hasErrors
|
||||
? 'text-red-500'
|
||||
: hasWarnings
|
||||
? 'text-yellow-500'
|
||||
: 'text-green-500';
|
||||
const progressColor = hasErrors ? 'bg-red-500' : hasWarnings ? 'bg-yellow-500' : 'bg-green-500';
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border transition-all duration-200',
|
||||
hasErrors ? 'border-red-500/30' : hasWarnings ? 'border-yellow-500/30' : 'border-border'
|
||||
)}
|
||||
>
|
||||
{/* Group header */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3 text-left rounded-lg',
|
||||
'hover:bg-muted/50 transition-colors duration-150',
|
||||
isOpen && 'rounded-b-none border-b border-border/50'
|
||||
)}
|
||||
>
|
||||
{/* Group icon */}
|
||||
<div
|
||||
className={cn(
|
||||
'p-1.5 rounded-md',
|
||||
hasErrors
|
||||
? 'bg-red-500/10 text-red-500'
|
||||
: hasWarnings
|
||||
? 'bg-yellow-500/10 text-yellow-500'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* Group name */}
|
||||
<span className="flex-1 text-sm font-semibold">{group.name}</span>
|
||||
|
||||
{/* Progress indicator (collapsed view) */}
|
||||
{!isOpen && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', progressColor)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Count badge */}
|
||||
<span className={cn('font-mono text-xs font-semibold', statusColor)}>
|
||||
{passed}/{total}
|
||||
</span>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Checks list */}
|
||||
<CollapsibleContent>
|
||||
<div className="p-2 space-y-0.5">
|
||||
{group.checks.map((check) => (
|
||||
<HealthCheckItem key={check.id} check={check} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HealthStatsBarProps {
|
||||
total: number;
|
||||
passed: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
info: number;
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
function StatItem({ label, value, color, bgColor }: StatItemProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('w-2 h-2 rounded-full animate-pulse', bgColor)} />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn('font-mono font-bold text-sm', color)}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthStatsBar({ total, passed, warnings, errors, info }: HealthStatsBarProps) {
|
||||
// Calculate percentages for the progress bar
|
||||
const passedPct = (passed / total) * 100;
|
||||
const warningPct = (warnings / total) * 100;
|
||||
const errorPct = (errors / total) * 100;
|
||||
const infoPct = (info / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Progress bar visualization */}
|
||||
<div className="h-2 rounded-full overflow-hidden bg-muted/50 flex">
|
||||
{errorPct > 0 && (
|
||||
<div
|
||||
className="h-full bg-red-500 transition-all duration-500"
|
||||
style={{ width: `${errorPct}%` }}
|
||||
/>
|
||||
)}
|
||||
{warningPct > 0 && (
|
||||
<div
|
||||
className="h-full bg-yellow-500 transition-all duration-500"
|
||||
style={{ width: `${warningPct}%` }}
|
||||
/>
|
||||
)}
|
||||
{infoPct > 0 && (
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-500"
|
||||
style={{ width: `${infoPct}%` }}
|
||||
/>
|
||||
)}
|
||||
{passedPct > 0 && (
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-500"
|
||||
style={{ width: `${passedPct}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Checks
|
||||
</span>
|
||||
<span className="font-mono font-bold text-lg">{total}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<StatItem label="OK" value={passed} color="text-green-500" bgColor="bg-green-500" />
|
||||
<StatItem label="WARN" value={warnings} color="text-yellow-500" bgColor="bg-yellow-500" />
|
||||
<StatItem label="ERR" value={errors} color="text-red-500" bgColor="bg-red-500" />
|
||||
<StatItem label="INFO" value={info} color="text-blue-500" bgColor="bg-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CcsLogo } from '@/components/ccs-logo';
|
||||
import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HeroSectionProps {
|
||||
version?: string;
|
||||
healthStatus?: 'ok' | 'warning' | 'error';
|
||||
healthPassed?: number;
|
||||
healthTotal?: number;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
ok: {
|
||||
icon: CheckCircle2,
|
||||
label: 'All Systems Operational',
|
||||
color: 'text-green-600',
|
||||
badgeBg: 'bg-green-500/10 text-green-600 border-green-500/20',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertCircle,
|
||||
label: 'Some Issues Detected',
|
||||
color: 'text-yellow-500',
|
||||
badgeBg: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
label: 'Action Required',
|
||||
color: 'text-red-500',
|
||||
badgeBg: 'bg-red-500/10 text-red-500 border-red-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
export function HeroSection({
|
||||
version = '5.0.0',
|
||||
healthStatus = 'ok',
|
||||
healthPassed = 0,
|
||||
healthTotal = 0,
|
||||
}: HeroSectionProps) {
|
||||
const status = statusConfig[healthStatus];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30 p-6">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{/* Left: Logo and Welcome */}
|
||||
<div className="flex items-center gap-4">
|
||||
<CcsLogo size="lg" showText={false} />
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
v{version}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Health Status */}
|
||||
<div className={cn('flex items-center gap-3 px-4 py-2 rounded-lg border', status.badgeBg)}>
|
||||
<StatusIcon className={cn('w-5 h-5', status.color)} />
|
||||
<div>
|
||||
<p className={cn('text-sm font-medium', status.color)}>{status.label}</p>
|
||||
{healthTotal > 0 && (
|
||||
<p className="text-xs opacity-80">
|
||||
{healthPassed}/{healthTotal} checks passed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommandSnippet {
|
||||
label: string;
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const defaultSnippets: CommandSnippet[] = [
|
||||
{
|
||||
label: 'Start Default',
|
||||
command: 'ccs',
|
||||
description: 'Launch Claude with default profile',
|
||||
},
|
||||
{
|
||||
label: 'GLM Profile',
|
||||
command: 'ccs glm',
|
||||
description: 'Switch to GLM model',
|
||||
},
|
||||
{
|
||||
label: 'Health Check',
|
||||
command: 'ccs doctor',
|
||||
description: 'Run system diagnostics',
|
||||
},
|
||||
{
|
||||
label: 'Delegate Task',
|
||||
command: 'ccs glm -p "your task"',
|
||||
description: 'Delegate to GLM profile',
|
||||
},
|
||||
];
|
||||
|
||||
interface QuickCommandsProps {
|
||||
snippets?: CommandSnippet[];
|
||||
}
|
||||
|
||||
export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const copyToClipboard = async (text: string, index: number) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-muted-foreground" />
|
||||
Quick Commands
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{snippets.map((snippet, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'group flex items-center justify-between gap-2 px-3 py-2',
|
||||
'rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{snippet.label}</p>
|
||||
<code className="text-sm font-mono font-medium truncate block">
|
||||
{snippet.command}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => copyToClipboard(snippet.command, index)}
|
||||
title={snippet.description}
|
||||
>
|
||||
{copiedIndex === index ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,33 +1,86 @@
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
color?: string;
|
||||
variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
|
||||
subtitle?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: {
|
||||
iconBg: 'bg-muted',
|
||||
iconColor: 'text-muted-foreground',
|
||||
borderHover: 'hover:border-primary',
|
||||
},
|
||||
success: {
|
||||
iconBg: 'bg-green-500/10',
|
||||
iconColor: 'text-green-600',
|
||||
borderHover: 'hover:border-green-500/50',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-yellow-500/10',
|
||||
iconColor: 'text-yellow-500',
|
||||
borderHover: 'hover:border-yellow-500/50',
|
||||
},
|
||||
error: {
|
||||
iconBg: 'bg-red-500/10',
|
||||
iconColor: 'text-red-500',
|
||||
borderHover: 'hover:border-red-500/50',
|
||||
},
|
||||
accent: {
|
||||
iconBg: 'bg-accent/10',
|
||||
iconColor: 'text-accent',
|
||||
borderHover: 'hover:border-accent/50',
|
||||
},
|
||||
};
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'text-primary',
|
||||
color,
|
||||
variant = 'default',
|
||||
subtitle,
|
||||
onClick,
|
||||
}: StatCardProps) {
|
||||
const styles = variantStyles[variant];
|
||||
const iconColorClass = color || styles.iconColor;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`cursor-pointer hover:shadow-md transition-shadow ${onClick ? 'hover:border-primary' : ''}`}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all duration-200',
|
||||
'hover:shadow-lg hover:-translate-y-0.5',
|
||||
styles.borderHover,
|
||||
onClick && 'active:scale-[0.98]'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className={`text-2xl font-bold ${color}`}>{value}</p>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Icon Container with background */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-12 h-12 rounded-lg transition-transform duration-200',
|
||||
styles.iconBg,
|
||||
'group-hover:scale-105'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-6 h-6', iconColorClass)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground truncate">{title}</p>
|
||||
<p className={cn('text-2xl font-bold font-mono', iconColorClass)}>{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
<Icon className={`w-8 h-8 ${color} opacity-20`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -4,23 +4,36 @@ import { toast } from 'sonner';
|
||||
interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'ok' | 'warning' | 'error';
|
||||
status: 'ok' | 'warning' | 'error' | 'info';
|
||||
message: string;
|
||||
details?: string;
|
||||
fix?: string;
|
||||
fixable?: boolean;
|
||||
}
|
||||
|
||||
interface HealthGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
checks: HealthCheck[];
|
||||
}
|
||||
|
||||
interface HealthReport {
|
||||
timestamp: number;
|
||||
version: string;
|
||||
groups: HealthGroup[];
|
||||
checks: HealthCheck[];
|
||||
summary: {
|
||||
total: number;
|
||||
passed: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
info: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type { HealthCheck, HealthGroup, HealthReport };
|
||||
|
||||
export function useHealth() {
|
||||
return useQuery<HealthReport>({
|
||||
queryKey: ['health'],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* React Query hooks for usage analytics
|
||||
* Phase 01: Analytics Page Implementation
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface UsageSummary {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
totalRequests: number;
|
||||
averageTokensPerRequest: number;
|
||||
dailyUsage: DailyUsage[];
|
||||
}
|
||||
|
||||
export interface DailyUsage {
|
||||
date: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
export interface ModelUsage {
|
||||
model: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
profile: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
sessions: Session[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface MonthlyUsage {
|
||||
month: string;
|
||||
tokens: number;
|
||||
cost: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
export interface UsageQueryOptions {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
profile?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface UsageStatus {
|
||||
lastFetch: number | null;
|
||||
cacheSize: number;
|
||||
}
|
||||
|
||||
// API
|
||||
const BASE_URL = '/api';
|
||||
|
||||
/**
|
||||
* Convert Date to YYYYMMDD format for API
|
||||
*/
|
||||
function formatDateForApi(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
summary: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<UsageSummary>(`/usage/summary?${params}`);
|
||||
},
|
||||
trends: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<DailyUsage[]>(`/usage/daily?${params}`);
|
||||
},
|
||||
models: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
return request<ModelUsage[]>(`/usage/models?${params}`);
|
||||
},
|
||||
sessions: (options?: UsageQueryOptions) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.startDate) params.append('since', formatDateForApi(options.startDate));
|
||||
if (options?.endDate) params.append('until', formatDateForApi(options.endDate));
|
||||
if (options?.profile) params.append('profile', options.profile);
|
||||
if (options?.limit) params.append('limit', options.limit.toString());
|
||||
if (options?.offset) params.append('offset', options.offset.toString());
|
||||
return request<PaginatedSessions>(`/usage/sessions?${params}`);
|
||||
},
|
||||
monthly: (months?: number, profile?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (months) params.append('months', months.toString());
|
||||
if (profile) params.append('profile', profile);
|
||||
return request<MonthlyUsage[]>(`/usage/monthly?${params}`);
|
||||
},
|
||||
/** Clear server-side usage cache and force fresh data fetch */
|
||||
refresh: async (): Promise<void> => {
|
||||
const res = await fetch(`${BASE_URL}/usage/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to refresh usage cache');
|
||||
}
|
||||
},
|
||||
/** Get cache status including last fetch timestamp */
|
||||
status: () => request<UsageStatus>('/usage/status'),
|
||||
};
|
||||
|
||||
// Helper function to match existing API client pattern
|
||||
async function request<T>(url: string): Promise<T> {
|
||||
const BASE_URL = '/api';
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || res.statusText);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
return result.data || result; // Extract data property if it exists
|
||||
}
|
||||
|
||||
// Hooks
|
||||
export function useUsageSummary(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'summary', options],
|
||||
queryFn: () => usageApi.summary(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'trends', options],
|
||||
queryFn: () => usageApi.trends(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelUsage(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'models', options],
|
||||
queryFn: () => usageApi.models(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useSessions(options?: UsageQueryOptions) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'sessions', options],
|
||||
queryFn: () => usageApi.sessions(options),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonthlyUsage(months?: number, profile?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'monthly', months, profile],
|
||||
queryFn: () => usageApi.monthly(months, profile),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to refresh all usage data
|
||||
* Clears server-side cache and invalidates React Query cache
|
||||
*/
|
||||
export function useRefreshUsage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
// Clear server-side cache
|
||||
await usageApi.refresh();
|
||||
// Invalidate all usage queries in React Query
|
||||
await queryClient.invalidateQueries({ queryKey: ['usage'] });
|
||||
}, [queryClient]);
|
||||
|
||||
return refresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get usage cache status
|
||||
* Returns last fetch timestamp for "Last updated" UI indicator
|
||||
*/
|
||||
export function useUsageStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['usage', 'status'],
|
||||
queryFn: () => usageApi.status(),
|
||||
staleTime: 10 * 1000, // 10 seconds - poll frequently for updates
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
});
|
||||
}
|
||||
+4
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Analytics Page
|
||||
*
|
||||
* Displays Claude Code usage analytics with charts and tables.
|
||||
* Features daily/monthly views, trend charts, model breakdown, and session history.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { DateRange } from 'react-day-picker';
|
||||
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
|
||||
import { SessionsTable } from '@/components/analytics/sessions-table';
|
||||
import { TrendingUp, PieChart, Clock, Calendar, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
useModelUsage,
|
||||
useSessions,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
} from '@/hooks/use-usage';
|
||||
|
||||
type ViewMode = 'daily' | 'monthly' | 'sessions';
|
||||
|
||||
export function AnalyticsPage() {
|
||||
// Default to last 30 days
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>({
|
||||
from: subDays(new Date(), 30),
|
||||
to: new Date(),
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('daily');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Refresh hook
|
||||
const refreshUsage = useRefreshUsage();
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshUsage();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert dates to API format
|
||||
const apiOptions = {
|
||||
startDate: dateRange?.from,
|
||||
endDate: dateRange?.to,
|
||||
};
|
||||
|
||||
// Fetch data
|
||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({
|
||||
...apiOptions,
|
||||
limit: 50,
|
||||
});
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
// Format "Last updated" text
|
||||
const lastUpdatedText = useMemo(() => {
|
||||
if (!status?.lastFetch) return null;
|
||||
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
|
||||
}, [status?.lastFetch]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-3rem)] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-14 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Analytics</h1>
|
||||
<p className="text-sm text-muted-foreground">Track usage & insights</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DateRangeFilter
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
presets={[
|
||||
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
|
||||
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
|
||||
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
|
||||
]}
|
||||
/>
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 h-8"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Tabs
|
||||
value={viewMode}
|
||||
onValueChange={(v) => setViewMode(v as ViewMode)}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3 h-9 mb-4">
|
||||
<TabsTrigger value="daily" className="text-xs">
|
||||
Daily
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monthly" className="text-xs">
|
||||
Monthly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="text-xs">
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* Daily View */}
|
||||
<TabsContent value="daily" className="flex flex-col gap-4 m-0 h-full overflow-hidden">
|
||||
{/* Usage Trend Chart - Full Width */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Usage Trends
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<UsageTrendChart
|
||||
data={trends || []}
|
||||
isLoading={isTrendsLoading}
|
||||
className="h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row - Model Usage & Cost */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1 min-h-0">
|
||||
{/* Model Distribution */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<PieChart className="w-4 h-4" />
|
||||
Model Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1 min-h-0 flex items-center justify-center">
|
||||
<div className="flex w-full h-full items-center">
|
||||
<div className="flex-1 h-full min-w-0">
|
||||
<ModelBreakdownChart
|
||||
data={models || []}
|
||||
isLoading={isModelsLoading}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[220px] shrink-0 pl-4 space-y-2 overflow-y-auto max-h-full">
|
||||
{models?.slice(0, 8).map((model) => (
|
||||
<div key={model.model} className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex justify-between items-baseline gap-2">
|
||||
<span className="font-medium truncate" title={model.model}>
|
||||
{model.model}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{model.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<Card className="flex flex-col h-full min-h-0 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost by Model</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 flex-1 min-h-0 overflow-y-auto">
|
||||
{isModelsLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{[...(models || [])]
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map((model) => (
|
||||
<div
|
||||
key={model.model}
|
||||
className="flex items-center justify-between text-xs border-b border-border/50 pb-2 last:border-0 last:pb-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: getModelColor(model.model) }}
|
||||
/>
|
||||
<span
|
||||
className="font-medium truncate max-w-[150px]"
|
||||
title={model.model}
|
||||
>
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground whitespace-nowrap font-mono">
|
||||
${model.cost.toFixed(4)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Monthly View */}
|
||||
<TabsContent value="monthly" className="m-0 h-full">
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Monthly Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<UsageTrendChart
|
||||
data={trends || []}
|
||||
isLoading={isTrendsLoading}
|
||||
granularity="monthly"
|
||||
className="h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sessions View */}
|
||||
<TabsContent value="sessions" className="m-0 h-full">
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
Session History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate consistent colors for models
|
||||
function getModelColor(model: string): string {
|
||||
const colors = [
|
||||
'#0080FF',
|
||||
'#00C49F',
|
||||
'#FFBB28',
|
||||
'#FF8042',
|
||||
'#8884D8',
|
||||
'#82CA9D',
|
||||
'#FFC658',
|
||||
'#8DD1E1',
|
||||
'#D084D0',
|
||||
'#87D068',
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < model.length; i++) {
|
||||
hash = model.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
export function AnalyticsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 h-full overflow-hidden">
|
||||
{/* Usage Trends Skeleton */}
|
||||
<Card className="flex flex-col min-h-[300px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bottom Row Skeletons */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Model Usage Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
<div className="flex w-full h-full items-center">
|
||||
<div className="flex-1 flex justify-center">
|
||||
<Skeleton className="h-[180px] w-[180px] rounded-full" />
|
||||
</div>
|
||||
<div className="w-[140px] shrink-0 pl-2 space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="w-2 h-2 rounded-full" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Breakdown Skeleton */}
|
||||
<Card className="flex flex-col min-h-[250px]">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+234
-34
@@ -1,56 +1,256 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { HealthCard } from '@/components/health-card';
|
||||
import { useHealth } from '@/hooks/use-health';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { RefreshCw, Terminal, Copy, Cpu } from 'lucide-react';
|
||||
import { HealthGauge } from '@/components/health-gauge';
|
||||
import { HealthStatsBar } from '@/components/health-stats-bar';
|
||||
import { HealthGroupSection } from '@/components/health-group-section';
|
||||
import { useHealth, type HealthGroup } from '@/hooks/use-health';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function getOverallStatus(summary: { passed: number; warnings: number; errors: number }) {
|
||||
if (summary.errors > 0) return 'error';
|
||||
if (summary.warnings > 0) return 'warning';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function sortGroupsByIssues(groups: HealthGroup[]): HealthGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const aErrors = a.checks.filter((c) => c.status === 'error').length;
|
||||
const bErrors = b.checks.filter((c) => c.status === 'error').length;
|
||||
const aWarnings = a.checks.filter((c) => c.status === 'warning').length;
|
||||
const bWarnings = b.checks.filter((c) => c.status === 'warning').length;
|
||||
if (aErrors !== bErrors) return bErrors - aErrors;
|
||||
return bWarnings - aWarnings;
|
||||
});
|
||||
}
|
||||
|
||||
function TerminalHeader() {
|
||||
return (
|
||||
<div className="font-mono text-sm text-muted-foreground flex items-center gap-2">
|
||||
<span className="text-green-500">$</span>
|
||||
<span>ccs doctor</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero skeleton */}
|
||||
<div className="rounded-xl border bg-gradient-to-br from-background to-muted/20 p-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="w-[120px] h-[120px] rounded-full" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats skeleton */}
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
|
||||
{/* Groups skeleton */}
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-20 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthPage() {
|
||||
const { data, isLoading, refetch, dataUpdatedAt } = useHealth();
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleTimeString();
|
||||
// Use dataUpdatedAt directly instead of storing in state
|
||||
const lastRefresh = dataUpdatedAt;
|
||||
|
||||
// Update relative time display by forcing re-render every second
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
// Consume tick to prevent unused variable warning
|
||||
void tick;
|
||||
|
||||
const copyDoctorCommand = () => {
|
||||
navigator.clipboard.writeText('ccs doctor');
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
toast.info('Refreshing health checks...');
|
||||
};
|
||||
|
||||
if (isLoading && !data) {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
const overallStatus = data ? getOverallStatus(data.summary) : 'ok';
|
||||
const sortedGroups = data?.groups ? sortGroupsByIssues(data.groups) : [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Health Dashboard</h1>
|
||||
{dataUpdatedAt && (
|
||||
<p className="text-sm text-muted-foreground">Last check: {formatTime(dataUpdatedAt)}</p>
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-6">
|
||||
{/* Hero Section - Terminal-inspired control center header */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl border p-6',
|
||||
'bg-gradient-to-br from-background via-background to-muted/30'
|
||||
)}
|
||||
>
|
||||
{/* Subtle scan lines effect */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.02] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid pattern background */}
|
||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
{/* Left: Health Gauge - excludes info from percentage */}
|
||||
{data && (
|
||||
<div className="shrink-0">
|
||||
<HealthGauge
|
||||
passed={data.summary.passed}
|
||||
total={data.summary.total - data.summary.info}
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center: Title and status */}
|
||||
<div className="flex-1 space-y-3">
|
||||
{/* Terminal prompt */}
|
||||
<TerminalHeader />
|
||||
|
||||
{/* Main title */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-2xl font-bold font-mono tracking-tight">System Health</h1>
|
||||
{data?.version && (
|
||||
<Badge variant="outline" className="font-mono text-xs bg-muted/50">
|
||||
build {data.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Last scan:</span>
|
||||
<span className="font-mono">
|
||||
{lastRefresh ? formatRelativeTime(lastRefresh) : '--'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">Auto-refresh:</span>
|
||||
<span className="font-mono text-green-500">30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyDoctorCommand}
|
||||
className="gap-2 font-mono text-xs"
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
ccs doctor
|
||||
<Copy className="w-3 h-3 opacity-50" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
<kbd className="hidden md:inline-flex h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
R
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isLoading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{data && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-green-600">{data.summary.passed}</span>
|
||||
<span className="text-muted-foreground">passed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-yellow-600">{data.summary.warnings}</span>
|
||||
<span className="text-muted-foreground">warnings</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-red-600">{data.summary.errors}</span>
|
||||
<span className="text-muted-foreground">errors</span>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<HealthStatsBar
|
||||
total={data.summary.total}
|
||||
passed={data.summary.passed}
|
||||
warnings={data.summary.warnings}
|
||||
errors={data.summary.errors}
|
||||
info={data.summary.info}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !data && <div className="text-muted-foreground">Running health checks...</div>}
|
||||
|
||||
{data && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.checks.map((check) => (
|
||||
<HealthCard key={check.id} check={check} />
|
||||
{/* Health Check Groups - Single column layout */}
|
||||
{sortedGroups.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{sortedGroups.map((group, index) => (
|
||||
<HealthGroupSection
|
||||
key={group.id}
|
||||
group={group}
|
||||
defaultOpen={
|
||||
index < 2 ||
|
||||
group.checks.some((c) => c.status === 'error' || c.status === 'warning')
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer metadata */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
Version <span className="font-mono">{data?.version ?? '--'}</span>
|
||||
</span>
|
||||
<span>
|
||||
Platform{' '}
|
||||
<span className="font-mono">
|
||||
{typeof navigator !== 'undefined' ? navigator.platform : 'linux'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span>Live monitoring active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+86
-58
@@ -2,6 +2,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { StatCard } from '@/components/stat-card';
|
||||
import { HeroSection } from '@/components/hero-section';
|
||||
import { QuickCommands } from '@/components/quick-commands';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
@@ -14,14 +16,15 @@ import {
|
||||
BookOpen,
|
||||
FolderOpen,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { useOverview } from '@/hooks/use-overview';
|
||||
import { useSharedSummary } from '@/hooks/use-shared';
|
||||
|
||||
const HEALTH_COLORS = {
|
||||
ok: 'text-green-500',
|
||||
warning: 'text-yellow-500',
|
||||
error: 'text-red-500',
|
||||
const HEALTH_VARIANTS = {
|
||||
ok: 'success',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
} as const;
|
||||
|
||||
export function HomePage() {
|
||||
@@ -32,44 +35,48 @@ export function HomePage() {
|
||||
if (isOverviewLoading || isSharedLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-[200px] mb-2" />
|
||||
<Skeleton className="h-5 w-[320px]" />
|
||||
{/* Hero Skeleton */}
|
||||
<div className="rounded-xl border p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div>
|
||||
<Skeleton className="h-7 w-[180px] mb-2" />
|
||||
<Skeleton className="h-4 w-[220px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Skeleton */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-3 p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-20 mb-1" />
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<div key={i} className="space-y-3 p-4 border rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-7 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-[180px]" />
|
||||
|
||||
{/* Quick Actions Skeleton */}
|
||||
<div className="border rounded-xl p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-[140px]" />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-10 w-[140px] rounded-md" />
|
||||
<Skeleton className="h-10 w-[120px] rounded-md" />
|
||||
<Skeleton className="h-10 w-[150px] rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-[140px]" />
|
||||
<Skeleton className="h-8 w-[80px] rounded-md" />
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
|
||||
{/* Quick Commands Skeleton */}
|
||||
<div className="border rounded-xl p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-[160px]" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-14 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,13 +84,21 @@ export function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const healthVariant = overview?.health
|
||||
? HEALTH_VARIANTS[overview.health.status as keyof typeof HEALTH_VARIANTS]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Welcome to CCS Config</h1>
|
||||
<p className="text-muted-foreground">Manage your Claude Code Switch configuration</p>
|
||||
</div>
|
||||
{/* Hero Section */}
|
||||
<HeroSection
|
||||
version={overview?.version}
|
||||
healthStatus={overview?.health?.status}
|
||||
healthPassed={overview?.health?.passed}
|
||||
healthTotal={overview?.health?.total}
|
||||
/>
|
||||
|
||||
{/* Configuration Warning */}
|
||||
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
@@ -98,74 +113,87 @@ export function HomePage() {
|
||||
title="API Profiles"
|
||||
value={overview?.profiles ?? 0}
|
||||
icon={Key}
|
||||
variant="accent"
|
||||
subtitle="Settings-based"
|
||||
onClick={() => navigate('/api')}
|
||||
/>
|
||||
<StatCard
|
||||
title="CLIProxy Variants"
|
||||
title="CLIProxy"
|
||||
value={overview?.cliproxy ?? 0}
|
||||
icon={Zap}
|
||||
variant="accent"
|
||||
subtitle={`${overview?.cliproxyProviders ?? 0} auth + ${overview?.cliproxyVariants ?? 0} custom`}
|
||||
onClick={() => navigate('/cliproxy')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Accounts"
|
||||
value={overview?.accounts ?? 0}
|
||||
icon={Users}
|
||||
variant="default"
|
||||
subtitle="Isolated instances"
|
||||
onClick={() => navigate('/accounts')}
|
||||
/>
|
||||
<StatCard
|
||||
title="Health"
|
||||
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
|
||||
icon={Activity}
|
||||
color={
|
||||
overview?.health
|
||||
? HEALTH_COLORS[overview.health.status as keyof typeof HEALTH_COLORS]
|
||||
: undefined
|
||||
}
|
||||
variant={healthVariant}
|
||||
subtitle="System checks"
|
||||
onClick={() => navigate('/health')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3">
|
||||
<Button onClick={() => navigate('/api')}>
|
||||
<Plus className="w-4 h-4 mr-2" /> New Profile
|
||||
<Button onClick={() => navigate('/api')} className="gap-2">
|
||||
<Plus className="w-4 h-4" /> New Profile
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate('/health')}>
|
||||
<Stethoscope className="w-4 h-4 mr-2" /> Run Doctor
|
||||
<Button variant="outline" onClick={() => navigate('/health')} className="gap-2">
|
||||
<Stethoscope className="w-4 h-4" /> Run Doctor
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" asChild className="gap-2">
|
||||
<a href="https://github.com/kaitranntt/ccs" target="_blank" rel="noopener noreferrer">
|
||||
<BookOpen className="w-4 h-4 mr-2" /> Documentation
|
||||
<BookOpen className="w-4 h-4" /> Documentation
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Commands */}
|
||||
<QuickCommands />
|
||||
|
||||
{/* Shared Data Summary */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Shared Data</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/shared')}>
|
||||
View All
|
||||
<Card className="group hover:border-primary/50 transition-colors">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-muted-foreground" />
|
||||
Shared Data
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/shared')}
|
||||
className="gap-1 opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
View All <ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium">{shared?.commands ?? 0}</span>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.commands ?? 0}</span>
|
||||
<span className="text-muted-foreground">Commands</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{shared?.skills ?? 0}</span>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.skills ?? 0}</span>
|
||||
<span className="text-muted-foreground">Skills</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{shared?.agents ?? 0}</span>
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50">
|
||||
<span className="text-xl font-bold font-mono">{shared?.agents ?? 0}</span>
|
||||
<span className="text-muted-foreground">Agents</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,3 +11,5 @@ export { SettingsPage } from './settings';
|
||||
export { HealthPage } from './health';
|
||||
|
||||
export { SharedPage } from './shared';
|
||||
|
||||
export { AnalyticsPage } from './analytics';
|
||||
|
||||
Reference in New Issue
Block a user