mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-14 02:22:04 +00:00
refactor(ui): split account-flow-viz into account/flow-viz/ directory
- extract 1,144-line monster file into 12 focused modules - add hooks.ts, types.ts, utils.ts for reusable logic - create backward-compatible re-export shim - add barrel export in account/index.ts
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<Account>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
size: 200,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{row.original.name}</span>
|
||||||
|
{row.original.name === defaultAccount && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded border border-primary/20">
|
||||||
|
default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'type',
|
||||||
|
header: 'Type',
|
||||||
|
size: 100,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="capitalize text-muted-foreground">{row.original.type || 'oauth'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created',
|
||||||
|
header: 'Created',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = new Date(row.original.created);
|
||||||
|
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'last_used',
|
||||||
|
header: 'Last Used',
|
||||||
|
size: 150,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.last_used) return <span className="text-muted-foreground/50">-</span>;
|
||||||
|
const date = new Date(row.original.last_used);
|
||||||
|
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
size: 100,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isDefault = row.original.name === defaultAccount;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isDefault ? 'secondary' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 w-full"
|
||||||
|
disabled={isDefault || setDefaultMutation.isPending}
|
||||||
|
onClick={() => setDefaultMutation.mutate(row.original.name)}
|
||||||
|
>
|
||||||
|
<Check className={`w-3 h-3 mr-1.5 ${isDefault ? 'opacity-50' : ''}`} />
|
||||||
|
{isDefault ? 'Active' : 'Set Default'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No accounts found. Use <code className="text-sm bg-muted px-1 rounded">ccs login</code> to
|
||||||
|
add accounts.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{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 (
|
||||||
|
<TableHead key={header.id} className={widthClass}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Click the button below to authenticate a new account. A browser window will open for
|
||||||
|
OAuth.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nickname">Nickname (optional)</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
placeholder="e.g., work, personal"
|
||||||
|
disabled={startAuthMutation.isPending}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A friendly name to identify this account. Auto-generated from email if left empty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={startAuthMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleStartAuth} disabled={startAuthMutation.isPending}>
|
||||||
|
{startAuthMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Authenticating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Authenticate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{startAuthMutation.isPending && (
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
Complete the OAuth flow in your browser...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{/* Primary Row: Success Rate & Total */}
|
||||||
|
<div className="flex items-end justify-between px-0.5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||||
|
Success Rate
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-mono font-bold leading-none mt-0.5',
|
||||||
|
successRate === 100
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: successRate >= 90
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-red-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Math.round(successRate)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end">
|
||||||
|
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||||
|
Volume
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono font-medium text-foreground/80 leading-none mt-0.5">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detailed Stats - Collapsible */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-2 gap-2 overflow-hidden transition-all duration-300 ease-in-out',
|
||||||
|
showDetails ? 'max-h-20 opacity-100 mt-2' : 'max-h-0 opacity-0 mt-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 px-1.5 py-1 rounded-md bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/10">
|
||||||
|
<CheckCircle2 className="w-2.5 h-2.5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
<span className="text-[10px] font-mono font-bold text-emerald-600 dark:text-emerald-400">
|
||||||
|
{success}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-1.5 py-1 rounded-md border',
|
||||||
|
failure > 0
|
||||||
|
? 'bg-red-500/5 dark:bg-red-500/10 border-red-500/20'
|
||||||
|
: 'bg-muted/10 border-transparent opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<XCircle
|
||||||
|
className={cn('w-2.5 h-2.5', failure > 0 ? 'text-red-500' : 'text-muted-foreground')}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-mono font-bold',
|
||||||
|
failure > 0 ? 'text-red-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{failure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Zone, string> = {
|
||||||
|
left: 'border-l-2',
|
||||||
|
right: 'border-r-2',
|
||||||
|
top: 'border-t-2',
|
||||||
|
bottom: 'border-b-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTOR_POSITION_MAP: Record<Zone, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
data-account-index={originalIndex}
|
||||||
|
data-zone={zone}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
className={cn(
|
||||||
|
'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]',
|
||||||
|
borderSide,
|
||||||
|
'select-none touch-none',
|
||||||
|
isHovered && 'bg-muted/50 dark:bg-zinc-800/60',
|
||||||
|
isDragging && 'cursor-grabbing shadow-xl scale-105 z-50'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...borderColor,
|
||||||
|
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
|
||||||
|
<div className="flex justify-between items-start mb-1 mr-4">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
|
||||||
|
privacyMode && PRIVACY_BLUR_CLASS
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cleanEmail(account.email)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AccountCardStats
|
||||||
|
success={account.successCount}
|
||||||
|
failure={account.failureCount}
|
||||||
|
showDetails={showDetails}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||||
|
'bg-muted dark:bg-zinc-800 border-border dark:border-zinc-600',
|
||||||
|
connectorPosition,
|
||||||
|
isHovered && 'bg-foreground dark:bg-white border-transparent'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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.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={cn(
|
||||||
|
'text-[10px] font-mono text-foreground truncate',
|
||||||
|
privacyMode && PRIVACY_BLUR_CLASS
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowPaths({
|
||||||
|
paths,
|
||||||
|
accounts,
|
||||||
|
maxRequests,
|
||||||
|
hoveredAccount,
|
||||||
|
pulsingAccounts,
|
||||||
|
}: FlowPathsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="flow-glow"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
width="140%"
|
||||||
|
height="140%"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
{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 (
|
||||||
|
<g key={i}>
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={connectionColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeOpacity={isHovered ? 0.8 : isDimmed ? 0.15 : 0.4}
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter={isHovered ? 'url(#flow-glow)' : undefined}
|
||||||
|
className="transition-all duration-300"
|
||||||
|
/>
|
||||||
|
{isPulsing && (
|
||||||
|
<>
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={account.color}
|
||||||
|
strokeWidth={strokeWidth * 2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter="url(#flow-glow)"
|
||||||
|
className="animate-request-pulse"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
r={6}
|
||||||
|
fill={account.color}
|
||||||
|
filter="url(#flow-glow)"
|
||||||
|
style={{
|
||||||
|
offsetPath: `path('${d}')`,
|
||||||
|
offsetDistance: '0%',
|
||||||
|
animation: 'travel-dot 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
{onBack ? (
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="group flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border/50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 rotate-180 transition-transform group-hover:-translate-x-0.5" />
|
||||||
|
<span>Back to providers</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggleDetails}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-xs font-medium transition-all duration-200 px-3 py-1.5 rounded-md border shadow-sm',
|
||||||
|
showDetails
|
||||||
|
? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'
|
||||||
|
: 'bg-background text-muted-foreground hover:text-foreground border-border/60 hover:border-border hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||||
|
<span>{showDetails ? 'Hide Details' : 'Show Details'}</span>
|
||||||
|
</button>
|
||||||
|
{hasCustomPositions && (
|
||||||
|
<button
|
||||||
|
onClick={onResetPositions}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-200 px-3 py-1.5 rounded-md border border-border/60 hover:border-border bg-background hover:bg-muted/50 shadow-sm"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
<span>Reset layout</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, DragOffset>;
|
||||||
|
draggingId: string | null;
|
||||||
|
didDragRef: React.MutableRefObject<boolean>;
|
||||||
|
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<string | null>(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<string, DragOffset> => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (saved) return JSON.parse(saved);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>(() =>
|
||||||
|
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<string, DragOffset>): 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<string> {
|
||||||
|
const [pulsingAccounts, setPulsingAccounts] = useState<Set<string>>(new Set());
|
||||||
|
const prevCountsRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
// Detect new activity and trigger pulse animation
|
||||||
|
useEffect(() => {
|
||||||
|
const newPulsing = new Set<string>();
|
||||||
|
const newCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [paths, setPaths] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
zone={zone}
|
||||||
|
originalIndex={originalIndex}
|
||||||
|
isHovered={hoveredAccount === originalIndex}
|
||||||
|
isDragging={draggingId === account.id}
|
||||||
|
offset={getOffset(account.id)}
|
||||||
|
showDetails={showDetails}
|
||||||
|
privacyMode={privacyMode}
|
||||||
|
onMouseEnter={() => setHoveredAccount(originalIndex)}
|
||||||
|
onMouseLeave={() => setHoveredAccount(null)}
|
||||||
|
onPointerDown={(e) => handlePointerDown(account.id, e)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col" ref={containerRef}>
|
||||||
|
<FlowVizHeader
|
||||||
|
onBack={onBack}
|
||||||
|
showDetails={showDetails}
|
||||||
|
onToggleDetails={() => setShowDetails(!showDetails)}
|
||||||
|
hasCustomPositions={hasCustomPositions}
|
||||||
|
onResetPositions={resetPositions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-[320px] flex gap-4 px-4 py-6 self-stretch items-stretch transition-all duration-200">
|
||||||
|
<div
|
||||||
|
className="relative flex-1 flex flex-col items-stretch justify-center px-4"
|
||||||
|
style={{
|
||||||
|
paddingTop: `${24 + containerExpansion.paddingTop}px`,
|
||||||
|
paddingBottom: `${24 + containerExpansion.paddingBottom}px`,
|
||||||
|
minHeight: `${320 + containerExpansion.extraHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="absolute inset-0 w-full h-full pointer-events-none z-0 overflow-visible"
|
||||||
|
>
|
||||||
|
<FlowPaths
|
||||||
|
paths={paths}
|
||||||
|
accounts={accounts}
|
||||||
|
maxRequests={maxRequests}
|
||||||
|
hoveredAccount={hoveredAccount}
|
||||||
|
pulsingAccounts={pulsingAccounts}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hasTopAccounts && (
|
||||||
|
<div className="flex flex-row gap-3 z-10 justify-center flex-wrap mb-8">
|
||||||
|
{renderAccountCards(topAccounts, 'top')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-8 flex-1">
|
||||||
|
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
|
||||||
|
{renderAccountCards(leftAccounts, 'left')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn('z-10 flex items-center flex-shrink-0', providerSize)}>
|
||||||
|
<ProviderCard
|
||||||
|
providerData={providerData}
|
||||||
|
providerColor={providerColor}
|
||||||
|
totalRequests={totalRequests}
|
||||||
|
maxRequests={maxRequests}
|
||||||
|
isDragging={draggingId === 'provider'}
|
||||||
|
offset={getOffset('provider')}
|
||||||
|
hoveredAccount={hoveredAccount}
|
||||||
|
hasRightAccounts={hasRightAccounts}
|
||||||
|
hasTopAccounts={hasTopAccounts}
|
||||||
|
hasBottomAccounts={hasBottomAccounts}
|
||||||
|
onPointerDown={(e) => handlePointerDown('provider', e)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasRightAccounts && (
|
||||||
|
<div className="flex flex-col gap-3 z-10 w-48 justify-center flex-shrink-0">
|
||||||
|
{renderAccountCards(rightAccounts, 'right')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasBottomAccounts && (
|
||||||
|
<div className="flex flex-row gap-3 z-10 justify-center flex-wrap mt-8">
|
||||||
|
{renderAccountCards(bottomAccounts, 'bottom')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-56 flex-shrink-0 self-stretch relative">
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* SVG path calculation utilities for bezier curves
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AccountData, AccountZone } from './types';
|
||||||
|
|
||||||
|
interface PathCalculationParams {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
svgRef: React.RefObject<SVGSVGElement | null>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
data-provider-node
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
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]',
|
||||||
|
!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
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-xl animate-glow-pulse pointer-events-none"
|
||||||
|
style={{ '--glow-color': `${providerColor}30` } as React.CSSProperties}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Connector Points */}
|
||||||
|
<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)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{hasRightAccounts && (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -right-1.5 w-3 h-3 rounded-full transform -translate-y-1/2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
boxShadow: `0 0 0 4px var(--background)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasTopAccounts && (
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -top-1.5 w-3 h-3 rounded-full transform -translate-x-1/2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
boxShadow: `0 0 0 4px var(--background)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasBottomAccounts && (
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -bottom-1.5 w-3 h-3 rounded-full transform -translate-x-1/2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
boxShadow: `0 0 0 4px var(--background)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4 relative z-10">
|
||||||
|
<div className="animate-icon-breathe">
|
||||||
|
<ProviderIcon provider={providerData.provider} size={36} withBackground />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground tracking-tight">
|
||||||
|
{providerData.displayName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-muted-foreground font-medium uppercase">Provider</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 relative z-10">
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-muted-foreground">Total Requests</span>
|
||||||
|
<span className="text-foreground font-mono">{totalRequests.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-muted-foreground">Accounts</span>
|
||||||
|
<span className="text-foreground font-mono">{accounts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full mt-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (totalRequests / (maxRequests * accounts.length)) * 100)}%`,
|
||||||
|
backgroundColor: providerColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user