diff --git a/ui/src/components/account-flow-viz.tsx b/ui/src/components/account-flow-viz.tsx index 7128b020..f61bc906 100644 --- a/ui/src/components/account-flow-viz.tsx +++ b/ui/src/components/account-flow-viz.tsx @@ -12,12 +12,13 @@ import { STATUS_COLORS } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { ChevronRight, - X, CheckCircle2, XCircle, - Clock, Activity, GripVertical, + Eye, + EyeOff, + RotateCcw, } from 'lucide-react'; /** Position offset for draggable cards */ @@ -61,20 +62,41 @@ function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] { const events: ConnectionEvent[] = []; accounts.forEach((account) => { - // Only show events for accounts that have actual request data - const hasActivity = account.successCount > 0 || account.failureCount > 0; - if (!hasActivity) return; - - // Create a single consolidated event per account showing its current status const lastUsed = account.lastUsedAt ? new Date(account.lastUsedAt) : new Date(); - const hasFailures = account.failureCount > 0; - events.push({ - id: `${account.id}-status`, - timestamp: lastUsed, - accountEmail: account.email, - status: hasFailures && account.failureCount > account.successCount ? 'failed' : 'success', - }); + // 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) @@ -134,7 +156,7 @@ function ConnectionTimeline({ {/* Events */}
- {events.slice(0, 8).map((event) => { + {events.map((event) => { const statusColor = event.status === 'success' ? STATUS_COLORS.success @@ -186,15 +208,6 @@ function ConnectionTimeline({ ); })}
- - {/* Show more indicator */} - {events.length > 8 && ( -
- - +{events.length - 8} more events - -
- )} @@ -206,27 +219,93 @@ function cleanEmail(email: string): string { return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); } -function getTimeAgo(dateStr?: string): string { - if (!dateStr) return 'never'; - const date = new Date(dateStr); - if (isNaN(date.getTime())) return 'unknown'; - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - if (diffMs < 0) return 'just now'; - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - return `${diffDays}d ago`; +/** 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 [selectedAccount, setSelectedAccount] = useState(null); + const [showDetails, setShowDetails] = useState(false); const [paths, setPaths] = useState([]); // Privacy mode for demo purposes @@ -402,6 +481,21 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { 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) @@ -445,6 +539,21 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { 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; @@ -501,21 +610,56 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { return (
- {/* Back button */} - {onBack && ( - - )} + {/* Header: Back button + Reset Layout */} +
+ {onBack ? ( + + ) : ( +
+ )} +
+ + + {Object.keys(dragOffsets).length > 0 && ( + + )} +
+
{/* Main visualization area - Multi-zone layout */} -
+
{/* Flow visualization section */} -
+
{/* SVG Canvas (Background) */} {topAccounts.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); @@ -594,10 +737,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="top" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -617,8 +756,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`, }} > - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+ {/* Connector Dot - Bottom side */}
{leftAccounts.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); @@ -675,10 +801,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="left" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -699,8 +821,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { }} > {/* Drag handle indicator */} - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+ {/* Connector Dot - Right side */}
{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); @@ -877,10 +986,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="right" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -901,14 +1006,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { }} > {/* Drag handle indicator */} - -
- + +
-
-
-
- {account.failureCount > 0 && ( -
- )} -
- - {total.toLocaleString()} reqs - -
+ {/* Connector Dot - Left side */}
{bottomAccounts.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); @@ -959,10 +1051,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { key={account.id} data-account-index={originalIndex} data-zone="bottom" - onClick={() => - !didDragRef.current && - setSelectedAccount((prev) => (prev?.id === account.id ? null : account)) - } onMouseEnter={() => setHoveredAccount(originalIndex)} onMouseLeave={() => setHoveredAccount(null)} onPointerDown={(e) => handlePointerDown(account.id, e)} @@ -970,7 +1058,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { onPointerUp={handlePointerUp} onPointerCancel={handlePointerUp} className={cn( - 'group/card relative rounded-lg p-3 pt-6 w-44 cursor-grab transition-shadow duration-200', + 'group/card relative rounded-lg p-3 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', @@ -990,8 +1078,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) { isHovered && 'bg-foreground dark:bg-white border-transparent' )} /> - -
+ +
{cleanEmail(account.email)} - -
-
- - {total.toLocaleString()} reqs - -
- {account.failureCount > 0 && ( -
- )} -
-
+
); })} @@ -1026,94 +1102,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
{/* Right Section: Connection Timeline - Fixed compact width */} -
- -
-
- - {/* Reset Layout Button */} - {Object.keys(dragOffsets).length > 0 && ( -
- -
- )} - - {/* Detail Panel - slides in from bottom, pushes content */} -
-
-
- - - {selectedAccount && ( -
- {/* Account Info */} -
-
-
- - {cleanEmail(selectedAccount.email)} - -
-
- Source Account -
-
- - {/* Stats */} -
-
- - SUCCESSFUL -
-
- {selectedAccount.successCount.toLocaleString()} -
-
- -
-
- - FAILED -
-
- {selectedAccount.failureCount.toLocaleString()} -
-
- -
-
- - LAST SYNC -
-
- {getTimeAgo(selectedAccount.lastUsedAt)} -
-
-
- )} +
+
+