feat(monitor): Enhance auth monitor and account flow visualization

This commit is contained in:
kaitranntt
2025-12-16 06:45:38 -05:00
parent 3216a0e847
commit 994bd7765a
4 changed files with 509 additions and 107 deletions
+345 -86
View File
@@ -9,7 +9,21 @@ import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/provider-icon';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { STATUS_COLORS } from '@/lib/utils';
import { ChevronRight, X, CheckCircle2, XCircle, Clock, Activity } from 'lucide-react';
import {
ChevronRight,
X,
CheckCircle2,
XCircle,
Clock,
Activity,
GripVertical,
} from 'lucide-react';
/** Position offset for draggable cards */
interface DragOffset {
x: number;
y: number;
}
interface AccountData {
id: string;
@@ -203,10 +217,48 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null);
const [paths, setPaths] = useState<string[]>([]);
// Drag state for all cards (account IDs + 'provider')
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
const [draggingId, setDraggingId] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
);
// Pulse state: account IDs that are currently pulsing
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
// Store previous counts to detect changes
const [prevCounts, setPrevCounts] = useState<Record<string, number>>({});
const { accounts } = providerData;
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1);
const totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
// Detect new activity and trigger pulse (runs when accounts data changes)
useEffect(() => {
const newPulsing = new Set<string>();
const newCounts: Record<string, number> = {};
accounts.forEach((account) => {
const currentCount = account.successCount + account.failureCount;
newCounts[account.id] = currentCount;
const prev = prevCounts[account.id] ?? 0;
if (currentCount > prev && prev > 0) {
newPulsing.add(account.id);
}
});
setPrevCounts(newCounts);
if (newPulsing.size > 0) {
setPulsingAccounts(newPulsing);
// Clear pulse after animation
const timer = setTimeout(() => setPulsingAccounts(new Set()), 600);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accounts]);
// Generate connection events for timeline
const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]);
@@ -222,10 +274,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
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) => {
@@ -233,9 +281,24 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
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;
// Determine if this account is on the right side
const isRightSide = sourceEl.hasAttribute('data-right-side');
let startX: number, startY: number, destX: number, destY: number;
if (isRightSide) {
// Right side account: connect from left edge to right edge of provider
startX = sourceRect.left - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.right - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
} else {
// Left side account: connect from right edge to left edge of provider
startX = sourceRect.right - svgRect.left;
startY = sourceRect.top + sourceRect.height / 2 - svgRect.top;
destX = destRect.left - svgRect.left;
destY = destRect.top + destRect.height / 2 - svgRect.top;
}
// Bezier control points
const cp1X = startX + (destX - startX) * 0.5;
@@ -261,6 +324,60 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
// Split accounts into left and right groups (when > 1 account, balance both sides)
const { leftAccounts, rightAccounts } = useMemo(() => {
if (accounts.length <= 1) {
return { leftAccounts: accounts, rightAccounts: [] };
}
const mid = Math.ceil(accounts.length / 2);
return {
leftAccounts: accounts.slice(0, mid),
rightAccounts: accounts.slice(mid),
};
}, [accounts]);
const hasRightAccounts = rightAccounts.length > 0;
// Drag handlers
const handlePointerDown = useCallback(
(id: string, e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const offset = dragOffsets[id] || { x: 0, y: 0 };
dragStartRef.current = { x: e.clientX, y: e.clientY, offsetX: offset.x, offsetY: offset.y };
setDraggingId(id);
},
[dragOffsets]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingId || !dragStartRef.current) return;
const start = dragStartRef.current;
const dx = e.clientX - start.x;
const dy = e.clientY - start.y;
setDragOffsets((prev) => ({
...prev,
[draggingId]: {
x: start.offsetX + dx,
y: start.offsetY + dy,
},
}));
// Recalculate paths during drag
requestAnimationFrame(calculatePaths);
},
[draggingId, calculatePaths]
);
const handlePointerUp = useCallback(() => {
setDraggingId(null);
dragStartRef.current = null;
}, []);
// Get offset for a card
const getOffset = (id: string): DragOffset => dragOffsets[id] || { x: 0, y: 0 };
return (
<div className="flex flex-col" ref={containerRef}>
{/* Back button */}
@@ -274,10 +391,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
</button>
)}
{/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */}
{/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */}
<div className="min-h-[320px] flex gap-4 px-4 py-6">
{/* Left Section: Account → Provider Flow - expands to fill available space */}
<div className="relative flex-1 flex items-center justify-between px-8">
{/* Flow visualization section */}
<div className="relative flex-1 flex items-center justify-between px-4">
{/* SVG Canvas (Background) */}
<svg
ref={svgRef}
@@ -295,47 +412,75 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
const isHovered = hoveredAccount === i;
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
const isPulsing = pulsingAccounts.has(account.id);
return (
<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"
/>
<g key={i}>
{/* Base path - static connection line */}
<path
d={d}
fill="none"
stroke={account.color}
strokeWidth={strokeWidth}
strokeOpacity={isHovered ? 0.9 : isDimmed ? 0.05 : 0.2}
strokeLinecap="round"
filter={isHovered ? 'url(#flow-glow)' : undefined}
className="transition-all duration-300"
/>
{/* Pulse layer - only shows when new activity detected */}
{isPulsing && (
<path
d={d}
fill="none"
stroke={account.color}
strokeWidth={strokeWidth * 1.5}
strokeLinecap="round"
filter="url(#flow-glow)"
className="animate-request-pulse"
/>
)}
</g>
);
})}
</svg>
{/* Source Accounts */}
<div className="flex flex-col gap-3 z-10 w-48 justify-center">
{accounts.map((account, i) => {
{/* Left Accounts */}
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
{leftAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === i;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={i}
onClick={() => setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(i)}
data-account-index={originalIndex}
onClick={() => !isDragging && setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pr-6 cursor-pointer transition-all duration-300',
'group/card relative rounded-lg p-3 pr-6 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-l-2 hover:translate-x-1',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60'
'border-l-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{ borderLeftColor: account.color }}
style={{
borderLeftColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
<div className="flex justify-between items-start mb-1">
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[120px]">
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 ml-3">
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
{cleanEmail(account.email)}
</span>
<ChevronRight
@@ -356,7 +501,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
</div>
</div>
{/* Connector Dot */}
{/* Connector Dot - Right side */}
<div
className={cn(
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
@@ -369,64 +514,178 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
})}
</div>
{/* Destination Provider */}
<div className="z-10 w-48 flex 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)`,
}}
/>
{/* Center Provider */}
<div className="z-10 w-52 flex items-center flex-shrink-0">
{(() => {
const isDragging = draggingId === 'provider';
const offset = getOffset('provider');
return (
<div
data-provider-node
onPointerDown={(e) => handlePointerDown('provider', e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group relative w-full rounded-xl p-4 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border-2 border-border/50 dark:border-white/[0.08]',
// Idle animations: float + border glow (disabled when dragging)
!isDragging && 'animate-subtle-float animate-border-glow',
'select-none touch-none',
hoveredAccount !== null && 'scale-[1.02]',
isDragging && 'cursor-grabbing shadow-2xl scale-105 z-50'
)}
style={
{
'--glow-color': `${providerColor}60`,
borderColor: hoveredAccount !== null ? `${providerColor}80` : undefined,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
} as React.CSSProperties
}
>
{/* Drag handle */}
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<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">
{/* Animated glow background */}
<div
className="h-full rounded-full transition-all duration-500"
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
/>
{/* Left Connector Point */}
<div
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
{/* Right Connector Point - only show if there are right accounts */}
{hasRightAccounts && (
<div
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
style={{
backgroundColor: providerColor,
boxShadow: `0 0 0 4px var(--background)`,
}}
/>
)}
<div className="flex items-center gap-3 mb-4 relative z-10">
{/* Provider icon with breathing animation */}
<div className="animate-icon-breathe">
<ProviderIcon provider={providerData.provider} size={36} withBackground />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName}
</h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">
Provider
</p>
</div>
</div>
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span>
<span className="text-foreground font-mono">
{totalRequests.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span>
<span className="text-foreground font-mono">{accounts.length}</span>
</div>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
backgroundColor: providerColor,
}}
/>
</div>
</div>
</div>
</div>
</div>
);
})()}
</div>
{/* Right Accounts */}
{hasRightAccounts && (
<div className="flex flex-col gap-3 z-10 w-44 justify-center">
{rightAccounts.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === originalIndex;
const isDragging = draggingId === account.id;
const offset = getOffset(account.id);
return (
<div
key={account.id}
data-account-index={originalIndex}
data-right-side
onClick={() => !isDragging && setSelectedAccount(account)}
onMouseEnter={() => setHoveredAccount(originalIndex)}
onMouseLeave={() => setHoveredAccount(null)}
onPointerDown={(e) => handlePointerDown(account.id, e)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
className={cn(
'group/card relative rounded-lg p-3 pl-6 cursor-grab transition-shadow duration-200',
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
'border border-border/50 dark:border-white/[0.08]',
'border-r-2 select-none touch-none',
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
)}
style={{
borderRightColor: account.color,
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Drag handle indicator */}
<GripVertical className="absolute top-1/2 right-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
<div className="flex justify-between items-start mb-1 mr-3">
<ChevronRight
className={cn(
'w-3.5 h-3.5 text-muted-foreground transition-opacity rotate-180',
isHovered ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
{cleanEmail(account.email)}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
{account.failureCount > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
)}
</div>
<span className="text-[10px] text-muted-foreground font-mono">
{total.toLocaleString()} reqs
</span>
</div>
{/* Connector Dot - Left side */}
<div
className={cn(
'absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
isHovered && 'bg-foreground dark:bg-white border-transparent'
)}
/>
</div>
);
})}
</div>
)}
</div>
{/* Right Section: Connection Timeline - Fixed compact width */}
+85 -19
View File
@@ -4,7 +4,7 @@
* Uses glass panel aesthetic with hover interactions and glow effects
*/
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats';
import { cn, STATUS_COLORS } from '@/lib/utils';
@@ -13,7 +13,7 @@ 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';
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
interface AccountRow {
id: string;
@@ -62,11 +62,77 @@ const ACCOUNT_COLORS = [
'#a78bfa', // Purple
];
/** Enhanced live pulse indicator with multi-ring animation */
function LivePulse() {
return (
<div className="relative flex items-center justify-center w-5 h-5">
{/* Outer ping ring */}
<div
className="absolute w-4 h-4 rounded-full animate-ping opacity-20"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Middle pulse ring */}
<div
className="absolute w-3 h-3 rounded-full animate-pulse opacity-40"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
{/* Inner solid dot */}
<div
className="relative w-2 h-2 rounded-full z-10"
style={{ backgroundColor: STATUS_COLORS.success }}
/>
</div>
);
}
/** Inline success/failure badge for provider cards */
function InlineStatsBadge({ success, failure }: { success: number; failure: number }) {
if (success === 0 && failure === 0) {
return <span className="text-[9px] text-muted-foreground/50 font-mono">no activity</span>;
}
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5">
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
<span className="text-[10px] font-mono font-medium text-emerald-500">
{success.toLocaleString()}
</span>
</div>
{failure > 0 && (
<div className="flex items-center gap-0.5">
<XCircle className="w-3 h-3 text-red-500" />
<span className="text-[10px] font-mono font-medium text-red-500">
{failure.toLocaleString()}
</span>
</div>
)}
</div>
);
}
export function AuthMonitor() {
const { data, isLoading, error } = useCliproxyAuth();
const { data: statsData, isLoading: statsLoading } = useCliproxyStats();
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
// Live countdown showing time since last data update
useEffect(() => {
if (!dataUpdatedAt) return;
const updateTime = () => {
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
if (diff < 60) {
setTimeSinceUpdate(`${diff}s ago`);
} else {
setTimeSinceUpdate(`${Math.floor(diff / 60)}m ago`);
}
};
updateTime();
const interval = setInterval(updateTime, 1000);
return () => clearInterval(interval);
}, [dataUpdatedAt]);
// Build a map of account email -> usage stats from CLIProxy
const accountStatsMap = useMemo(() => {
@@ -189,18 +255,19 @@ export function AuthMonitor() {
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>
{/* Enhanced Live Header with gradient glow */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-gradient-to-r from-emerald-500/5 via-transparent to-transparent dark:from-emerald-500/10">
<div className="flex items-center gap-2">
<LivePulse />
<span className="text-xs font-semibold tracking-tight text-foreground">LIVE</span>
<span className="text-[10px] text-muted-foreground">Account Monitor</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-4 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Radio className="w-3 h-3 animate-pulse" />
<span>Updated {timeSinceUpdate || 'now'}</span>
</div>
<span className="text-muted-foreground/50">|</span>
<span>{accounts.length} accounts</span>
<span className="font-mono">{totalRequests.toLocaleString()} req</span>
</div>
@@ -299,11 +366,10 @@ export function AuthMonitor() {
</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>
{/* Inline success/failure stats - immediately visible */}
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Stats</span>
<InlineStatsBadge success={ps.successCount} failure={ps.failureCount} />
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Success Rate</span>
+2 -2
View File
@@ -85,9 +85,9 @@ export function useCliproxyStats(enabled = true) {
queryKey: ['cliproxy-stats'],
queryFn: fetchCliproxyStats,
enabled,
refetchInterval: 30000, // Refresh every 30 seconds
refetchInterval: 5000, // Refresh every 5 seconds for near-real-time updates
retry: 1,
staleTime: 10000, // Consider data stale after 10 seconds
staleTime: 3000, // Consider data stale after 3 seconds
});
}
+77
View File
@@ -199,3 +199,80 @@
.zoom-in-95 {
animation-name: zoom-in;
}
/* Flow visualization animations */
@keyframes request-pulse {
0% {
stroke-opacity: 0.9;
stroke-width: inherit;
}
50% {
stroke-opacity: 0.6;
}
100% {
stroke-opacity: 0;
stroke-width: calc(inherit * 2);
}
}
@keyframes glow-pulse {
0%,
100% {
box-shadow: 0 0 0 0 var(--glow-color, rgba(16, 185, 129, 0.2));
}
50% {
box-shadow: 0 0 20px 4px var(--glow-color, rgba(16, 185, 129, 0.3));
}
}
@keyframes subtle-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes icon-breathe {
0%,
100% {
transform: scale(1);
opacity: 0.9;
}
50% {
transform: scale(1.05);
opacity: 1;
}
}
@keyframes border-glow {
0%,
100% {
border-color: var(--glow-color, rgba(16, 185, 129, 0.2));
}
50% {
border-color: var(--glow-color, rgba(16, 185, 129, 0.5));
}
}
.animate-request-pulse {
animation: request-pulse 0.6s ease-out forwards;
}
.animate-glow-pulse {
animation: glow-pulse 2s ease-in-out infinite;
}
.animate-subtle-float {
animation: subtle-float 3s ease-in-out infinite;
}
.animate-icon-breathe {
animation: icon-breathe 2.5s ease-in-out infinite;
}
.animate-border-glow {
animation: border-glow 2s ease-in-out infinite;
}