diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index c707e212..daa07b65 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -1,1144 +1,12 @@ /** * 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 + * Re-exports from modularized location for backward compatibility */ -import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; -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 { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { - ChevronRight, - CheckCircle2, - XCircle, - Activity, - GripVertical, - Eye, - EyeOff, - RotateCcw, -} from 'lucide-react'; - -// Maximum events to display in the Connection Timeline to prevent performance issues -const MAX_TIMELINE_EVENTS = 100; - -// Earthy, sophisticated color palette for connection lines - works in both light/dark themes -const CONNECTION_COLORS = [ - '#3b3c36', // Charcoal Brown - urban mystery - '#568203', // Forest Moss - woodland depth - '#8d4557', // Vintage Berry - timeless elegance - '#da9100', // Harvest Gold - sun-drenched warmth - '#3c6c82', // Blue Slate - cool authority - '#c96907', // Burnt Caramel - earthy comfort -]; - -/** Get a muted connection color based on index */ -function getConnectionColor(index: number): string { - return CONNECTION_COLORS[index % CONNECTION_COLORS.length]; -} - -/** Position offset for draggable cards */ -interface DragOffset { - x: number; - y: number; -} - -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; -} - -interface ConnectionEvent { - id: string; - timestamp: Date; - accountEmail: string; - status: 'success' | 'failed' | 'pending'; - latencyMs?: number; -} - -/** Generate connection events from real account data */ -function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { - const events: ConnectionEvent[] = []; - - accounts.forEach((account) => { - const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); - - // Helper to add events - const addEvents = (count: number, status: 'success' | 'failed') => { - for (let i = 0; i < count; i++) { - // Simulate timestamps: - // - Distribute events over a 24-hour window relative to lastUsed - // - Add random jitter so events from different accounts mix - const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1))); - const timestamp = new Date(lastUsed.getTime() - timeOffset); - - // Add small random jitter (±5 mins) to avoid exact overlaps - const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000); - timestamp.setTime(timestamp.getTime() + jitter); - - // Sanity check: don't go into the future relative to "now" - const now = new Date(); - if (timestamp > now) timestamp.setTime(now.getTime()); - - events.push({ - id: `${account.id}-${status}-${i}`, - timestamp, - accountEmail: account.email, - status, - // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms) - latencyMs: - status === 'success' - ? 50 + Math.floor(Math.random() * 150) - : 200 + Math.floor(Math.random() * 4800), - }); - } - }; - - addEvents(account.successCount, 'success'); - addEvents(account.failureCount, 'failed'); - }); - - // Sort by timestamp descending (most recent first) - return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); -} - -/** Format timestamp for timeline display */ -function formatTimelineTime(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return 'now'; - if (diffMins < 60) return `${diffMins}m`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h`; - return `${Math.floor(diffHours / 24)}d`; -} - -/** Connection Timeline Component - right sidebar panel */ -function ConnectionTimeline({ - events, - privacyMode, -}: { - events: ConnectionEvent[]; - privacyMode: boolean; -}) { - if (events.length === 0) { - return ( -
-
No recent connections
-
- ); - } - - return ( -
- {/* Header */} -
- - - Connection Timeline - -
- - {/* Timeline container */} -
-
- {/* Vertical line */} -
- - {/* Events */} -
- {events.map((event) => { - const statusColor = - event.status === 'success' - ? STATUS_COLORS.success - : event.status === 'failed' - ? STATUS_COLORS.failed - : STATUS_COLORS.degraded; - - return ( -
- {/* Timeline dot */} -
- - {/* Event content */} -
-
- - {cleanEmail(event.accountEmail)} - - - {formatTimelineTime(event.timestamp)} - -
-
- - {event.status} - - {event.latencyMs && ( - - {event.latencyMs}ms - - )} -
-
-
- ); - })} -
-
-
-
- ); -} - -/** Strip common email domains for cleaner display */ -function cleanEmail(email: string): string { - return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); -} - -/** Premium compact stats visualization for account cards */ -function AccountCardStats({ - success, - failure, - showDetails, -}: { - success: number; - failure: number; - showDetails: boolean; -}) { - const total = success + failure; - const successRate = total > 0 ? (success / total) * 100 : 100; - - return ( -
- {/* Primary Row: Success Rate & Total */} -
-
- - Success Rate - - = 90 - ? 'text-amber-500' - : 'text-red-500' - )} - > - {Math.round(successRate)}% - -
-
- - Volume - - - {total.toLocaleString()} - -
-
- - {/* Detailed Stats - Collapsible */} -
-
- - - {success} - -
-
0 - ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20' - : 'bg-muted/10 border-transparent opacity-40' - )} - > - 0 ? 'text-red-500' : 'text-muted-foreground')} - /> - 0 ? 'text-red-500' : 'text-muted-foreground' - )} - > - {failure} - -
-
-
- ); -} - -export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { - const containerRef = useRef(null); - const svgRef = useRef(null); - const [hoveredAccount, setHoveredAccount] = useState(null); - const [showDetails, setShowDetails] = useState(false); - const [paths, setPaths] = useState([]); - - // Privacy mode for demo purposes - const { privacyMode } = usePrivacy(); - - // Drag state for all cards (account IDs + 'provider') - const [draggingId, setDraggingId] = useState(null); - const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( - null - ); - const didDragRef = useRef(false); // Track if actual movement occurred (for click vs drag detection) - - // LocalStorage persistence for card positions - const storageKey = `ccs-flow-positions-${providerData.provider}`; - const loadSavedPositions = useCallback((): Record => { - try { - const saved = localStorage.getItem(storageKey); - if (saved) return JSON.parse(saved); - } catch { - // Ignore parse errors - } - return {}; - }, [storageKey]); - - const [dragOffsets, setDragOffsets] = useState>(() => - loadSavedPositions() - ); - - // Save positions to localStorage when they change - useEffect(() => { - if (Object.keys(dragOffsets).length > 0) { - localStorage.setItem(storageKey, JSON.stringify(dragOffsets)); - } - }, [dragOffsets, storageKey]); - - // Reset positions handler - const resetPositions = useCallback(() => { - setDragOffsets({}); - localStorage.removeItem(storageKey); - }, [storageKey]); - - // 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 completes (match CSS animation duration) - const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000); - return () => clearTimeout(timer); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [accounts]); - - // Generate connection events for timeline - // Limit events to prevent UI lag with high request counts - const connectionEvents = useMemo( - () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS), - [accounts] - ); - - // 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(); - - const newPaths: string[] = []; - - accounts.forEach((_, i) => { - const sourceEl = container.querySelector(`[data-account-index="${i}"]`); - if (!sourceEl) return; - const sourceRect = sourceEl.getBoundingClientRect(); - - // Determine zone from data attribute - const zone = sourceEl.getAttribute('data-zone') || 'left'; - - let startX: number, startY: number, destX: number, destY: number; - - // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit - - switch (zone) { - case 'right': - // Right side: connect from left edge of card 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; - break; - case 'top': - // Top side: connect from bottom edge of card to top edge of provider - startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; - startY = sourceRect.bottom - svgRect.top; - destX = destRect.left + destRect.width / 2 - svgRect.left; - destY = destRect.top - svgRect.top; - break; - case 'bottom': - // Bottom side: connect from top edge of card to bottom edge of provider - startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; - startY = sourceRect.top - svgRect.top; - destX = destRect.left + destRect.width / 2 - svgRect.left; - destY = destRect.bottom - svgRect.top; - break; - default: // 'left' - // Left side: connect from right edge of card 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 - adjust based on zone direction - // Note: getBoundingClientRect already includes CSS transforms, so no manual offset needed - let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number; - - if (zone === 'top' || zone === 'bottom') { - // Vertical connection - control points extend horizontally for curve - cp1X = startX; - cp1Y = startY + (destY - startY) * 0.5; - cp2X = destX; - cp2Y = destY - (destY - startY) * 0.5; - } else { - // Horizontal connection - control points extend vertically for curve - cp1X = startX + (destX - startX) * 0.5; - cp1Y = startY; - cp2X = destX - (destX - startX) * 0.5; - 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]); - - // Recalculate paths when drag offsets change (including reset) - useEffect(() => { - const timer = setTimeout(calculatePaths, 10); - return () => clearTimeout(timer); - }, [dragOffsets, calculatePaths]); - - // Animate paths when toggling details to match CSS transition - useEffect(() => { - const startTime = Date.now(); - const duration = 350; // Match transition duration (300ms) + buffer - - const animate = () => { - calculatePaths(); - if (Date.now() - startTime < duration) { - requestAnimationFrame(animate); - } - }; - - requestAnimationFrame(animate); - }, [showDetails, calculatePaths]); - - const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; - - // Split accounts into zones based on count (top/left/right/bottom) - const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = useMemo(() => { - const count = accounts.length; - // 1-2 accounts: left only - if (count <= 2) { - return { leftAccounts: accounts, rightAccounts: [], topAccounts: [], bottomAccounts: [] }; - } - // 3-4 accounts: left and right - if (count <= 4) { - const mid = Math.ceil(count / 2); - return { - leftAccounts: accounts.slice(0, mid), - rightAccounts: accounts.slice(mid), - topAccounts: [], - bottomAccounts: [], - }; - } - // 5-8 accounts: left, right, top - if (count <= 8) { - const perZone = Math.ceil(count / 3); - return { - leftAccounts: accounts.slice(0, perZone), - rightAccounts: accounts.slice(perZone, perZone * 2), - topAccounts: accounts.slice(perZone * 2), - bottomAccounts: [], - }; - } - // 9+ accounts: all four zones - const perZone = Math.ceil(count / 4); - return { - leftAccounts: accounts.slice(0, perZone), - rightAccounts: accounts.slice(perZone, perZone * 2), - topAccounts: accounts.slice(perZone * 2, perZone * 3), - bottomAccounts: accounts.slice(perZone * 3), - }; - }, [accounts]); - - const hasRightAccounts = rightAccounts.length > 0; - const hasTopAccounts = topAccounts.length > 0; - const hasBottomAccounts = bottomAccounts.length > 0; - - // Container expansion based on drag offsets (only vertical to avoid squeezing horizontal content) - const containerExpansion = useMemo(() => { - let minY = 0, - maxY = 0; - Object.values(dragOffsets).forEach((offset) => { - minY = Math.min(minY, offset.y); - maxY = Math.max(maxY, offset.y); - }); - return { - paddingTop: Math.max(0, -minY), - paddingBottom: Math.max(0, maxY), - extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2, - }; - }, [dragOffsets]); - - // Dynamic provider card size based on account count - const providerSize = useMemo(() => { - const count = accounts.length; - if (count >= 9) return 'w-64'; // 4 zones - largest - if (count >= 5) return 'w-60'; // 3 zones - if (count >= 3) return 'w-56'; // 2 zones - return 'w-52'; // 1 zone - default - }, [accounts.length]); - - // 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 }; - didDragRef.current = false; // Reset movement flag - 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; - // Track if actual movement occurred (threshold of 3px) - if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { - didDragRef.current = true; - } - 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 ( -
- {/* Header: Back button + Reset Layout */} -
- {onBack ? ( - - ) : ( -
- )} -
- - - {Object.keys(dragOffsets).length > 0 && ( - - )} -
-
- - {/* Main visualization area - Multi-zone layout */} -
- {/* Flow visualization section */} -
- {/* SVG Canvas (Background) */} - - - - - - - - {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; - const isPulsing = pulsingAccounts.has(account.id); - // Use muted connection colors for gentler appearance - const connectionColor = getConnectionColor(i); - - return ( - - {/* Base path - static connection line */} - - {/* Pulse layer - only shows when new activity detected */} - {isPulsing && ( - <> - {/* Glowing path pulse */} - - {/* Traveling dot along path */} - - - )} - - ); - })} - - - {/* Top Zone Accounts */} - {hasTopAccounts && ( -
- {topAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
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 pb-4 w-44 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-t-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderTopColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - -
- - {cleanEmail(account.email)} - -
- - {/* Connector Dot - Bottom side */} -
-
- ); - })} -
- )} - - {/* Middle Row: Left | Center Provider | Right */} -
- {/* Left Accounts */} -
- {leftAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
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-4 w-44 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 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, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - {/* Drag handle indicator */} - -
- - {cleanEmail(account.email)} - -
- - {/* Connector Dot - Right side */} -
-
- ); - })} -
- - {/* 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 */} - - - {/* Animated glow background */} -
- - {/* Left Connector Point */} -
- - {/* Right Connector Point - only show if there are right accounts */} - {hasRightAccounts && ( -
- )} - - {/* Top Connector Point - only show if there are top accounts */} - {hasTopAccounts && ( -
- )} - - {/* Bottom Connector Point - only show if there are bottom accounts */} - {hasBottomAccounts && ( -
- )} - -
- {/* 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 isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
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-4 w-44 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)} - -
- - {/* Connector Dot - Left side */} -
-
- ); - })} -
- )} -
- - {/* Bottom Zone Accounts */} - {hasBottomAccounts && ( -
- {bottomAccounts.map((account) => { - const originalIndex = accounts.findIndex((a) => a.id === account.id); - const isHovered = hoveredAccount === originalIndex; - const isDragging = draggingId === account.id; - const offset = getOffset(account.id); - - return ( -
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 pt-4 w-44 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-b-2 select-none touch-none', - isHovered && 'bg-muted/50 dark:bg-zinc-800/60', - isDragging && 'cursor-grabbing shadow-xl scale-105 z-50' - )} - style={{ - borderBottomColor: account.color, - transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, - }} - > - {/* Connector Dot - Top side */} -
- -
- - {cleanEmail(account.email)} - -
- -
- ); - })} -
- )} -
- - {/* Right Section: Connection Timeline - Fixed compact width */} -
-
- -
-
-
-
- ); -} +export { AccountFlowViz } from './account/flow-viz'; +export type { + AccountData, + ProviderData, + AccountFlowVizProps, + ConnectionEvent, +} from './account/flow-viz'; diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx new file mode 100644 index 00000000..65865f03 --- /dev/null +++ b/ui/src/components/account/accounts-table.tsx @@ -0,0 +1,150 @@ +/** + * Accounts Table Component + * Phase 03: REST API Routes & CRUD + */ + +import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Check } from 'lucide-react'; +import { useSetDefaultAccount } from '@/hooks/use-accounts'; +import type { Account } from '@/lib/api-client'; + +interface AccountsTableProps { + data: Account[]; + defaultAccount: string | null; +} + +export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { + const setDefaultMutation = useSetDefaultAccount(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + size: 200, + cell: ({ row }) => ( +
+ {row.original.name} + {row.original.name === defaultAccount && ( + + default + + )} +
+ ), + }, + { + accessorKey: 'type', + header: 'Type', + size: 100, + cell: ({ row }) => ( + {row.original.type || 'oauth'} + ), + }, + { + accessorKey: 'created', + header: 'Created', + size: 150, + cell: ({ row }) => { + const date = new Date(row.original.created); + return {date.toLocaleDateString()}; + }, + }, + { + accessorKey: 'last_used', + header: 'Last Used', + size: 150, + cell: ({ row }) => { + if (!row.original.last_used) return -; + const date = new Date(row.original.last_used); + return {date.toLocaleDateString()}; + }, + }, + { + id: 'actions', + header: 'Actions', + size: 100, + cell: ({ row }) => { + const isDefault = row.original.name === defaultAccount; + return ( + + ); + }, + }, + ]; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) { + return ( +
+ No accounts found. Use ccs login to + add accounts. +
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const widthClass = + { + name: 'w-[200px]', + type: 'w-[100px]', + created: 'w-[150px]', + last_used: 'w-[150px]', + actions: 'w-[100px]', + }[header.id] || 'w-auto'; + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx new file mode 100644 index 00000000..fa01fa4e --- /dev/null +++ b/ui/src/components/account/add-account-dialog.tsx @@ -0,0 +1,128 @@ +/** + * Add Account Dialog Component + * Triggers OAuth flow server-side to add another account to a provider + * Applies default preset when adding first account + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Loader2, ExternalLink, User } from 'lucide-react'; +import { useStartAuth } from '@/hooks/use-cliproxy'; +import { applyDefaultPreset } from '@/lib/preset-utils'; +import { toast } from 'sonner'; + +interface AddAccountDialogProps { + open: boolean; + onClose: () => void; + provider: string; + displayName: string; + /** Whether this is the first account being added (triggers preset application) */ + isFirstAccount?: boolean; +} + +export function AddAccountDialog({ + open, + onClose, + provider, + displayName, + isFirstAccount = false, +}: AddAccountDialogProps) { + const [nickname, setNickname] = useState(''); + const startAuthMutation = useStartAuth(); + + const handleStartAuth = () => { + startAuthMutation.mutate( + { provider, nickname: nickname.trim() || undefined }, + { + onSuccess: async () => { + // Apply default preset if this is the first account + if (isFirstAccount) { + const result = await applyDefaultPreset(provider); + if (result.success && result.presetName) { + toast.success(`Applied "${result.presetName}" preset`); + } else if (!result.success) { + toast.warning('Account added, but failed to apply default preset'); + } + } + setNickname(''); + onClose(); + }, + } + ); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen && !startAuthMutation.isPending) { + setNickname(''); + onClose(); + } + }; + + return ( + + + + Add {displayName} Account + + Click the button below to authenticate a new account. A browser window will open for + OAuth. + + + +
+
+ +
+ + setNickname(e.target.value)} + placeholder="e.g., work, personal" + disabled={startAuthMutation.isPending} + className="flex-1" + /> +
+

+ A friendly name to identify this account. Auto-generated from email if left empty. +

+
+ +
+ + +
+ + {startAuthMutation.isPending && ( +

+ Complete the OAuth flow in your browser... +

+ )} +
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/account-card-stats.tsx b/ui/src/components/account/flow-viz/account-card-stats.tsx new file mode 100644 index 00000000..3b3a18f3 --- /dev/null +++ b/ui/src/components/account/flow-viz/account-card-stats.tsx @@ -0,0 +1,85 @@ +/** + * Premium compact stats visualization for account cards + */ + +import { cn } from '@/lib/utils'; +import { CheckCircle2, XCircle } from 'lucide-react'; + +interface AccountCardStatsProps { + success: number; + failure: number; + showDetails: boolean; +} + +export function AccountCardStats({ success, failure, showDetails }: AccountCardStatsProps) { + const total = success + failure; + const successRate = total > 0 ? (success / total) * 100 : 100; + + return ( +
+ {/* Primary Row: Success Rate & Total */} +
+
+ + Success Rate + + = 90 + ? 'text-amber-500' + : 'text-red-500' + )} + > + {Math.round(successRate)}% + +
+
+ + Volume + + + {total.toLocaleString()} + +
+
+ + {/* Detailed Stats - Collapsible */} +
+
+ + + {success} + +
+
0 + ? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20' + : 'bg-muted/10 border-transparent opacity-40' + )} + > + 0 ? 'text-red-500' : 'text-muted-foreground')} + /> + 0 ? 'text-red-500' : 'text-muted-foreground' + )} + > + {failure} + +
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx new file mode 100644 index 00000000..3defbedc --- /dev/null +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -0,0 +1,127 @@ +/** + * Account Card Component for Flow Visualization + */ + +import { cn } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { GripVertical } from 'lucide-react'; + +import type { AccountData, DragOffset } from './types'; +import { cleanEmail } from './utils'; +import { AccountCardStats } from './account-card-stats'; + +type Zone = 'left' | 'right' | 'top' | 'bottom'; + +interface AccountCardProps { + account: AccountData; + zone: Zone; + originalIndex: number; + isHovered: boolean; + isDragging: boolean; + offset: DragOffset; + showDetails: boolean; + privacyMode: boolean; + onMouseEnter: () => void; + onMouseLeave: () => void; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +const BORDER_SIDE_MAP: Record = { + left: 'border-l-2', + right: 'border-r-2', + top: 'border-t-2', + bottom: 'border-b-2', +}; + +const CONNECTOR_POSITION_MAP: Record = { + left: 'top-1/2 -right-1.5 -translate-y-1/2', + right: 'top-1/2 -left-1.5 -translate-y-1/2', + top: 'left-1/2 -bottom-1.5 -translate-x-1/2', + bottom: 'left-1/2 -top-1.5 -translate-x-1/2', +}; + +function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties { + switch (zone) { + case 'left': + return { borderLeftColor: color }; + case 'right': + return { borderRightColor: color }; + case 'top': + return { borderTopColor: color }; + case 'bottom': + return { borderBottomColor: color }; + } +} + +export function AccountCard({ + account, + zone, + originalIndex, + isHovered, + isDragging, + offset, + showDetails, + privacyMode, + onMouseEnter, + onMouseLeave, + onPointerDown, + onPointerMove, + onPointerUp, +}: AccountCardProps) { + const borderSide = BORDER_SIDE_MAP[zone]; + const borderColor = getBorderColorStyle(zone, account.color); + const connectorPosition = CONNECTOR_POSITION_MAP[zone]; + + return ( +
+ +
+ + {cleanEmail(account.email)} + +
+ +
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/connection-timeline.tsx b/ui/src/components/account/flow-viz/connection-timeline.tsx new file mode 100644 index 00000000..f7743435 --- /dev/null +++ b/ui/src/components/account/flow-viz/connection-timeline.tsx @@ -0,0 +1,107 @@ +/** + * Connection Timeline Component - right sidebar panel + */ + +import { cn } from '@/lib/utils'; +import { STATUS_COLORS } from '@/lib/utils'; +import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { Activity } from 'lucide-react'; + +import type { ConnectionEvent } from './types'; +import { cleanEmail, formatTimelineTime } from './utils'; + +interface ConnectionTimelineProps { + events: ConnectionEvent[]; + privacyMode: boolean; +} + +export function ConnectionTimeline({ events, privacyMode }: ConnectionTimelineProps) { + if (events.length === 0) { + return ( +
+
No recent connections
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + Connection Timeline + +
+ + {/* Timeline container */} +
+
+ {/* Vertical line */} +
+ + {/* Events */} +
+ {events.map((event) => { + const statusColor = + event.status === 'success' + ? STATUS_COLORS.success + : event.status === 'failed' + ? STATUS_COLORS.failed + : STATUS_COLORS.degraded; + + return ( +
+ {/* Timeline dot */} +
+ + {/* Event content */} +
+
+ + {cleanEmail(event.accountEmail)} + + + {formatTimelineTime(event.timestamp)} + +
+
+ + {event.status} + + {event.latencyMs && ( + + {event.latencyMs}ms + + )} +
+
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/flow-paths.tsx b/ui/src/components/account/flow-viz/flow-paths.tsx new file mode 100644 index 00000000..810be3b8 --- /dev/null +++ b/ui/src/components/account/flow-viz/flow-paths.tsx @@ -0,0 +1,89 @@ +/** + * SVG Connection Paths Component + */ + +import type { AccountData } from './types'; +import { getConnectionColor } from './utils'; + +interface FlowPathsProps { + paths: string[]; + accounts: AccountData[]; + maxRequests: number; + hoveredAccount: number | null; + pulsingAccounts: Set; +} + +export function FlowPaths({ + paths, + accounts, + maxRequests, + hoveredAccount, + pulsingAccounts, +}: FlowPathsProps) { + return ( + <> + + + + + + + {paths.map((d, i) => { + const account = accounts[i]; + if (!account) return null; + + const total = account.successCount + account.failureCount; + const strokeWidth = Math.max(2, (total / maxRequests) * 10); + const isHovered = hoveredAccount === i; + const isDimmed = hoveredAccount !== null && hoveredAccount !== i; + const isPulsing = pulsingAccounts.has(account.id); + const connectionColor = getConnectionColor(i); + + return ( + + + {isPulsing && ( + <> + + + + )} + + ); + })} + + ); +} diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx new file mode 100644 index 00000000..01065856 --- /dev/null +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -0,0 +1,61 @@ +/** + * Flow Visualization Header Component + */ + +import { cn } from '@/lib/utils'; +import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; + +interface FlowVizHeaderProps { + onBack?: () => void; + showDetails: boolean; + onToggleDetails: () => void; + hasCustomPositions: boolean; + onResetPositions: () => void; +} + +export function FlowVizHeader({ + onBack, + showDetails, + onToggleDetails, + hasCustomPositions, + onResetPositions, +}: FlowVizHeaderProps) { + return ( +
+ {onBack ? ( + + ) : ( +
+ )} +
+ + {hasCustomPositions && ( + + )} +
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/hooks.ts b/ui/src/components/account/flow-viz/hooks.ts new file mode 100644 index 00000000..635a700d --- /dev/null +++ b/ui/src/components/account/flow-viz/hooks.ts @@ -0,0 +1,183 @@ +/** + * Custom hooks for drag and position management + */ + +import { useRef, useState, useEffect, useCallback } from 'react'; +import type { DragOffset, ContainerExpansion } from './types'; + +interface UseDragPositionsOptions { + storageKey: string; + onDrag?: () => void; +} + +interface UseDragPositionsReturn { + dragOffsets: Record; + draggingId: string | null; + didDragRef: React.MutableRefObject; + handlePointerDown: (id: string, e: React.PointerEvent) => void; + handlePointerMove: (e: React.PointerEvent) => void; + handlePointerUp: () => void; + getOffset: (id: string) => DragOffset; + resetPositions: () => void; + hasCustomPositions: boolean; +} + +/** + * Hook for managing draggable card positions with localStorage persistence + */ +export function useDragPositions({ + storageKey, + onDrag, +}: UseDragPositionsOptions): UseDragPositionsReturn { + // Drag state + const [draggingId, setDraggingId] = useState(null); + const dragStartRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>( + null + ); + const didDragRef = useRef(false); + + // Load saved positions from localStorage + const loadSavedPositions = useCallback((): Record => { + try { + const saved = localStorage.getItem(storageKey); + if (saved) return JSON.parse(saved); + } catch { + // Ignore parse errors + } + return {}; + }, [storageKey]); + + const [dragOffsets, setDragOffsets] = useState>(() => + loadSavedPositions() + ); + + // Save positions to localStorage when they change + useEffect(() => { + if (Object.keys(dragOffsets).length > 0) { + localStorage.setItem(storageKey, JSON.stringify(dragOffsets)); + } + }, [dragOffsets, storageKey]); + + // Reset positions handler + const resetPositions = useCallback(() => { + setDragOffsets({}); + localStorage.removeItem(storageKey); + }, [storageKey]); + + // 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 }; + didDragRef.current = false; + 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; + // Track if actual movement occurred (threshold of 3px) + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + didDragRef.current = true; + } + setDragOffsets((prev) => ({ + ...prev, + [draggingId]: { + x: start.offsetX + dx, + y: start.offsetY + dy, + }, + })); + // Notify parent to recalculate paths + if (onDrag) { + requestAnimationFrame(onDrag); + } + }, + [draggingId, onDrag] + ); + + 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 { + dragOffsets, + draggingId, + didDragRef, + handlePointerDown, + handlePointerMove, + handlePointerUp, + getOffset, + resetPositions, + hasCustomPositions: Object.keys(dragOffsets).length > 0, + }; +} + +/** + * Calculate container expansion based on drag offsets + */ +export function useContainerExpansion(dragOffsets: Record): ContainerExpansion { + let minY = 0, + maxY = 0; + Object.values(dragOffsets).forEach((offset) => { + minY = Math.min(minY, offset.y); + maxY = Math.max(maxY, offset.y); + }); + return { + paddingTop: Math.max(0, -minY), + paddingBottom: Math.max(0, maxY), + extraHeight: Math.max(0, Math.abs(minY), Math.abs(maxY)) * 2, + }; +} + +interface AccountLike { + id: string; + successCount: number; + failureCount: number; +} + +/** + * Hook for detecting new activity and triggering pulse animations + */ +export function usePulseAnimation(accounts: AccountLike[]): Set { + const [pulsingAccounts, setPulsingAccounts] = useState>(new Set()); + const prevCountsRef = useRef>({}); + + // Detect new activity and trigger pulse animation + useEffect(() => { + const newPulsing = new Set(); + const newCounts: Record = {}; + + accounts.forEach((account) => { + const currentCount = account.successCount + account.failureCount; + newCounts[account.id] = currentCount; + const prev = prevCountsRef.current[account.id]; + if (prev !== undefined && currentCount > prev) { + newPulsing.add(account.id); + } + }); + + prevCountsRef.current = newCounts; + + if (newPulsing.size > 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid pattern for animation triggers + setPulsingAccounts(newPulsing); + + const timer = setTimeout(() => setPulsingAccounts(new Set()), 2000); + return () => clearTimeout(timer); + } + }, [accounts]); + + return pulsingAccounts; +} diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx new file mode 100644 index 00000000..7d1dae00 --- /dev/null +++ b/ui/src/components/account/flow-viz/index.tsx @@ -0,0 +1,202 @@ +/** + * Account Flow Visualization + * Custom SVG bezier curve visualization showing request flow from accounts to providers + */ + +import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; +import { cn } from '@/lib/utils'; +import { PROVIDER_COLORS } from '@/lib/provider-config'; +import { usePrivacy } from '@/contexts/privacy-context'; + +import type { AccountFlowVizProps } from './types'; +import { MAX_TIMELINE_EVENTS, generateConnectionEvents } from './utils'; +import { calculateBezierPaths } from './path-utils'; +import { splitAccountsIntoZones, getProviderSizeClass } from './zone-utils'; +import { useDragPositions, useContainerExpansion, usePulseAnimation } from './hooks'; +import { ConnectionTimeline } from './connection-timeline'; +import { AccountCard } from './account-card'; +import { ProviderCard } from './provider-card'; +import { FlowPaths } from './flow-paths'; +import { FlowVizHeader } from './flow-viz-header'; + +// Re-export types for backward compatibility +export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './types'; + +export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [hoveredAccount, setHoveredAccount] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [paths, setPaths] = useState([]); + + const { privacyMode } = usePrivacy(); + 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); + + const calculatePaths = useCallback(() => { + const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts }); + if (newPaths.length > 0) setPaths(newPaths); + }, [accounts]); + + const storageKey = `ccs-flow-positions-${providerData.provider}`; + const { + dragOffsets, + draggingId, + handlePointerDown, + handlePointerMove, + handlePointerUp, + getOffset, + resetPositions, + hasCustomPositions, + } = useDragPositions({ storageKey, onDrag: calculatePaths }); + const containerExpansion = useContainerExpansion(dragOffsets); + const pulsingAccounts = usePulseAnimation(accounts); + + const connectionEvents = useMemo( + () => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS), + [accounts] + ); + + useEffect(() => { + const timer = setTimeout(calculatePaths, 50); + window.addEventListener('resize', calculatePaths); + return () => { + clearTimeout(timer); + window.removeEventListener('resize', calculatePaths); + }; + }, [calculatePaths]); + + useEffect(() => { + const timer = setTimeout(calculatePaths, 10); + return () => clearTimeout(timer); + }, [dragOffsets, calculatePaths]); + + useEffect(() => { + const startTime = Date.now(); + const duration = 350; + const animate = () => { + calculatePaths(); + if (Date.now() - startTime < duration) requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }, [showDetails, calculatePaths]); + + const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; + const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]); + const { leftAccounts, rightAccounts, topAccounts, bottomAccounts } = zones; + const hasRightAccounts = rightAccounts.length > 0; + const hasTopAccounts = topAccounts.length > 0; + const hasBottomAccounts = bottomAccounts.length > 0; + const providerSize = useMemo(() => getProviderSizeClass(accounts.length), [accounts.length]); + + const renderAccountCards = ( + accountList: typeof accounts, + zone: 'left' | 'right' | 'top' | 'bottom' + ) => + accountList.map((account) => { + const originalIndex = accounts.findIndex((a) => a.id === account.id); + return ( + setHoveredAccount(originalIndex)} + onMouseLeave={() => setHoveredAccount(null)} + onPointerDown={(e) => handlePointerDown(account.id, e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> + ); + }); + + return ( +
+ setShowDetails(!showDetails)} + hasCustomPositions={hasCustomPositions} + onResetPositions={resetPositions} + /> + +
+
+ + + + + {hasTopAccounts && ( +
+ {renderAccountCards(topAccounts, 'top')} +
+ )} + +
+
+ {renderAccountCards(leftAccounts, 'left')} +
+ +
+ handlePointerDown('provider', e)} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + /> +
+ + {hasRightAccounts && ( +
+ {renderAccountCards(rightAccounts, 'right')} +
+ )} +
+ + {hasBottomAccounts && ( +
+ {renderAccountCards(bottomAccounts, 'bottom')} +
+ )} +
+ +
+
+ +
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/path-utils.ts b/ui/src/components/account/flow-viz/path-utils.ts new file mode 100644 index 00000000..422c8727 --- /dev/null +++ b/ui/src/components/account/flow-viz/path-utils.ts @@ -0,0 +1,96 @@ +/** + * SVG path calculation utilities for bezier curves + */ + +import type { AccountData, AccountZone } from './types'; + +interface PathCalculationParams { + containerRef: React.RefObject; + svgRef: React.RefObject; + accounts: AccountData[]; +} + +/** + * Calculate SVG bezier curve paths from account cards to provider node + */ +export function calculateBezierPaths({ + containerRef, + svgRef, + accounts, +}: PathCalculationParams): string[] { + 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(); + + const newPaths: string[] = []; + + accounts.forEach((_, i) => { + const sourceEl = container.querySelector(`[data-account-index="${i}"]`); + if (!sourceEl) return; + const sourceRect = sourceEl.getBoundingClientRect(); + + // Determine zone from data attribute + const zone = (sourceEl.getAttribute('data-zone') || 'left') as AccountZone; + + let startX: number, startY: number, destX: number, destY: number; + + // Note: getBoundingClientRect already includes CSS transforms, so offset is implicit + + switch (zone) { + case 'right': + // Right side: connect from left edge of card 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; + break; + case 'top': + // Top side: connect from bottom edge of card to top edge of provider + startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; + startY = sourceRect.bottom - svgRect.top; + destX = destRect.left + destRect.width / 2 - svgRect.left; + destY = destRect.top - svgRect.top; + break; + case 'bottom': + // Bottom side: connect from top edge of card to bottom edge of provider + startX = sourceRect.left + sourceRect.width / 2 - svgRect.left; + startY = sourceRect.top - svgRect.top; + destX = destRect.left + destRect.width / 2 - svgRect.left; + destY = destRect.bottom - svgRect.top; + break; + default: // 'left' + // Left side: connect from right edge of card 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 - adjust based on zone direction + let cp1X: number, cp1Y: number, cp2X: number, cp2Y: number; + + if (zone === 'top' || zone === 'bottom') { + // Vertical connection - control points extend horizontally for curve + cp1X = startX; + cp1Y = startY + (destY - startY) * 0.5; + cp2X = destX; + cp2Y = destY - (destY - startY) * 0.5; + } else { + // Horizontal connection - control points extend vertically for curve + cp1X = startX + (destX - startX) * 0.5; + cp1Y = startY; + cp2X = destX - (destX - startX) * 0.5; + cp2Y = destY; + } + + newPaths.push(`M ${startX} ${startY} C ${cp1X} ${cp1Y}, ${cp2X} ${cp2Y}, ${destX} ${destY}`); + }); + + return newPaths; +} diff --git a/ui/src/components/account/flow-viz/provider-card.tsx b/ui/src/components/account/flow-viz/provider-card.tsx new file mode 100644 index 00000000..95328d94 --- /dev/null +++ b/ui/src/components/account/flow-viz/provider-card.tsx @@ -0,0 +1,143 @@ +/** + * Provider Card Component for Flow Visualization + */ + +import { cn } from '@/lib/utils'; +import { ProviderIcon } from '@/components/shared/provider-icon'; +import { GripVertical } from 'lucide-react'; + +import type { DragOffset, ProviderData } from './types'; + +interface ProviderCardProps { + providerData: ProviderData; + providerColor: string; + totalRequests: number; + maxRequests: number; + isDragging: boolean; + offset: DragOffset; + hoveredAccount: number | null; + hasRightAccounts: boolean; + hasTopAccounts: boolean; + hasBottomAccounts: boolean; + onPointerDown: (e: React.PointerEvent) => void; + onPointerMove: (e: React.PointerEvent) => void; + onPointerUp: () => void; +} + +export function ProviderCard({ + providerData, + providerColor, + totalRequests, + maxRequests, + isDragging, + offset, + hoveredAccount, + hasRightAccounts, + hasTopAccounts, + hasBottomAccounts, + onPointerDown, + onPointerMove, + onPointerUp, +}: ProviderCardProps) { + const { accounts } = providerData; + + return ( +
+ +
+ + {/* Connector Points */} +
+ {hasRightAccounts && ( +
+ )} + {hasTopAccounts && ( +
+ )} + {hasBottomAccounts && ( +
+ )} + +
+
+ +
+
+

+ {providerData.displayName} +

+

Provider

+
+
+ +
+
+ Total Requests + {totalRequests.toLocaleString()} +
+
+ Accounts + {accounts.length} +
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/account/flow-viz/types.ts b/ui/src/components/account/flow-viz/types.ts new file mode 100644 index 00000000..b6e5ca33 --- /dev/null +++ b/ui/src/components/account/flow-viz/types.ts @@ -0,0 +1,57 @@ +/** + * Type definitions for Account Flow Visualization + */ + +/** Position offset for draggable cards */ +export interface DragOffset { + x: number; + y: number; +} + +export interface AccountData { + id: string; + email: string; + provider: string; + successCount: number; + failureCount: number; + lastUsedAt?: string; + color: string; +} + +export interface ProviderData { + provider: string; + displayName: string; + totalRequests: number; + accounts: AccountData[]; +} + +export interface AccountFlowVizProps { + providerData: ProviderData; + onBack?: () => void; +} + +export interface ConnectionEvent { + id: string; + timestamp: Date; + accountEmail: string; + status: 'success' | 'failed' | 'pending'; + latencyMs?: number; +} + +/** Zone type for account card placement */ +export type AccountZone = 'left' | 'right' | 'top' | 'bottom'; + +/** Container expansion state */ +export interface ContainerExpansion { + paddingTop: number; + paddingBottom: number; + extraHeight: number; +} + +/** Account zone distribution */ +export interface AccountZones { + leftAccounts: AccountData[]; + rightAccounts: AccountData[]; + topAccounts: AccountData[]; + bottomAccounts: AccountData[]; +} diff --git a/ui/src/components/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts new file mode 100644 index 00000000..ce101437 --- /dev/null +++ b/ui/src/components/account/flow-viz/utils.ts @@ -0,0 +1,87 @@ +/** + * Utility functions for Account Flow Visualization + */ + +import type { AccountData, ConnectionEvent } from './types'; + +// Maximum events to display in the Connection Timeline to prevent performance issues +export const MAX_TIMELINE_EVENTS = 100; + +// Earthy, sophisticated color palette for connection lines - works in both light/dark themes +export const CONNECTION_COLORS = [ + '#3b3c36', // Charcoal Brown - urban mystery + '#568203', // Forest Moss - woodland depth + '#8d4557', // Vintage Berry - timeless elegance + '#da9100', // Harvest Gold - sun-drenched warmth + '#3c6c82', // Blue Slate - cool authority + '#c96907', // Burnt Caramel - earthy comfort +]; + +/** Get a muted connection color based on index */ +export function getConnectionColor(index: number): string { + return CONNECTION_COLORS[index % CONNECTION_COLORS.length]; +} + +/** Strip common email domains for cleaner display */ +export function cleanEmail(email: string): string { + return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); +} + +/** Format timestamp for timeline display */ +export function formatTimelineTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'now'; + if (diffMins < 60) return `${diffMins}m`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.floor(diffHours / 24)}d`; +} + +/** Generate connection events from real account data */ +export function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { + const events: ConnectionEvent[] = []; + + accounts.forEach((account) => { + const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); + + // Helper to add events + const addEvents = (count: number, status: 'success' | 'failed') => { + for (let i = 0; i < count; i++) { + // Simulate timestamps: + // - Distribute events over a 24-hour window relative to lastUsed + // - Add random jitter so events from different accounts mix + const timeOffset = Math.floor(Math.random() * 24 * 60 * 60 * 1000 * (i / (count || 1))); + const timestamp = new Date(lastUsed.getTime() - timeOffset); + + // Add small random jitter (+/-5 mins) to avoid exact overlaps + const jitter = Math.floor((Math.random() - 0.5) * 10 * 60 * 1000); + timestamp.setTime(timestamp.getTime() + jitter); + + // Sanity check: don't go into the future relative to "now" + const now = new Date(); + if (timestamp > now) timestamp.setTime(now.getTime()); + + events.push({ + id: `${account.id}-${status}-${i}`, + timestamp, + accountEmail: account.email, + status, + // Simulate realistic latency (success: 50-200ms, failed: 200-5000ms) + latencyMs: + status === 'success' + ? 50 + Math.floor(Math.random() * 150) + : 200 + Math.floor(Math.random() * 4800), + }); + } + }; + + addEvents(account.successCount, 'success'); + addEvents(account.failureCount, 'failed'); + }); + + // Sort by timestamp descending (most recent first) + return events.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); +} diff --git a/ui/src/components/account/flow-viz/zone-utils.ts b/ui/src/components/account/flow-viz/zone-utils.ts new file mode 100644 index 00000000..328103f7 --- /dev/null +++ b/ui/src/components/account/flow-viz/zone-utils.ts @@ -0,0 +1,63 @@ +/** + * Zone distribution utilities for account cards + */ + +import type { AccountData, AccountZones } from './types'; + +/** + * Split accounts into zones based on count (top/left/right/bottom) + */ +export function splitAccountsIntoZones(accounts: AccountData[]): AccountZones { + const count = accounts.length; + + // 1-2 accounts: left only + if (count <= 2) { + return { + leftAccounts: accounts, + rightAccounts: [], + topAccounts: [], + bottomAccounts: [], + }; + } + + // 3-4 accounts: left and right + if (count <= 4) { + const mid = Math.ceil(count / 2); + return { + leftAccounts: accounts.slice(0, mid), + rightAccounts: accounts.slice(mid), + topAccounts: [], + bottomAccounts: [], + }; + } + + // 5-8 accounts: left, right, top + if (count <= 8) { + const perZone = Math.ceil(count / 3); + return { + leftAccounts: accounts.slice(0, perZone), + rightAccounts: accounts.slice(perZone, perZone * 2), + topAccounts: accounts.slice(perZone * 2), + bottomAccounts: [], + }; + } + + // 9+ accounts: all four zones + const perZone = Math.ceil(count / 4); + return { + leftAccounts: accounts.slice(0, perZone), + rightAccounts: accounts.slice(perZone, perZone * 2), + topAccounts: accounts.slice(perZone * 2, perZone * 3), + bottomAccounts: accounts.slice(perZone * 3), + }; +} + +/** + * Get provider card size class based on account count + */ +export function getProviderSizeClass(accountCount: number): string { + if (accountCount >= 9) return 'w-64'; // 4 zones - largest + if (accountCount >= 5) return 'w-60'; // 3 zones + if (accountCount >= 3) return 'w-56'; // 2 zones + return 'w-52'; // 1 zone - default +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts new file mode 100644 index 00000000..ba0cf9b4 --- /dev/null +++ b/ui/src/components/account/index.ts @@ -0,0 +1,11 @@ +/** + * Account Components Barrel Export + */ + +// Main components +export { AccountsTable } from './accounts-table'; +export { AddAccountDialog } from './add-account-dialog'; + +// Flow visualization (from subdirectory) +export { AccountFlowViz } from './flow-viz'; +export type { AccountData, ProviderData, AccountFlowVizProps, ConnectionEvent } from './flow-viz';