diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index f12fb059..59339640 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -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(null); const [paths, setPaths] = useState([]); + // Drag state for all cards (account IDs + 'provider') + const [dragOffsets, setDragOffsets] = useState>({}); + const [draggingId, setDraggingId] = useState(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>(new Set()); + // Store previous counts to detect changes + const [prevCounts, setPrevCounts] = useState>({}); + 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(); + const newCounts: Record = {}; + + 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 (
{/* Back button */} @@ -274,10 +391,10 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { )} - {/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */} + {/* Main visualization area - 3 column layout: Left Accounts | Provider | Right Accounts + Timeline */}
- {/* Left Section: Account → Provider Flow - expands to fill available space */} -
+ {/* Flow visualization section */} +
{/* SVG Canvas (Background) */} + + {/* Base path - static connection line */} + + {/* Pulse layer - only shows when new activity detected */} + {isPulsing && ( + + )} + ); })} - {/* Source Accounts */} -
- {accounts.map((account, i) => { + {/* Left Accounts */} +
+ {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 (
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)' : ''}`, + }} > -
- + {/* Drag handle indicator */} + +
+ {cleanEmail(account.email)}
- {/* Connector Dot */} + {/* Connector Dot - Right side */}
- {/* Destination Provider */} -
-
- {/* Connector Point */} -
+ {/* Center Provider */} +
+ {(() => { + const isDragging = draggingId === 'provider'; + const offset = getOffset('provider'); + return ( +
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 */} + -
- -
-

- {providerData.displayName} -

-

- Provider -

-
-
- -
-
- Total Requests - - {totalRequests.toLocaleString()} - -
-
- Accounts - {accounts.length} -
-
+ {/* Animated glow background */}
+ + {/* Left Connector Point */} +
+ + {/* Right Connector Point - only show if there are right accounts */} + {hasRightAccounts && ( +
+ )} + +
+ {/* Provider icon with breathing animation */} +
+ +
+
+

+ {providerData.displayName} +

+

+ Provider +

+
+
+ +
+
+ Total Requests + + {totalRequests.toLocaleString()} + +
+
+ Accounts + {accounts.length} +
+
+
+
+
-
-
+ ); + })()}
+ + {/* Right Accounts */} + {hasRightAccounts && ( +
+ {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 ( +
!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 */} + +
+ + + {cleanEmail(account.email)} + +
+
+
+
+ {account.failureCount > 0 && ( +
+ )} +
+ + {total.toLocaleString()} reqs + +
+ {/* Connector Dot - Left side */} +
+
+ ); + })} +
+ )}
{/* Right Section: Connection Timeline - Fixed compact width */} diff --git a/ui/src/components/auth-monitor.tsx b/ui/src/components/auth-monitor.tsx index ae17e7c7..cdac5e5c 100644 --- a/ui/src/components/auth-monitor.tsx +++ b/ui/src/components/auth-monitor.tsx @@ -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 ( +
+ {/* Outer ping ring */} +
+ {/* Middle pulse ring */} +
+ {/* Inner solid dot */} +
+
+ ); +} + +/** Inline success/failure badge for provider cards */ +function InlineStatsBadge({ success, failure }: { success: number; failure: number }) { + if (success === 0 && failure === 0) { + return no activity; + } + + return ( +
+
+ + + {success.toLocaleString()} + +
+ {failure > 0 && ( +
+ + + {failure.toLocaleString()} + +
+ )} +
+ ); +} + 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(null); const [hoveredProvider, setHoveredProvider] = useState(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 (
- {/* Header */} -
-
-
- - Live Stream - + {/* Enhanced Live Header with gradient glow */} +
+
+ + LIVE + Account Monitor
-
+
+
+ + Updated {timeSinceUpdate || 'now'} +
+ | {accounts.length} accounts {totalRequests.toLocaleString()} req
@@ -299,11 +366,10 @@ export function AuthMonitor() {
-
- Requests - - {ps.totalRequests.toLocaleString()} - + {/* Inline success/failure stats - immediately visible */} +
+ Stats +
Success Rate diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index d930277d..3d27c7a9 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -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 }); } diff --git a/ui/src/index.css b/ui/src/index.css index 664ae9c1..ffac8b61 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; +}