mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 14:22:06 +00:00
feat(ui): implement auth monitor components & pages
This commit is contained in:
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ProviderIcon } from '@/components/provider-icon';
|
||||||
|
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||||
|
import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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[]>([]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
// Destination point (left center of provider card)
|
||||||
|
const destX = destRect.left - svgRect.left;
|
||||||
|
const destY = destRect.top + destRect.height / 2 - svgRect.top;
|
||||||
|
|
||||||
|
const newPaths: string[] = [];
|
||||||
|
|
||||||
|
accounts.forEach((_, i) => {
|
||||||
|
const sourceEl = container.querySelector(`[data-account-index="${i}"]`);
|
||||||
|
if (!sourceEl) return;
|
||||||
|
const sourceRect = sourceEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Source point (right center of account card)
|
||||||
|
const startX = sourceRect.right - svgRect.left;
|
||||||
|
const startY = sourceRect.top + sourceRect.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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={containerRef}>
|
||||||
|
{/* Back button */}
|
||||||
|
{onBack && (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="absolute top-0 left-0 z-20 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 */}
|
||||||
|
<div className="min-h-[320px] relative flex items-center justify-between px-8 py-8 pt-12">
|
||||||
|
{/* 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Left Column: Source Accounts */}
|
||||||
|
<div className="flex flex-col gap-3 z-10 w-56">
|
||||||
|
{accounts.map((account, i) => {
|
||||||
|
const total = account.successCount + account.failureCount;
|
||||||
|
const isHovered = hoveredAccount === i;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
data-account-index={i}
|
||||||
|
onClick={() => setSelectedAccount(account)}
|
||||||
|
onMouseEnter={() => setHoveredAccount(i)}
|
||||||
|
onMouseLeave={() => setHoveredAccount(null)}
|
||||||
|
className={cn(
|
||||||
|
'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300',
|
||||||
|
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||||
|
'border border-border/50 dark:border-white/[0.08]',
|
||||||
|
'border-l-2 hover:translate-x-1',
|
||||||
|
isHovered && 'bg-muted/50 dark:bg-zinc-800/60'
|
||||||
|
)}
|
||||||
|
style={{ borderLeftColor: account.color }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[140px]">
|
||||||
|
{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 */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Right Column: Destination Provider */}
|
||||||
|
<div className="z-10 w-56 flex justify-end items-center">
|
||||||
|
<div
|
||||||
|
data-provider-node
|
||||||
|
className={cn(
|
||||||
|
'group relative w-full rounded-xl p-4 cursor-pointer transition-all duration-300',
|
||||||
|
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||||
|
'border border-border/50 dark:border-white/[0.08]',
|
||||||
|
hoveredAccount !== null && 'scale-[1.02]'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
borderColor: hoveredAccount !== null ? `${providerColor}50` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<ProviderIcon provider={providerData.provider} size={36} withBackground />
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Panel (Bottom slide-up) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-0 inset-x-0 bg-card dark:bg-zinc-950 border-t border-border',
|
||||||
|
'transform transition-transform duration-300 z-30',
|
||||||
|
selectedAccount ? 'translate-y-0' : 'translate-y-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedAccount(null)}
|
||||||
|
className="absolute top-3 right-3 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-500" />
|
||||||
|
<span>SUCCESSFUL</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-mono 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-500" />
|
||||||
|
<span>FAILED</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-mono 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from 'react';
|
||||||
|
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
|
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 } 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
|
||||||
|
const ACCOUNT_COLORS = [
|
||||||
|
'#277da1', // Cerulean
|
||||||
|
'#43aa8b', // Seaweed
|
||||||
|
'#f9c74f', // Tuscan Sun
|
||||||
|
'#f94144', // Strawberry
|
||||||
|
'#f3722c', // Pumpkin
|
||||||
|
'#90be6d', // Willow
|
||||||
|
'#577590', // Blue Slate
|
||||||
|
'#f8961e', // Carrot
|
||||||
|
'#4d908e', // Dark Cyan
|
||||||
|
'#a78bfa', // Purple
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AuthMonitor() {
|
||||||
|
const { data, isLoading, error } = useCliproxyAuth();
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||||
|
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
// Mock stats - in production, fetch from CLIProxy /usage endpoint
|
||||||
|
const success = Math.floor(Math.random() * 2000) + 100;
|
||||||
|
const failure = account.isDefault ? Math.floor(Math.random() * 50) : 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: 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]);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/30 dark:bg-zinc-900/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full animate-pulse"
|
||||||
|
style={{ background: STATUS_COLORS.success }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium tracking-tight text-muted-foreground uppercase">
|
||||||
|
Live Stream
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<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 min-h-[320px] 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">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Requests</span>
|
||||||
|
<span className="font-mono text-foreground">
|
||||||
|
{ps.totalRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,85 +1,22 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CcsLogo } from '@/components/ccs-logo';
|
import { CcsLogo } from '@/components/ccs-logo';
|
||||||
import { CheckCircle2, AlertCircle, XCircle } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
version?: string;
|
version?: string;
|
||||||
healthStatus?: 'ok' | 'warning' | 'error';
|
|
||||||
healthPassed?: number;
|
|
||||||
healthTotal?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
export function HeroSection({ version = '5.0.0' }: HeroSectionProps) {
|
||||||
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 (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30 p-6">
|
<div className="flex items-center gap-4">
|
||||||
{/* Subtle background pattern */}
|
<CcsLogo size="lg" showText={false} />
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
<div>
|
||||||
<div
|
<div className="flex items-center gap-3">
|
||||||
className="absolute inset-0"
|
<h1 className="text-2xl font-bold">CCS Config</h1>
|
||||||
style={{
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
backgroundImage: `radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)`,
|
v{version}
|
||||||
backgroundSize: '24px 24px',
|
</Badge>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">Claude Code Switch Dashboard</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -5,21 +5,38 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getModelColor(model: string): string {
|
// Vibrant Tones Palette
|
||||||
// Vibrant Tones Palette
|
const VIBRANT_TONES = [
|
||||||
const colors = [
|
'#f94144', // Strawberry Red
|
||||||
'#f94144', // Strawberry Red
|
'#f3722c', // Pumpkin Spice
|
||||||
'#f3722c', // Pumpkin Spice
|
'#f8961e', // Carrot Orange
|
||||||
'#f8961e', // Carrot Orange
|
'#f9844a', // Atomic Tangerine
|
||||||
'#f9844a', // Atomic Tangerine
|
'#f9c74f', // Tuscan Sun
|
||||||
'#f9c74f', // Tuscan Sun
|
'#90be6d', // Willow Green
|
||||||
'#90be6d', // Willow Green
|
'#43aa8b', // Seaweed
|
||||||
'#43aa8b', // Seaweed
|
'#4d908e', // Dark Cyan
|
||||||
'#4d908e', // Dark Cyan
|
'#577590', // Blue Slate
|
||||||
'#577590', // Blue Slate
|
'#277da1', // Cerulean
|
||||||
'#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)
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
success: '#43aa8b', // Seaweed
|
||||||
|
degraded: '#e09f3e', // Ochre
|
||||||
|
failed: '#9e2a2b', // Merlot
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function getModelColor(model: string): string {
|
||||||
// FNV-1a hash algorithm
|
// FNV-1a hash algorithm
|
||||||
let hash = 0x811c9dc5;
|
let hash = 0x811c9dc5;
|
||||||
for (let i = 0; i < model.length; i++) {
|
for (let i = 0; i < model.length; i++) {
|
||||||
@@ -28,5 +45,10 @@ export function getModelColor(model: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure positive index
|
// 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
@@ -1,25 +1,13 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
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 { 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react';
|
||||||
Key,
|
|
||||||
Zap,
|
|
||||||
Users,
|
|
||||||
Activity,
|
|
||||||
Plus,
|
|
||||||
Stethoscope,
|
|
||||||
BookOpen,
|
|
||||||
FolderOpen,
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowRight,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useOverview } from '@/hooks/use-overview';
|
import { useOverview } from '@/hooks/use-overview';
|
||||||
import { useSharedSummary } from '@/hooks/use-shared';
|
import { useSharedSummary } from '@/hooks/use-shared';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
const HEALTH_VARIANTS = {
|
const HEALTH_VARIANTS = {
|
||||||
ok: 'success',
|
ok: 'success',
|
||||||
@@ -27,6 +15,51 @@ const HEALTH_VARIANTS = {
|
|||||||
error: 'error',
|
error: 'error',
|
||||||
} as const;
|
} 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-500/10', iconColor: 'text-green-600' },
|
||||||
|
warning: { iconBg: 'bg-yellow-500/10', iconColor: 'text-yellow-500' },
|
||||||
|
error: { iconBg: 'bg-red-500/10', iconColor: 'text-red-500' },
|
||||||
|
accent: { iconBg: 'bg-accent/10', 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() {
|
export function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: overview, isLoading: isOverviewLoading } = useOverview();
|
const { data: overview, isLoading: isOverviewLoading } = useOverview();
|
||||||
@@ -35,8 +68,8 @@ export function HomePage() {
|
|||||||
if (isOverviewLoading || isSharedLoading) {
|
if (isOverviewLoading || isSharedLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Hero Skeleton */}
|
{/* Hero Row Skeleton */}
|
||||||
<div className="rounded-xl border p-6">
|
<div className="rounded-xl border p-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||||
<div>
|
<div>
|
||||||
@@ -44,42 +77,31 @@ export function HomePage() {
|
|||||||
<Skeleton className="h-4 w-[220px]" />
|
<Skeleton className="h-4 w-[220px]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,13 +112,57 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Hero Section */}
|
{/* Hero Row: Logo/Title + Inline Stats */}
|
||||||
<HeroSection
|
<div className="relative overflow-hidden rounded-xl border bg-gradient-to-br from-background via-background to-muted/30">
|
||||||
version={overview?.version}
|
{/* Subtle background pattern */}
|
||||||
healthStatus={overview?.health?.status}
|
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
|
||||||
healthPassed={overview?.health?.passed}
|
<div
|
||||||
healthTotal={overview?.health?.total}
|
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 */}
|
{/* Configuration Warning */}
|
||||||
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
|
||||||
@@ -107,98 +173,8 @@ export function HomePage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Auth Monitor */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<AuthMonitor />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user