Merge pull request #104 from kaitranntt/kai/feat/auth-monitor-design

feat(ui): auth monitor with real-time account flow visualization
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-16 07:09:23 -05:00
committed by GitHub
19 changed files with 1917 additions and 274 deletions
+3 -1
View File
@@ -473,12 +473,14 @@ export function discoverExistingAccounts(): void {
const stats = fs.statSync(filePath);
// Register account with auto-generated nickname
// Use mtime as lastUsedAt (when token was last modified = last auth/refresh)
const lastModified = stats.mtime || stats.birthtime || new Date();
providerAccounts.accounts[accountId] = {
email,
nickname: generateNickname(email),
tokenFile: file,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: stats.mtime?.toISOString(),
lastUsedAt: lastModified.toISOString(),
};
} catch {
// Skip invalid files
+81 -3
View File
@@ -7,10 +7,28 @@
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
/** Per-account usage statistics */
export interface AccountUsageStats {
/** Account email or identifier */
source: string;
/** Number of successful requests */
successCount: number;
/** Number of failed requests */
failureCount: number;
/** Total tokens used */
totalTokens: number;
/** Last request timestamp */
lastUsedAt?: string;
}
/** Usage statistics from CLIProxyAPI */
export interface CliproxyStats {
/** Total number of requests processed */
totalRequests: number;
/** Total successful requests */
successCount: number;
/** Total failed requests */
failureCount: number;
/** Token counts */
tokens: {
input: number;
@@ -21,6 +39,8 @@ export interface CliproxyStats {
requestsByModel: Record<string, number>;
/** Requests grouped by provider */
requestsByProvider: Record<string, number>;
/** Per-account usage breakdown */
accountStats: Record<string, AccountUsageStats>;
/** Number of quota exceeded (429) events */
quotaExceededCount: number;
/** Number of request retries */
@@ -29,6 +49,21 @@ export interface CliproxyStats {
collectedAt: string;
}
/** Request detail from CLIProxyAPI */
interface RequestDetail {
timestamp: string;
source: string;
auth_index: number;
tokens: {
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
total_tokens: number;
};
failed: boolean;
}
/** Usage API response from CLIProxyAPI /v0/management/usage endpoint */
interface UsageApiResponse {
failed_requests?: number;
@@ -47,6 +82,7 @@ interface UsageApiResponse {
{
total_requests?: number;
total_tokens?: number;
details?: RequestDetail[];
}
>;
}
@@ -83,9 +119,14 @@ export async function fetchCliproxyStats(
const data = (await response.json()) as UsageApiResponse;
const usage = data.usage;
// Extract models and providers from the nested API structure
// Extract models, providers, and per-account stats from the nested API structure
const requestsByModel: Record<string, number> = {};
const requestsByProvider: Record<string, number> = {};
const accountStats: Record<string, AccountUsageStats> = {};
let totalSuccessCount = 0;
let totalFailureCount = 0;
let totalInputTokens = 0;
let totalOutputTokens = 0;
if (usage?.apis) {
for (const [provider, providerData] of Object.entries(usage.apis)) {
@@ -93,6 +134,40 @@ export async function fetchCliproxyStats(
if (providerData.models) {
for (const [model, modelData] of Object.entries(providerData.models)) {
requestsByModel[model] = modelData.total_requests ?? 0;
// Aggregate per-account stats from request details
if (modelData.details) {
for (const detail of modelData.details) {
const source = detail.source || 'unknown';
// Initialize account stats if not exists
if (!accountStats[source]) {
accountStats[source] = {
source,
successCount: 0,
failureCount: 0,
totalTokens: 0,
};
}
// Update account stats
if (detail.failed) {
accountStats[source].failureCount++;
totalFailureCount++;
} else {
accountStats[source].successCount++;
totalSuccessCount++;
}
const tokens = detail.tokens?.total_tokens ?? 0;
accountStats[source].totalTokens += tokens;
accountStats[source].lastUsedAt = detail.timestamp;
// Aggregate token breakdowns
totalInputTokens += detail.tokens?.input_tokens ?? 0;
totalOutputTokens += detail.tokens?.output_tokens ?? 0;
}
}
}
}
}
@@ -101,13 +176,16 @@ export async function fetchCliproxyStats(
// Normalize the response to our interface
return {
totalRequests: usage?.total_requests ?? 0,
successCount: totalSuccessCount,
failureCount: totalFailureCount,
tokens: {
input: 0, // API doesn't provide input/output breakdown
output: 0,
input: totalInputTokens,
output: totalOutputTokens,
total: usage?.total_tokens ?? 0,
},
requestsByModel,
requestsByProvider,
accountStats,
quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0,
retryCount: 0, // API doesn't track retries separately
collectedAt: new Date().toISOString(),
+34 -1
View File
@@ -36,6 +36,7 @@ import {
getProviderAccounts,
setDefaultAccount as setDefaultAccountFn,
removeAccount as removeAccountFn,
touchAccount,
} from '../cliproxy/account-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { getClaudeEnvVars } from '../cliproxy/config-generator';
@@ -469,11 +470,43 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
/**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
*/
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
apiRoutes.get('/cliproxy/auth', async (_req: Request, res: Response) => {
// Initialize accounts from existing tokens on first request
initializeAccounts();
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
gemini: 'gemini',
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
};
// Update lastUsedAt for providers with recent activity
if (stats?.requestsByProvider) {
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
if (requestCount > 0) {
const provider = statsProviderMap[statsProvider.toLowerCase()];
if (provider) {
// Touch the default account for this provider (or all accounts)
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
// Only touch if this is the default account (most likely being used)
if (account.isDefault) {
touchAccount(provider, account.id);
}
}
}
}
}
}
const statuses = getAllAuthStatus();
const authStatus = statuses.map((status) => {
+68 -2
View File
@@ -5,6 +5,8 @@
"name": "ui",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
@@ -201,6 +203,20 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
"@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="],
"@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="],
"@nivo/sankey": ["@nivo/sankey@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-sankey": "^0.11.2", "@types/d3-shape": "^3.1.6", "d3-sankey": "^0.12.3", "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw=="],
"@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="],
"@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="],
"@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -279,6 +295,18 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="],
"@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="],
"@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="],
"@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="],
"@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="],
"@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
@@ -383,8 +411,12 @@
"@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="],
"@types/d3-sankey": ["@types/d3-sankey@0.11.2", "", { "dependencies": { "@types/d3-shape": "^1" } }, "sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
"@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=="],
@@ -479,19 +511,23 @@
"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-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="],
"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-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
"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-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
"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-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
@@ -725,6 +761,8 @@
"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=="],
"react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="],
"react-virtuoso": ["react-virtuoso@4.17.0", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@@ -783,6 +821,8 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "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-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="],
"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=="],
@@ -807,6 +847,10 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@nivo/core/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@nivo/sankey/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -845,12 +889,34 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
"d3-scale/d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-time-format/d3-time": ["d3-time@2.1.1", "", { "dependencies": { "d3-array": "2" } }, "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ=="],
"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=="],
"@nivo/core/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@nivo/sankey/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"d3-time-format/d3-time/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
"victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"d3-time-format/d3-time/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
}
}
+2
View File
@@ -16,6 +16,8 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
+782
View File
@@ -0,0 +1,782 @@
/**
* Account Flow Visualization
* Custom SVG bezier curve visualization showing request flow from accounts to providers
* Inspired by modern dark theme design with glass panels and glow effects
*/
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/provider-icon';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { STATUS_COLORS } from '@/lib/utils';
import {
ChevronRight,
X,
CheckCircle2,
XCircle,
Clock,
Activity,
GripVertical,
} from 'lucide-react';
/** Position offset for draggable cards */
interface DragOffset {
x: number;
y: number;
}
interface AccountData {
id: string;
email: string;
provider: string;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
interface ProviderData {
provider: string;
displayName: string;
totalRequests: number;
accounts: AccountData[];
}
interface AccountFlowVizProps {
providerData: ProviderData;
onBack?: () => void;
}
interface ConnectionEvent {
id: string;
timestamp: Date;
accountEmail: string;
status: 'success' | 'failed' | 'pending';
latencyMs?: number;
}
/** Generate connection events from real account data */
function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
const events: ConnectionEvent[] = [];
accounts.forEach((account) => {
// Only show events for accounts that have actual request data
const hasActivity = account.successCount > 0 || account.failureCount > 0;
if (!hasActivity) return;
// Create a single consolidated event per account showing its current status
const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date();
const hasFailures = account.failureCount > 0;
events.push({
id: `${account.id}-status`,
timestamp: lastUsed,
accountEmail: account.email,
status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success',
});
});
// Sort by timestamp descending (most recent first)
return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
}
/** Format timestamp for timeline display */
function formatTimelineTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'now';
if (diffMins < 60) return `${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h`;
return `${Math.floor(diffHours / 24)}d`;
}
/** Connection Timeline Component - right sidebar panel */
function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) {
if (events.length === 0) {
return (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline
</span>
</div>
{/* Timeline container */}
<div
className={cn(
'flex-1 rounded-xl p-4 overflow-y-auto',
'bg-muted/20 dark:bg-zinc-900/40 backdrop-blur-sm',
'border border-border/30 dark:border-white/[0.05]'
)}
>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50 dark:bg-white/[0.08]" />
{/* Events */}
<div className="space-y-3">
{events.slice(0, 8).map((event) => {
const statusColor =
event.status === 'success'
? STATUS_COLORS.success
: event.status === 'failed'
? STATUS_COLORS.failed
: STATUS_COLORS.degraded;
return (
<div key={event.id} className="relative flex items-start gap-3 pl-1">
{/* Timeline dot */}
<div
className={cn(
'relative z-10 w-3.5 h-3.5 rounded-full flex-shrink-0 mt-0.5',
'ring-2 ring-background dark:ring-zinc-950'
)}
style={{ backgroundColor: statusColor }}
/>
{/* Event content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-mono text-foreground truncate">
{cleanEmail(event.accountEmail)}
</span>
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
{formatTimelineTime(event.timestamp)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-[9px] font-medium uppercase"
style={{ color: statusColor }}
>
{event.status}
</span>
{event.latencyMs && (
<span className="text-[9px] text-muted-foreground font-mono">
{event.latencyMs}ms
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Show more indicator */}
{events.length > 8 && (
<div className="mt-3 pt-2 border-t border-border/30 dark:border-white/[0.05]">
<span className="text-[9px] text-muted-foreground font-mono">
+{events.length - 8} more events
</span>
</div>
)}
</div>
</div>
</div>
);
}
/** Strip common email domains for cleaner display */
function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
function getTimeAgo(dateStr?: string): string {
if (!dateStr) return 'never';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'unknown';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
if (diffMs < 0) return 'just now';
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null);
const [paths, setPaths] = useState<string[]>([]);
// Drag state for all cards (account IDs + 'provider')
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
);
// Pulse state: account IDs that are currently pulsing
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
// Store previous counts to detect changes
const [prevCounts, setPrevCounts] = useState<Record<string, number>>({});
const { accounts } = providerData;
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
// Detect new activity and trigger pulse (runs when accounts data changes)
useEffect(() => {
const newPulsing = new Set<string>();
const newCounts: Record<string, number> = {};
accounts.forEach((account) => {
const currentCount = account.successCount + account.failureCount;
newCounts[account.id] = currentCount;
const prev = prevCounts[account.id] ?? 0;
if (currentCount > prev && prev > 0) {
newPulsing.add(account.id);
}
});
setPrevCounts(newCounts);
if (newPulsing.size > 0) {
setPulsingAccounts(newPulsing);
// Clear pulse after animation completes (match CSS animation duration)
const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accounts]);
// Generate connection events for timeline
const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]);
// Calculate SVG paths for bezier curves
const calculatePaths = useCallback(() => {
if (!containerRef.current || !svgRef.current) return;
const container = containerRef.current;
const svg = svgRef.current;
const svgRect = svg.getBoundingClientRect();
const destEl = container.querySelector('[data-provider-node]');
if (!destEl) return;
const destRect = destEl.getBoundingClientRect();
const newPaths: string[] = [];
accounts.forEach((_, i) => {
const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
if (!sourceEl) return;
const sourceRect = sourceEl.getBoundingClientRect();
// Determine if this account is on the right side
const isRightSide = sourceEl.hasAttribute('data-right-side');
let startX: number, startY: number, destX: number, destY: number;
if (isRightSide) {
// Right side account: connect from left edge to right edge of provider
startX = sourceRect.left - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.right - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
} else {
// Left side account: connect from right edge to left edge of provider
startX = sourceRect.right - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.left - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
}
// Bezier control points
const cp1X = startX + (destX - startX) * 0.5;
const cp1Y = startY;
const cp2X = destX - (destX - startX) * 0.5;
const cp2Y = destY;
newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`);
});
setPaths(newPaths);
}, [accounts]);
useEffect(() => {
// Initial calculation after render
const timer = setTimeout(calculatePaths, 50);
window.addEventListener('resize', calculatePaths);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', calculatePaths);
};
}, [calculatePaths]);
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
// Split accounts into left and right groups (when > 1 account, balance both sides)
const { leftAccounts, rightAccounts } = useMemo(() => {
if (accounts.length <= 1) {
return { leftAccounts: accounts, rightAccounts: [] };
}
const mid = Math.ceil(accounts.length / 2);
return {
leftAccounts: accounts.slice(0, mid),
rightAccounts: accounts.slice(mid),
};
}, [accounts]);
const hasRightAccounts = rightAccounts.length > 0;
// Drag handlers
const handlePointerDown = useCallback(
(id: string, e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const offset = dragOffsets[id] || { x: 0, y: 0 };
dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
setDraggingId(id);
},
[dragOffsets]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingId || !dragStartRef.current) return;
const start = dragStartRef.current;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
setDragOffsets((prev) => ({
...prev,
[draggingId]: {
x: start.offsetX + dx,
y: start.offsetY + dy,
},
}));
// Recalculate paths during drag
requestAnimationFrame(calculatePaths);
},
[draggingId, calculatePaths]
);
const handlePointerUp = useCallback(() => {
setDraggingId(null);
dragStartRef.current = null;
}, []);
// Get offset for a card
const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
return (
<div className="flex flex-col" ref={containerRef}>
{/* Back button */}
{onBack && (
<button
onClick={onBack}
className="self-start flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className="w-3 h-3 rotate-180" />
<span>Back to providers</span>
</button>
)}
{/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */}
<div className="min-h-[320px] flex gap-4 px-4 py-6">
{/* Flow visualization section */}
<div className="relative flex-1 flex items-center justify-between px-4">
{/* SVG Canvas (Background) */}
<svg
ref={svgRef}
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
>
<defs>
<filter id="flow-glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
{paths.map((d, i) => {
const account = accounts[i];
const total = account.successCount + account.failureCount;
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
const isHovered = hoveredAccount === i;
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
const isPulsing = pulsingAccounts.has(account.id);
return (
<g key={i}>
{/* Base path - static connection line */}
<path
d={d}
fill="none"
stroke={account.color}
strokeWidth={strokeWidth}
strokeOpacity={isHovered ? 0.9 : isDimmed ? 0.05 : 0.2}
strokeLinecap="round"
filter={isHovered ? 'url(#flow-glow)' : undefined}
className="transition-all duration-300"
/>
{/* Pulse layer - only shows when new activity detected */}
{isPulsing && (
<>
{/* Glowing path pulse */}
<path
d={d}
fill="none"
stroke={account.color}
strokeWidth={strokeWidth * 2}
strokeLinecap="round"
filter="url(#flow-glow)"
className="animate-request-pulse"
/>
{/* Traveling dot along path */}
<circle
r={6}
fill={account.color}
filter="url(#flow-glow)"
style={{
offsetPath: `path('${d}')`,
offsetDistance: '0%',
animation: 'travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
}}
/>
</>
)}
</g>
);
})}
</svg>
{/* Left Accounts */}
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{leftAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
onClick={() => !isDragging && setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pr-6 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-l-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderLeftColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 ml-3">
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
{cleanEmail(account.email)}
</span>
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
<div className="flex gap-1">
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div>
{/* Connector Dot - Right side */}
<div
className={cn(
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
{/* Center Provider */}
<div className="z-10 w-52 flex items-center flex-shrink-0">
{(() => {
const isDragging = draggingId === 'provider';
const offset = getOffset('provider');
return (
<div
data-provider-node
onPointerDown={(e) => handlePointerDown('provider', e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border-2 border-border/50 dark:border-white/[0.08]',
// Idle animations: float + border glow (disabled when dragging)
!isDragging && 'animate-subtle-float animate-border-glow',
'select-none touch-none',
hoveredAccount !== null && 'scale-[1.02]',
isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
)}
style={
{
'--glow-color': `${providerColor}60`,
borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
} as React.CSSProperties
}
>
{/* Drag handle */}
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
{/* Animated glow background */}
<div
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
/>
{/* Left Connector Point */}
<div
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
{/* Right Connector Point - only show if there are right accounts */}
{hasRightAccounts && (
<div
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
<div className="flex items-center gap-3 mb-4 relative z-10">
{/* Provider icon with breathing animation */}
<div className="animate-icon-breathe">
<ProviderIcon provider={providerData.provider} size={36} withBackground />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">
Provider
</p>
</div>
</div>
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span>
<span className="text-foreground font-mono">
{totalRequests.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span>
<span className="text-foreground font-mono">{accounts.length}</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
</div>
);
})()}
</div>
{/* Right Accounts */}
{hasRightAccounts && (
<div className="flex flex-col gap-3 z-10 w-44 justify-center">
{rightAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
data-right-side
onClick={() => !isDragging && setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-r-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderRightColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 right-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 mr-3">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-180',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
{cleanEmail(account.email)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
</div>
{/* Connector Dot - Left side */}
<div
className={cn(
'absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Right Section: Connection Timeline - Fixed compact width */}
<div className="w-56 flex-shrink-0">
<ConnectionTimeline events={connectionEvents} />
</div>
</div>
{/* Detail Panel - slides in from bottom, pushes content */}
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-out',
selectedAccount ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<div className={cn('bg-card dark:bg-zinc-950 border-t border-border', 'p-4')}>
<div className="relative">
<button
onClick={() => setSelectedAccount(null)}
className="absolute top-0 right-0 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
{selectedAccount && (
<div className="grid grid-cols-4 gap-4">
{/* Account Info */}
<div className="border-r border-border pr-4">
<div className="flex items-center gap-2 mb-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: selectedAccount.color }}
/>
<span className="text-sm font-semibold text-foreground tracking-tight truncate">
{cleanEmail(selectedAccount.email)}
</span>
</div>
<div className="text-[10px] text-muted-foreground uppercase tracking-widest">
Source Account
</div>
</div>
{/* Stats */}
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
<span>SUCCESSFUL</span>
</div>
<div className="text-xl font-mono text-emerald-700 dark:text-emerald-500 tracking-tighter">
{selectedAccount.successCount.toLocaleString()}
</div>
</div>
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
<span>FAILED</span>
</div>
<div className="text-xl font-mono text-red-700 dark:text-red-500 tracking-tighter">
{selectedAccount.failureCount.toLocaleString()}
</div>
</div>
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
<Clock className="w-3 h-3" />
<span>LAST SYNC</span>
</div>
<div className="text-sm font-mono text-foreground mt-1">
{getTimeAgo(selectedAccount.lastUsedAt)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
+463
View File
@@ -0,0 +1,463 @@
/**
* Auth Monitor Component with Account Flow Visualization
* Shows request flow from accounts to providers using custom SVG bezier curves
* Uses glass panel aesthetic with hover interactions and glow effects
*/
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
import { Skeleton } from '@/components/ui/skeleton';
import { ProviderIcon } from '@/components/provider-icon';
import { AccountFlowViz } from '@/components/account-flow-viz';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
interface AccountRow {
id: string;
email: string;
provider: string;
displayName: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
color: string;
}
interface ProviderStats {
provider: string;
displayName: string;
totalRequests: number;
successCount: number;
failureCount: number;
accountCount: number;
accounts: AccountRow[];
}
function getSuccessRate(success: number, failure: number): number {
const total = success + failure;
if (total === 0) return 100;
return Math.round((success / total) * 100);
}
/** Strip common email domains for cleaner display */
function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
}
// Vibrant colors for account segments - darker for light theme contrast
const ACCOUNT_COLORS = [
'#1e6091', // Deep Cerulean (was #277da1)
'#2d8a6e', // Deep Seaweed (was #43aa8b)
'#d4a012', // Dark Tuscan (was #f9c74f)
'#c92a2d', // Deep Strawberry (was #f94144)
'#c45a1a', // Deep Pumpkin (was #f3722c)
'#6b9c4d', // Dark Willow (was #90be6d)
'#3d5a73', // Deep Blue Slate (was #577590)
'#cc7614', // Dark Carrot (was #f8961e)
'#3a7371', // Deep Cyan (was #4d908e)
'#7c5fc4', // Deep Purple (was #a78bfa)
];
/** Enhanced live pulse indicator with multi-ring animation */
function LivePulse() {
return (
<div className="relative flex items-center justify-center w-5 h-5">
{/* Outer ping ring */}
<div
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Middle pulse ring */}
<div
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Inner solid dot */}
<div
className="relative w-2 h-2 rounded-full z-10"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
</div>
);
}
/** Inline success/failure badge for provider cards */
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3 text-emerald-700 dark:text-emerald-500" />
<span className="text-[10px] font-mono font-medium text-emerald-700 dark:text-emerald-500">
{success.toLocaleString()}
</span>
</div>
{failure > 0 && (
<div className="flex items-center gap-0.5">
<XCircle className="w-3 h-3 text-red-700 dark:text-red-500" />
<span className="text-[10px] font-mono font-medium text-red-700 dark:text-red-500">
{failure.toLocaleString()}
</span>
</div>
)}
</div>
);
}
export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
}
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
if (!statsData?.accountStats) return new Map<string, AccountUsageStats>();
return new Map(Object.entries(statsData.accountStats));
}, [statsData?.accountStats]);
// Transform auth status data into account rows
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
if (!data?.authStatus) {
return {
accounts: [],
totalSuccess: 0,
totalFailure: 0,
totalRequests: 0,
providerStats: [],
};
}
const accountsList: AccountRow[] = [];
const providerMap = new Map<
string,
{ success: number; failure: number; accounts: AccountRow[] }
>();
let tSuccess = 0;
let tFailure = 0;
let colorIndex = 0;
data.authStatus.forEach((status: AuthStatus) => {
const providerKey = status.provider;
if (!providerMap.has(providerKey)) {
providerMap.set(providerKey, { success: 0, failure: 0, accounts: [] });
}
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
// Get real stats from CLIProxy - try email first, then id
const accountEmail = account.email || account.id;
const realStats = accountStatsMap.get(accountEmail);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
};
accountsList.push(row);
providerData.accounts.push(row);
colorIndex++;
});
});
// Build provider stats array
const providerStatsArr: ProviderStats[] = [];
providerMap.forEach((pData, provider) => {
if (pData.accounts.length === 0) return;
providerStatsArr.push({
provider,
displayName: getProviderDisplayName(provider),
totalRequests: pData.success + pData.failure,
successCount: pData.success,
failureCount: pData.failure,
accountCount: pData.accounts.length,
accounts: pData.accounts,
});
});
providerStatsArr.sort((a, b) => b.totalRequests - a.totalRequests);
return {
accounts: accountsList,
totalSuccess: tSuccess,
totalFailure: tFailure,
totalRequests: tSuccess + tFailure,
providerStats: providerStatsArr,
};
}, [data?.authStatus, accountStatsMap]);
const overallSuccessRate =
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
// Get selected provider data for detail view
const selectedProviderData = selectedProvider
? providerStats.find((ps) => ps.provider === selectedProvider)
: null;
if (isLoading || statsLoading) {
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="p-4 space-y-4">
<div className="flex gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-16 flex-1 rounded-lg" />
))}
</div>
<Skeleton className="h-48 w-full rounded-lg" />
</div>
</div>
);
}
if (error || !data?.authStatus || accounts.length === 0) {
return null;
}
return (
<div className="rounded-xl border border-border overflow-hidden font-mono text-[13px] text-foreground bg-card/50 dark:bg-zinc-900/60 backdrop-blur-sm">
{/* Enhanced Live Header with gradient glow */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
</div>
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div>
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-4 gap-3 p-4 border-b border-border bg-muted/20 dark:bg-zinc-900/30">
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Accounts"
value={accounts.length}
color="var(--accent)"
/>
<SummaryCard
icon={<CheckCircle2 className="w-4 h-4" />}
label="Success"
value={totalSuccess.toLocaleString()}
color={STATUS_COLORS.success}
/>
<SummaryCard
icon={<XCircle className="w-4 h-4" />}
label="Failed"
value={totalFailure.toLocaleString()}
color={totalFailure > 0 ? STATUS_COLORS.failed : undefined}
/>
<SummaryCard
icon={<Activity className="w-4 h-4" />}
label="Success Rate"
value={`${overallSuccessRate}%`}
color={
overallSuccessRate === 100
? STATUS_COLORS.success
: overallSuccessRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed
}
/>
</div>
{/* Flow Visualization */}
<div className="relative overflow-hidden">
{selectedProviderData ? (
// Account-level flow view
<AccountFlowViz
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
/>
) : (
// Provider cards view
<div className="p-6">
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mb-4">
Request Distribution by Provider
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{providerStats.map((ps) => {
const successRate = getSuccessRate(ps.successCount, ps.failureCount);
const providerColor = PROVIDER_COLORS[ps.provider.toLowerCase()] || '#6b7280';
const isHovered = hoveredProvider === ps.provider;
return (
<button
key={ps.provider}
onClick={() => setSelectedProvider(ps.provider)}
onMouseEnter={() => setHoveredProvider(ps.provider)}
onMouseLeave={() => setHoveredProvider(null)}
className={cn(
'group relative rounded-xl p-4 text-left transition-all duration-300',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'hover:border-opacity-50 hover:scale-[1.02] hover:shadow-lg',
isHovered && 'ring-1'
)}
style={
{
borderColor: isHovered ? providerColor : undefined,
'--ring-color': providerColor,
} as React.CSSProperties
}
>
<div className="flex items-center gap-3 mb-3">
<ProviderIcon provider={ps.provider} size={36} withBackground />
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{ps.displayName}
</h3>
<p className="text-[10px] text-muted-foreground">
{ps.accountCount} account{ps.accountCount !== 1 ? 's' : ''}
</p>
</div>
<ChevronRight
className={cn(
'w-4 h-4 ml-auto text-muted-foreground transition-all',
isHovered ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2'
)}
/>
</div>
<div className="space-y-2">
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
<span
className="font-mono font-semibold"
style={{
color:
successRate === 100
? STATUS_COLORS.success
: successRate >= 95
? STATUS_COLORS.degraded
: STATUS_COLORS.failed,
}}
>
{successRate}%
</span>
</div>
{/* Progress bar */}
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${successRate}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
{/* Account color dots */}
<div className="flex gap-1 mt-3">
{ps.accounts.slice(0, 5).map((acc) => (
<div
key={acc.id}
className="w-2 h-2 rounded-full"
style={{ backgroundColor: acc.color }}
title={cleanEmail(acc.email)}
/>
))}
{ps.accounts.length > 5 && (
<span className="text-[10px] text-muted-foreground ml-1">
+{ps.accounts.length - 5}
</span>
)}
</div>
</button>
);
})}
</div>
</div>
)}
</div>
</div>
);
}
// Summary Card Component
function SummaryCard({
icon,
label,
value,
color,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg bg-card/50 dark:bg-zinc-900/50 border border-border/50 dark:border-white/[0.06]">
<div
className="w-8 h-8 rounded-md flex items-center justify-center"
style={{
backgroundColor: color ? `${color}15` : 'var(--muted)',
color: color || 'var(--muted-foreground)',
}}
>
{icon}
</div>
<div>
<div className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</div>
<div
className="text-lg font-semibold font-mono leading-tight"
style={{ color: color || 'var(--foreground)' }}
>
{value}
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Docs Link Button
*
* Links to CCS documentation site for guides and reference.
*/
import { BookOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
const DOCS_URL = 'https://docs.ccs.kaitran.ca';
export function DocsLink() {
return (
<Button variant="ghost" size="icon" asChild title="View documentation">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer">
<BookOpen className="w-4 h-4" />
</a>
</Button>
);
}
+10 -73
View File
@@ -1,85 +1,22 @@
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;
export function HeroSection({ version = '5.0.0' }: HeroSectionProps) {
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 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>
);
+2
View File
@@ -4,6 +4,7 @@ import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { ThemeToggle } from '@/components/theme-toggle';
import { GitHubLink } from '@/components/github-link';
import { DocsLink } from '@/components/docs-link';
import { ConnectionIndicator } from '@/components/connection-indicator';
import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
import { Skeleton } from '@/components/ui/skeleton';
@@ -26,6 +27,7 @@ export function Layout() {
<div className="font-semibold text-lg tracking-tight">CCS Config</div>
<div className="flex items-center gap-2">
<ConnectionIndicator />
<DocsLink />
<GitHubLink />
<ThemeToggle />
</div>
+3 -6
View File
@@ -31,20 +31,17 @@ export function ProfileDeck() {
);
}
// Mock data for demonstration
const mockProfiles = profiles.map((profile, index: number) => ({
// Use real profile data directly
const normalizedProfiles = profiles.map((profile) => ({
...profile,
configured: profile.configured ?? false, // Ensure configured is always boolean
isActive: index === 0, // First profile is active
lastUsed: index === 0 ? '2 hours ago' : index === 1 ? '1 day ago' : undefined,
model: index === 0 ? 'claude-3.5-sonnet' : index === 1 ? 'claude-3-haiku' : undefined,
}));
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Profiles</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{mockProfiles.map((profile) => (
{normalizedProfiles.map((profile) => (
<ProfileCard
key={profile.name}
profile={profile}
+95
View File
@@ -0,0 +1,95 @@
/**
* Provider Icon Component
* Renders provider logos from /assets/providers/
* Supports white background circle variant for dark themes
*/
import { cn } from '@/lib/utils';
import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
interface ProviderIconProps {
provider: string;
className?: string;
size?: number;
/** White background circle variant for better visibility */
withBackground?: boolean;
}
export function ProviderIcon({
provider,
className,
size = 18,
withBackground = false,
}: ProviderIconProps) {
const normalized = provider.toLowerCase();
const assetPath = PROVIDER_ASSETS[normalized];
// Icon size is smaller when inside background circle
const iconSize = withBackground ? Math.floor(size * 0.65) : size;
const iconElement = assetPath ? (
<img
src={assetPath}
alt={`${provider} icon`}
width={iconSize}
height={iconSize}
className="shrink-0 object-contain"
/>
) : (
// Fallback: colored text letter
<span
className="font-bold"
style={{
color: PROVIDER_COLORS[normalized] || '#6b7280',
fontSize: iconSize * 0.6,
}}
>
{provider.charAt(0).toUpperCase()}
</span>
);
if (withBackground) {
return (
<div
className={cn(
'shrink-0 rounded-full bg-white border border-border flex items-center justify-center shadow-sm',
className
)}
style={{ width: size, height: size }}
>
{iconElement}
</div>
);
}
// Without background - original behavior for logos, colored circle for fallback
if (assetPath) {
return (
<img
src={assetPath}
alt={`${provider} icon`}
width={size}
height={size}
className={cn('shrink-0 rounded-sm object-contain', className)}
/>
);
}
const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
return (
<div
className={cn(
'shrink-0 rounded-full flex items-center justify-center text-white font-bold',
className
)}
style={{
width: size,
height: size,
backgroundColor: bgColor,
fontSize: size * 0.5,
}}
>
{provider.charAt(0).toUpperCase()}
</div>
);
}
+20 -2
View File
@@ -4,9 +4,25 @@
import { useQuery } from '@tanstack/react-query';
/** Per-account usage statistics */
export interface AccountUsageStats {
/** Account email or identifier */
source: string;
/** Number of successful requests */
successCount: number;
/** Number of failed requests */
failureCount: number;
/** Total tokens used */
totalTokens: number;
/** Last request timestamp */
lastUsedAt?: string;
}
/** CLIProxy usage statistics */
export interface CliproxyStats {
totalRequests: number;
successCount: number;
failureCount: number;
tokens: {
input: number;
output: number;
@@ -14,6 +30,8 @@ export interface CliproxyStats {
};
requestsByModel: Record<string, number>;
requestsByProvider: Record<string, number>;
/** Per-account usage breakdown */
accountStats: Record<string, AccountUsageStats>;
quotaExceededCount: number;
retryCount: number;
collectedAt: string;
@@ -67,9 +85,9 @@ export function useCliproxyStats(enabled = true) {
queryKey: ['cliproxy-stats'],
queryFn: fetchCliproxyStats,
enabled,
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: 5000, // Refresh every 5 seconds for near-real-time updates
retry: 1,
staleTime: 10000, // Consider data stale after 10 seconds
staleTime: 3000, // Consider data stale after 3 seconds
});
}
+134 -21
View File
@@ -5,46 +5,46 @@
:root {
/* CCS Brand Colors - Calculated from Crail, Pampas, White, Cloudy */
/* Primary: Neutral (Dark Warm Grey) - Demoted Crail from Primary */
--primary: oklch(0.2 0.02 40);
--primary: oklch(0.15 0.02 40); /* Darker for better contrast */
--primary-foreground: oklch(0.9635 0.0067 97.35); /* Pampas */
--secondary: oklch(0.9 0.01 95); /* Light Cloudy */
--secondary-foreground: oklch(0.2 0.02 40);
--secondary: oklch(0.88 0.012 95); /* Slightly darker */
--secondary-foreground: oklch(0.15 0.02 40);
/* Accent: Crail (Moved from Primary) */
--accent: oklch(0.5971 0.1352 39.87); /* Crail */
--accent: oklch(0.52 0.15 39.87); /* Crail - slightly darker */
--accent-foreground: oklch(1 0 0); /* White */
--background: oklch(0.9635 0.0067 97.35); /* Pampas */
--foreground: oklch(0.2 0.02 40); /* Dark warm grey */
--foreground: oklch(0.15 0.02 40); /* Darker for better readability */
--muted: oklch(0.92 0.01 95);
--muted-foreground: oklch(0.55 0.02 40);
--muted: oklch(0.9 0.012 95);
--muted-foreground: oklch(0.4 0.025 40); /* Much darker: 0.55 -> 0.40 */
--border: oklch(0.85 0.015 91.6); /* Based on Cloudy */
--input: oklch(0.85 0.015 91.6);
--border: oklch(0.78 0.02 91.6); /* Darker: 0.85 -> 0.78 */
--input: oklch(0.78 0.02 91.6);
--ring: oklch(0.2 0.02 40); /* Match primary */
--ring: oklch(0.15 0.02 40); /* Match primary */
--radius: 0.5rem;
--popover: oklch(0.9635 0.0067 97.35); /* Match background */
--popover-foreground: oklch(0.2 0.02 40); /* Match foreground */
--popover: oklch(0.98 0.005 97.35); /* Slight depth */
--popover-foreground: oklch(0.15 0.02 40); /* Match foreground */
--card: oklch(0.9635 0.0067 97.35); /* Match background */
--card-foreground: oklch(0.2 0.02 40); /* Match foreground */
--card: oklch(0.98 0.005 97.35); /* Slight depth from background */
--card-foreground: oklch(0.15 0.02 40); /* Match foreground */
--destructive: oklch(0.577 0.245 27.325); /* Red 600 */
--destructive-foreground: oklch(0.9635 0.0067 97.35); /* White */
--destructive: oklch(0.5 0.22 27); /* Darker red for visibility */
--destructive-foreground: oklch(0.98 0.005 97.35); /* White */
/* Sidebar colors - Light */
--sidebar: oklch(0.9635 0.0067 97.35); /* Pampas */
--sidebar-foreground: oklch(0.2 0.02 40);
--sidebar-primary: oklch(0.2 0.02 40); /* Neutral */
--sidebar-foreground: oklch(0.15 0.02 40);
--sidebar-primary: oklch(0.15 0.02 40); /* Neutral - darker */
--sidebar-primary-foreground: oklch(0.9635 0.0067 97.35);
--sidebar-accent: oklch(0.5971 0.1352 39.87); /* Crail */
--sidebar-accent: oklch(0.52 0.15 39.87); /* Crail - darker */
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.85 0.015 91.6);
--sidebar-ring: oklch(0.2 0.02 40);
--sidebar-border: oklch(0.78 0.02 91.6); /* Darker */
--sidebar-ring: oklch(0.15 0.02 40);
}
.dark {
@@ -199,3 +199,116 @@
.zoom-in-95 {
animation-name: zoom-in;
}
/* Flow visualization animations */
@keyframes request-pulse {
0% {
stroke-opacity: 1;
stroke-width: inherit;
filter: drop-shadow(0 0 2px currentColor);
}
30% {
stroke-opacity: 0.9;
filter: drop-shadow(0 0 8px currentColor);
}
60% {
stroke-opacity: 0.5;
stroke-width: calc(inherit * 1.5);
filter: drop-shadow(0 0 4px currentColor);
}
100% {
stroke-opacity: 0;
stroke-width: calc(inherit * 2);
filter: none;
}
}
/* Traveling dot along path */
@keyframes travel-dot {
0% {
offset-distance: 0%;
opacity: 1;
}
80% {
opacity: 1;
}
100% {
offset-distance: 100%;
opacity: 0;
}
}
@keyframes glow-pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.3));
transform: scale(1);
}
25% {
box-shadow: 0 0 15px 3px var(--glow-color, rgba(16, 185, 129, 0.5));
transform: scale(1.02);
}
50% {
box-shadow: 0 0 25px 6px var(--glow-color, rgba(16, 185, 129, 0.4));
transform: scale(1.01);
}
75% {
box-shadow: 0 0 12px 2px var(--glow-color, rgba(16, 185, 129, 0.35));
}
}
@keyframes subtle-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes icon-breathe {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
@keyframes border-glow {
0%,
100% {
border-color: var(--glow-color, rgba(16, 185, 129, 0.2));
}
50% {
border-color: var(--glow-color, rgba(16, 185, 129, 0.5));
}
}
.animate-request-pulse {
animation: request-pulse 2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.animate-glow-pulse {
animation: glow-pulse 2.5s ease-in-out infinite;
}
.animate-travel-dot {
animation: travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.animate-subtle-float {
animation: subtle-float 3s ease-in-out infinite;
}
.animate-icon-breathe {
animation: icon-breathe 2.5s ease-in-out infinite;
}
.animate-border-glow {
animation: border-glow 2s ease-in-out infinite;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Provider Configuration
* Shared constants for provider branding and assets
*/
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<string, string> = {
gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png',
codex: '/assets/providers/openai.svg',
qwen: '/assets/providers/qwen-color.svg',
};
// Provider brand colors
export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4',
agy: '#f3722c',
codex: '#10a37f',
vertex: '#4285F4',
iflow: '#f94144',
qwen: '#6236FF',
};
// Provider display names
const PROVIDER_NAMES: Record<string, string> = {
gemini: 'Gemini',
agy: 'Antigravity',
codex: 'Codex',
vertex: 'Vertex AI',
iflow: 'iFlow',
qwen: 'Qwen',
};
// Map provider to display name
export function getProviderDisplayName(provider: string): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
}
+37 -15
View File
@@ -5,21 +5,38 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function getModelColor(model: string): string {
// Vibrant Tones Palette
const colors = [
'#f94144', // Strawberry Red
'#f3722c', // Pumpkin Spice
'#f8961e', // Carrot Orange
'#f9844a', // Atomic Tangerine
'#f9c74f', // Tuscan Sun
'#90be6d', // Willow Green
'#43aa8b', // Seaweed
'#4d908e', // Dark Cyan
'#577590', // Blue Slate
'#277da1', // Cerulean
];
// Vibrant Tones Palette
const VIBRANT_TONES = [
'#f94144', // Strawberry Red
'#f3722c', // Pumpkin Spice
'#f8961e', // Carrot Orange
'#f9844a', // Atomic Tangerine
'#f9c74f', // Tuscan Sun
'#90be6d', // Willow Green
'#43aa8b', // Seaweed
'#4d908e', // Dark Cyan
'#577590', // Blue Slate
'#277da1', // Cerulean
];
// Provider color mapping (fixed colors for consistency)
const PROVIDER_COLORS: Record<string, string> = {
agy: '#f3722c', // Pumpkin
gemini: '#277da1', // Cerulean
codex: '#f8961e', // Carrot
vertex: '#577590', // Blue Slate
iflow: '#f94144', // Strawberry
qwen: '#f9c74f', // Tuscan
};
// Status colors (from Analytics Cost breakdown) - darker for light theme contrast
export const STATUS_COLORS = {
success: '#15803d', // Green-700 (was Seaweed #43aa8b)
degraded: '#b45309', // Amber-700 (was Ochre #e09f3e)
failed: '#b91c1c', // Red-700 (was Merlot #9e2a2b)
} as const;
export function getModelColor(model: string): string {
// FNV-1a hash algorithm
let hash = 0x811c9dc5;
for (let i = 0; i < model.length; i++) {
@@ -28,5 +45,10 @@ export function getModelColor(model: string): string {
}
// Ensure positive index
return colors[(hash >>> 0) % colors.length];
return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length];
}
export function getProviderColor(provider: string): string {
const normalized = provider.toLowerCase();
return PROVIDER_COLORS[normalized] || getModelColor(provider);
}
+125 -149
View File
@@ -1,25 +1,13 @@
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 { AuthMonitor } from '@/components/auth-monitor';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Key,
Zap,
Users,
Activity,
Plus,
Stethoscope,
BookOpen,
FolderOpen,
AlertTriangle,
ArrowRight,
} from 'lucide-react';
import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react';
import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared';
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
const HEALTH_VARIANTS = {
ok: 'success',
@@ -27,6 +15,51 @@ const HEALTH_VARIANTS = {
error: 'error',
} as const;
type StatVariant = 'default' | 'success' | 'warning' | 'error' | 'accent';
const variantStyles: Record<StatVariant, { iconBg: string; iconColor: string }> = {
default: { iconBg: 'bg-muted', iconColor: 'text-muted-foreground' },
success: { iconBg: 'bg-green-600/15', iconColor: 'text-green-700 dark:text-green-500' },
warning: { iconBg: 'bg-amber-500/15', iconColor: 'text-amber-700 dark:text-amber-400' },
error: { iconBg: 'bg-red-600/15', iconColor: 'text-red-700 dark:text-red-500' },
accent: { iconBg: 'bg-accent/15', iconColor: 'text-accent' },
};
function InlineStat({
title,
value,
icon: Icon,
variant = 'default',
onClick,
}: {
title: string;
value: number | string;
icon: LucideIcon;
variant?: StatVariant;
onClick?: () => void;
}) {
const styles = variantStyles[variant];
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-lg border bg-card/50',
'transition-all hover:bg-card hover:shadow-sm hover:-translate-y-0.5',
'active:scale-[0.98]'
)}
>
<div className={cn('flex items-center justify-center w-9 h-9 rounded-md', styles.iconBg)}>
<Icon className={cn('w-4 h-4', styles.iconColor)} />
</div>
<div className="text-left">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{title}</p>
<p className={cn('text-lg font-bold font-mono leading-tight', styles.iconColor)}>{value}</p>
</div>
</button>
);
}
export function HomePage() {
const navigate = useNavigate();
const { data: overview, isLoading: isOverviewLoading } = useOverview();
@@ -35,8 +68,8 @@ export function HomePage() {
if (isOverviewLoading || isSharedLoading) {
return (
<div className="p-6 space-y-6">
{/* Hero Skeleton */}
<div className="rounded-xl border p-6">
{/* Hero Row Skeleton */}
<div className="rounded-xl border p-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div>
@@ -44,42 +77,31 @@ export function HomePage() {
<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-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>
{/* 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>
{/* 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">
<div className="flex items-center gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-14 rounded-lg" />
<Skeleton key={i} className="h-14 w-28 rounded-lg" />
))}
</div>
</div>
{/* Auth Monitor Skeleton */}
<div className="border rounded-xl overflow-hidden">
<div className="px-4 py-2.5 border-b flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
<div className="px-4 py-3 border-b">
<Skeleton className="h-2 w-full rounded-full" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="px-4 py-2.5 flex items-center gap-3 border-b last:border-b-0">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-1.5 w-24 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}
@@ -90,13 +112,57 @@ export function HomePage() {
return (
<div className="p-6 space-y-6">
{/* Hero Section */}
<HeroSection
version={overview?.version}
healthStatus={overview?.health?.status}
healthPassed={overview?.health?.passed}
healthTotal={overview?.health?.total}
/>
{/* Hero Row: Logo/Title + Inline Stats */}
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30">
{/* Subtle background pattern */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
backgroundSize: '24px 24px',
}}
/>
</div>
{/* Single Row Layout */}
<div className="relative p-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
{/* Left: Logo + Title */}
<HeroSection version={overview?.version} />
{/* Right: Inline Stats */}
<div className="flex flex-wrap items-center gap-3">
<InlineStat
title="Profiles"
value={overview?.profiles ?? 0}
icon={Key}
variant="accent"
onClick={() => navigate('/api')}
/>
<InlineStat
title="CLIProxy"
value={overview?.cliproxy ?? 0}
icon={Zap}
variant="accent"
onClick={() => navigate('/cliproxy')}
/>
<InlineStat
title="Accounts"
value={overview?.accounts ?? 0}
icon={Users}
variant="default"
onClick={() => navigate('/accounts')}
/>
<InlineStat
title="Health"
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity}
variant={healthVariant}
onClick={() => navigate('/health')}
/>
</div>
</div>
</div>
{/* Configuration Warning */}
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
@@ -107,98 +173,8 @@ export function HomePage() {
</Alert>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="API Profiles"
value={overview?.profiles ?? 0}
icon={Key}
variant="accent"
subtitle="Settings-based"
onClick={() => navigate('/api')}
/>
<StatCard
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}
variant={healthVariant}
subtitle="System checks"
onClick={() => navigate('/health')}
/>
</div>
{/* Quick Actions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button onClick={() => navigate('/api')} className="gap-2">
<Plus className="w-4 h-4" /> New Profile
</Button>
<Button variant="outline" onClick={() => navigate('/health')} className="gap-2">
<Stethoscope className="w-4 h-4" /> Run Doctor
</Button>
<Button variant="outline" asChild className="gap-2">
<a href="https://docs.ccs.kaitran.ca" target="_blank" rel="noopener noreferrer">
<BookOpen className="w-4 h-4" /> Documentation
</a>
</Button>
</CardContent>
</Card>
{/* Quick Commands */}
<QuickCommands />
{/* Shared Data Summary */}
<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 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 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 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>
</CardContent>
</Card>
{/* Auth Monitor */}
<AuthMonitor />
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
-1
View File
@@ -5,7 +5,6 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */