feat(ui): implement auth monitor components & pages

This commit is contained in:
kaitranntt
2025-12-14 04:26:02 -05:00
parent 257af14293
commit b97c3bfda4
7 changed files with 1041 additions and 237 deletions
+350
View File
@@ -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>
);
}
+387
View File
@@ -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>
);
}
+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>
);
+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>
);
}
+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)
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
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-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() {
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>
);
}