feat(ui): add connection timeline and improve account flow layout

- Add ConnectionTimeline component showing recent connection events
- Restructure layout to 2-column design: flow viz (flex) | timeline (fixed)
- Flow chart now expands to fill available space with justify-between
- Timeline has compact fixed width (w-56) on the right
- Detail panel now uses flow-based positioning instead of absolute
- Generate mock connection events based on account success/failure data
This commit is contained in:
kaitranntt committed 2025-12-15 01:23:26 -05:00
1 parent 349d81a1b0
commit 27de6af8aa
1 file changed
+187 -19
+187 -19
View File
@@ -4,11 +4,12 @@
* Inspired by modern dark theme design with glass panels and glow effects * Inspired by modern dark theme design with glass panels and glow effects
*/ */
import { useRef, useEffect, useState, useCallback } from 'react'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ProviderIcon } from '@/components/provider-icon'; import { ProviderIcon } from '@/components/provider-icon';
import { PROVIDER_COLORS } from '@/lib/provider-config'; import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ChevronRight, X, CheckCircle2, XCircle, Clock } from 'lucide-react'; import { STATUS_COLORS } from '@/lib/utils';
import { ChevronRight, X, CheckCircle2, XCircle, Clock, Activity } from 'lucide-react';
interface AccountData { interface AccountData {
id: string; id: string;
@@ -32,6 +33,157 @@ interface AccountFlowVizProps {
onBack?: () => void; onBack?: () => void;
} }
interface ConnectionEvent {
id: string;
timestamp: Date;
accountEmail: string;
status: 'success' | 'failed' | 'pending';
latencyMs?: number;
}
/** Generate mock connection events based on account data */
function generateConnectionEvents(accounts: AccountData[]): ConnectionEvent[] {
const events: ConnectionEvent[] = [];
const now = new Date();
accounts.forEach((account) => {
// Generate events based on success/failure counts
const successEvents = Math.min(account.successCount, 3);
const failEvents = Math.min(account.failureCount, 2);
for (let i = 0; i < successEvents; i++) {
events.push({
id: `${account.id}-s-${i}`,
timestamp: new Date(now.getTime() - Math.random() * 3600000), // Last hour
accountEmail: account.email,
status: 'success',
latencyMs: Math.floor(Math.random() * 500) + 50,
});
}
for (let i = 0; i < failEvents; i++) {
events.push({
id: `${account.id}-f-${i}`,
timestamp: new Date(now.getTime() - Math.random() * 7200000), // Last 2 hours
accountEmail: account.email,
status: '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 }: { events: ConnectionEvent[] }) {
if (events.length === 0) {
return (
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
<div className="text-xs text-muted-foreground font-mono">No recent connections</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3 px-2">
<Activity className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
Connection Timeline
</span>
</div>
{/* Timeline container */}
<div
className={cn(
'flex-1 rounded-xl p-4 overflow-y-auto',
'bg-muted/20 dark:bg-zinc-900/40 backdrop-blur-sm',
'border border-border/30 dark:border-white/[0.05]'
)}
>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-border/50 dark:bg-white/[0.08]" />
{/* Events */}
<div className="space-y-3">
{events.slice(0, 8).map((event) => {
const statusColor =
event.status === 'success'
? STATUS_COLORS.success
: event.status === 'failed'
? STATUS_COLORS.failed
: STATUS_COLORS.degraded;
return (
<div key={event.id} className="relative flex items-start gap-3 pl-1">
{/* Timeline dot */}
<div
className={cn(
'relative z-10 w-3.5 h-3.5 rounded-full flex-shrink-0 mt-0.5',
'ring-2 ring-background dark:ring-zinc-950'
)}
style={{ backgroundColor: statusColor }}
/>
{/* Event content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-mono text-foreground truncate">
{cleanEmail(event.accountEmail)}
</span>
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
{formatTimelineTime(event.timestamp)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className="text-[9px] font-medium uppercase"
style={{ color: statusColor }}
>
{event.status}
</span>
{event.latencyMs && (
<span className="text-[9px] text-muted-foreground font-mono">
{event.latencyMs}ms
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Show more indicator */}
{events.length > 8 && (
<div className="mt-3 pt-2 border-t border-border/30 dark:border-white/[0.05]">
<span className="text-[9px] text-muted-foreground font-mono">
+{events.length - 8} more events
</span>
</div>
)}
</div>
</div>
</div>
);
}
/** Strip common email domains for cleaner display */ /** Strip common email domains for cleaner display */
function cleanEmail(email: string): string { function cleanEmail(email: string): string {
return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, ''); return email.replace(/@(gmail|yahoo|hotmail|outlook|icloud)\.com$/i, '');
@@ -64,6 +216,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const maxRequests = Math.max(...accounts.map((a) => a.successCount + a.failureCount), 1); 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 totalRequests = accounts.reduce((acc, a) => acc + a.successCount + a.failureCount, 0);
// Generate connection events for timeline
const connectionEvents = useMemo(() => generateConnectionEvents(accounts), [accounts]);
// Calculate SVG paths for bezier curves // Calculate SVG paths for bezier curves
const calculatePaths = useCallback(() => { const calculatePaths = useCallback(() => {
if (!containerRef.current || !svgRef.current) return; if (!containerRef.current || !svgRef.current) return;
@@ -116,20 +271,22 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280'; const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
return ( return (
<div className="relative" ref={containerRef}> <div className="flex flex-col" ref={containerRef}>
{/* Back button */} {/* Back button */}
{onBack && ( {onBack && (
<button <button
onClick={onBack} onClick={onBack}
className="absolute top-0 left-0 z-20 flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" className="self-start flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
> >
<ChevronRight className="w-3 h-3 rotate-180" /> <ChevronRight className="w-3 h-3 rotate-180" />
<span>Back to providers</span> <span>Back to providers</span>
</button> </button>
)} )}
{/* Main visualization area */} {/* Main visualization area - 2 column layout: Flow viz (left) | Timeline (right) */}
<div className="min-h-[320px] relative flex items-center justify-between px-8 py-8 pt-12"> <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">
{/* SVG Canvas (Background) */} {/* SVG Canvas (Background) */}
<svg <svg
ref={svgRef} ref={svgRef}
@@ -164,8 +321,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
})} })}
</svg> </svg>
{/* Left Column: Source Accounts */} {/* Source Accounts */}
<div className="flex flex-col gap-3 z-10 w-56"> <div className="flex flex-col gap-3 z-10 w-48 justify-center">
{accounts.map((account, i) => { {accounts.map((account, i) => {
const total = account.successCount + account.failureCount; const total = account.successCount + account.failureCount;
const isHovered = hoveredAccount === i; const isHovered = hoveredAccount === i;
@@ -187,7 +344,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
style={{ borderLeftColor: account.color }} style={{ borderLeftColor: account.color }}
> >
<div className="flex justify-between items-start mb-1"> <div className="flex justify-between items-start mb-1">
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[140px]"> <span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[120px]">
{cleanEmail(account.email)} {cleanEmail(account.email)}
</span> </span>
<ChevronRight <ChevronRight
@@ -221,8 +378,8 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
})} })}
</div> </div>
{/* Right Column: Destination Provider */} {/* Destination Provider */}
<div className="z-10 w-56 flex justify-end items-center"> <div className="z-10 w-48 flex items-center">
<div <div
data-provider-node data-provider-node
className={cn( className={cn(
@@ -250,14 +407,18 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
<h3 className="text-sm font-semibold text-foreground tracking-tight"> <h3 className="text-sm font-semibold text-foreground tracking-tight">
{providerData.displayName} {providerData.displayName}
</h3> </h3>
<p className="text-[10px] text-muted-foreground font-medium uppercase">Provider</p> <p className="text-[10px] text-muted-foreground font-medium uppercase">
Provider
</p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Total Requests</span> <span className="text-muted-foreground">Total Requests</span>
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span> <span className="text-foreground font-mono">
{totalRequests.toLocaleString()}
</span>
</div> </div>
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Accounts</span> <span className="text-muted-foreground">Accounts</span>
@@ -277,18 +438,24 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
</div> </div>
</div> </div>
{/* Detail Panel (Bottom slide-up) */} {/* Right Section: Connection Timeline - Fixed compact width */}
<div className="w-56 flex-shrink-0">
<ConnectionTimeline events={connectionEvents} />
</div>
</div>
{/* Detail Panel - slides in from bottom, pushes content */}
<div <div
className={cn( className={cn(
'absolute bottom-0 inset-x-0 bg-card dark:bg-zinc-950 border-t border-border', 'overflow-hidden transition-all duration-300 ease-out',
'transform transition-transform duration-300 z-30', selectedAccount ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
selectedAccount ? 'translate-y-0' : 'translate-y-full'
)} )}
> >
<div className="p-4"> <div className={cn('bg-card dark:bg-zinc-950 border-t border-border', 'p-4')}>
<div className="relative">
<button <button
onClick={() => setSelectedAccount(null)} onClick={() => setSelectedAccount(null)}
className="absolute top-3 right-3 text-muted-foreground hover:text-foreground transition-colors" className="absolute top-0 right-0 text-muted-foreground hover:text-foreground transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -346,5 +513,6 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }