mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 02:16:43 +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 { 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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user