mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +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