mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 18:18:43 +00:00
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:
@@ -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,233 +271,246 @@ 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">
|
||||||
{/* SVG Canvas (Background) */}
|
{/* Left Section: Account → Provider Flow - expands to fill available space */}
|
||||||
<svg
|
<div className="relative flex-1 flex items-center justify-between px-8">
|
||||||
ref={svgRef}
|
{/* SVG Canvas (Background) */}
|
||||||
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
|
<svg
|
||||||
>
|
ref={svgRef}
|
||||||
<defs>
|
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
|
||||||
<filter id="flow-glow" x="-20%" y="-20%" width="140%" height="140%">
|
>
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
<defs>
|
||||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
<filter id="flow-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
</filter>
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
</defs>
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
{paths.map((d, i) => {
|
</filter>
|
||||||
const account = accounts[i];
|
</defs>
|
||||||
const total = account.successCount + account.failureCount;
|
{paths.map((d, i) => {
|
||||||
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
|
const account = accounts[i];
|
||||||
const isHovered = hoveredAccount === i;
|
const total = account.successCount + account.failureCount;
|
||||||
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
|
const strokeWidth = Math.max(2, (total / maxRequests) * 10);
|
||||||
|
const isHovered = hoveredAccount === i;
|
||||||
|
const isDimmed = hoveredAccount !== null && hoveredAccount !== i;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={i}
|
key={i}
|
||||||
d={d}
|
d={d}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={account.color}
|
stroke={account.color}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeOpacity={isHovered ? 0.9 : isDimmed ? 0.05 : 0.2}
|
strokeOpacity={isHovered ? 0.9 : isDimmed ? 0.05 : 0.2}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
filter={isHovered ? 'url(#flow-glow)' : undefined}
|
filter={isHovered ? 'url(#flow-glow)' : undefined}
|
||||||
className="transition-all duration-300"
|
className="transition-all duration-300"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
data-account-index={i}
|
data-account-index={i}
|
||||||
onClick={() => setSelectedAccount(account)}
|
onClick={() => setSelectedAccount(account)}
|
||||||
onMouseEnter={() => setHoveredAccount(i)}
|
onMouseEnter={() => setHoveredAccount(i)}
|
||||||
onMouseLeave={() => setHoveredAccount(null)}
|
onMouseLeave={() => setHoveredAccount(null)}
|
||||||
className={cn(
|
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-pointer transition-all duration-300',
|
||||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||||
'border border-border/50 dark:border-white/[0.08]',
|
'border border-border/50 dark:border-white/[0.08]',
|
||||||
'border-l-2 hover:translate-x-1',
|
'border-l-2 hover:translate-x-1',
|
||||||
isHovered && 'bg-muted/50 dark:bg-zinc-800/60'
|
isHovered && 'bg-muted/50 dark:bg-zinc-800/60'
|
||||||
)}
|
)}
|
||||||
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
|
||||||
|
className={cn(
|
||||||
|
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
|
||||||
|
isHovered ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground font-mono">
|
||||||
|
{total.toLocaleString()} reqs
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{account.failureCount > 0 && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
|
||||||
|
)}
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Connector Dot */}
|
||||||
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3.5 h-3.5 text-muted-foreground transition-opacity',
|
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
|
||||||
isHovered ? 'opacity-100' : 'opacity-0'
|
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
|
||||||
|
isHovered && 'bg-foreground dark:bg-white border-transparent'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
);
|
||||||
<span className="text-[10px] text-muted-foreground font-mono">
|
})}
|
||||||
{total.toLocaleString()} reqs
|
</div>
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{account.failureCount > 0 && (
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500/80" />
|
|
||||||
)}
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500/80" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Connector Dot */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2 z-20 transition-colors border',
|
|
||||||
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
|
|
||||||
isHovered && 'bg-foreground dark:bg-white border-transparent'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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
|
|
||||||
data-provider-node
|
|
||||||
className={cn(
|
|
||||||
'group relative w-full rounded-xl p-4 cursor-pointer transition-all duration-300',
|
|
||||||
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
|
||||||
'border border-border/50 dark:border-white/[0.08]',
|
|
||||||
hoveredAccount !== null && 'scale-[1.02]'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
borderColor: hoveredAccount !== null ? `${providerColor}50` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Connector Point */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
|
data-provider-node
|
||||||
|
className={cn(
|
||||||
|
'group relative w-full rounded-xl p-4 cursor-pointer transition-all duration-300',
|
||||||
|
'bg-muted/30 dark:bg-zinc-900/60 backdrop-blur-sm',
|
||||||
|
'border border-border/50 dark:border-white/[0.08]',
|
||||||
|
hoveredAccount !== null && 'scale-[1.02]'
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: providerColor,
|
borderColor: hoveredAccount !== null ? `${providerColor}50` : undefined,
|
||||||
boxShadow: `0 0 0 4px var(--background)`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{/* Connector Point */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -left-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
boxShadow: `0 0 0 4px var(--background)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<ProviderIcon provider={providerData.provider} size={36} withBackground />
|
<ProviderIcon provider={providerData.provider} size={36} withBackground />
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
</div>
|
{totalRequests.toLocaleString()}
|
||||||
<div className="flex justify-between items-center text-xs">
|
</span>
|
||||||
<span className="text-muted-foreground">Accounts</span>
|
</div>
|
||||||
<span className="text-foreground font-mono">{accounts.length}</span>
|
<div className="flex justify-between items-center text-xs">
|
||||||
</div>
|
<span className="text-muted-foreground">Accounts</span>
|
||||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
<span className="text-foreground font-mono">{accounts.length}</span>
|
||||||
<div
|
</div>
|
||||||
className="h-full rounded-full transition-all duration-500"
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
||||||
style={{
|
<div
|
||||||
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
|
className="h-full rounded-full transition-all duration-500"
|
||||||
backgroundColor: providerColor,
|
style={{
|
||||||
}}
|
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
|
||||||
/>
|
backgroundColor: providerColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section: Connection Timeline - Fixed compact width */}
|
||||||
|
<div className="w-56 flex-shrink-0">
|
||||||
|
<ConnectionTimeline events={connectionEvents} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail Panel (Bottom slide-up) */}
|
{/* 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')}>
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => setSelectedAccount(null)}
|
<button
|
||||||
className="absolute top-3 right-3 text-muted-foreground hover:text-foreground transition-colors"
|
onClick={() => setSelectedAccount(null)}
|
||||||
>
|
className="absolute top-0 right-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
<X className="w-4 h-4" />
|
>
|
||||||
</button>
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{selectedAccount && (
|
{selectedAccount && (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{/* Account Info */}
|
{/* Account Info */}
|
||||||
<div className="border-r border-border pr-4">
|
<div className="border-r border-border pr-4">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full"
|
className="w-2 h-2 rounded-full"
|
||||||
style={{ backgroundColor: selectedAccount.color }}
|
style={{ backgroundColor: selectedAccount.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-semibold text-foreground tracking-tight truncate">
|
<span className="text-sm font-semibold text-foreground tracking-tight truncate">
|
||||||
{cleanEmail(selectedAccount.email)}
|
{cleanEmail(selectedAccount.email)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
|
Source Account
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
|
||||||
Source Account
|
{/* Stats */}
|
||||||
|
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||||
|
<span>SUCCESSFUL</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-mono text-emerald-500 tracking-tighter">
|
||||||
|
{selectedAccount.successCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||||
|
<XCircle className="w-3 h-3 text-red-500" />
|
||||||
|
<span>FAILED</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-mono text-red-500 tracking-tighter">
|
||||||
|
{selectedAccount.failureCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>LAST SYNC</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-mono text-foreground mt-1">
|
||||||
|
{getTimeAgo(selectedAccount.lastUsedAt)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Stats */}
|
</div>
|
||||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
|
||||||
<span>SUCCESSFUL</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl font-mono text-emerald-500 tracking-tighter">
|
|
||||||
{selectedAccount.successCount.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
|
||||||
<XCircle className="w-3 h-3 text-red-500" />
|
|
||||||
<span>FAILED</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xl font-mono text-red-500 tracking-tighter">
|
|
||||||
{selectedAccount.failureCount.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-muted/30 dark:bg-zinc-900/50 rounded-lg p-3 border border-border">
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground mb-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
<span>LAST SYNC</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-mono text-foreground mt-1">
|
|
||||||
{getTimeAgo(selectedAccount.lastUsedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user