Merge pull request #940 from kaitranntt/kai/feat/live-account-monitor-paused-toggle

feat: add paused account toggle to live monitor
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-10 01:13:38 -04:00
committed by GitHub
5 changed files with 290 additions and 20 deletions
@@ -4,12 +4,15 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react';
import { ChevronRight, Eye, EyeOff, ListFilter, RotateCcw } from 'lucide-react';
interface FlowVizHeaderProps {
onBack?: () => void;
showDetails: boolean;
onToggleDetails: () => void;
showPausedAccounts: boolean;
pausedAccountsCount: number;
onTogglePausedAccounts: () => void;
hasCustomPositions: boolean;
onResetPositions: () => void;
}
@@ -18,6 +21,9 @@ export function FlowVizHeader({
onBack,
showDetails,
onToggleDetails,
showPausedAccounts,
pausedAccountsCount,
onTogglePausedAccounts,
hasCustomPositions,
onResetPositions,
}: FlowVizHeaderProps) {
@@ -48,6 +54,24 @@ export function FlowVizHeader({
{showDetails ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
<span>{showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')}</span>
</button>
{pausedAccountsCount > 0 && (
<button
onClick={onTogglePausedAccounts}
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',
showPausedAccounts
? 'bg-background text-muted-foreground hover:text-foreground border-border/60 hover:border-border hover:bg-muted/50'
: 'bg-amber-500/15 text-amber-700 border-amber-500/40 hover:bg-amber-500/20 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30'
)}
>
<ListFilter className="w-3.5 h-3.5" />
<span>
{showPausedAccounts
? t('flowViz.hidePausedAccounts', { count: pausedAccountsCount })
: t('flowViz.showPausedAccounts', { count: pausedAccountsCount })}
</span>
</button>
)}
{hasCustomPositions && (
<button
onClick={onResetPositions}
+59 -16
View File
@@ -32,17 +32,41 @@ export function AccountFlowViz({
const svgRef = useRef<SVGSVGElement>(null);
const [hoveredAccount, setHoveredAccount] = useState<number | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [showPausedAccounts, setShowPausedAccounts] = useState(true);
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 pausedAccountsCount = useMemo(
() => accounts.filter((account) => account.paused).length,
[accounts]
);
const visibleAccounts = useMemo(
() => (showPausedAccounts ? accounts : accounts.filter((account) => !account.paused)),
[accounts, showPausedAccounts]
);
const visibleAccountIds = useMemo(
() => new Set(visibleAccounts.map((account) => account.id)),
[visibleAccounts]
);
const maxRequests = Math.max(...visibleAccounts.map((a) => a.successCount + a.failureCount), 1);
const totalRequests = visibleAccounts.reduce(
(acc, a) => acc + a.successCount + a.failureCount,
0
);
const visibleProviderData = useMemo(
() => ({
...providerData,
accounts: visibleAccounts,
totalRequests,
}),
[providerData, visibleAccounts, totalRequests]
);
const calculatePaths = useCallback(() => {
const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts });
if (newPaths.length > 0) setPaths(newPaths);
}, [accounts]);
const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts: visibleAccounts });
setPaths(newPaths);
}, [visibleAccounts]);
const storageKey = `ccs-flow-positions-${providerData.provider}`;
const {
@@ -55,12 +79,20 @@ export function AccountFlowViz({
resetPositions,
hasCustomPositions,
} = useDragPositions({ storageKey, onDrag: calculatePaths });
const containerExpansion = useContainerExpansion(dragOffsets);
const pulsingAccounts = usePulseAnimation(accounts);
const visibleDragOffsets = useMemo(
() =>
Object.fromEntries(
Object.entries(dragOffsets).filter(([id]) => id === 'provider' || visibleAccountIds.has(id))
),
[dragOffsets, visibleAccountIds]
);
const containerExpansion = useContainerExpansion(visibleDragOffsets);
const hasVisibleCustomPositions = Object.keys(visibleDragOffsets).length > 0;
const pulsingAccounts = usePulseAnimation(visibleAccounts);
const connectionEvents = useMemo(
() => generateConnectionEvents(accounts).slice(0, MAX_TIMELINE_EVENTS),
[accounts]
() => generateConnectionEvents(visibleAccounts).slice(0, MAX_TIMELINE_EVENTS),
[visibleAccounts]
);
useEffect(() => {
@@ -88,19 +120,22 @@ export function AccountFlowViz({
}, [showDetails, calculatePaths]);
const providerColor = PROVIDER_COLORS[providerData.provider.toLowerCase()] || '#6b7280';
const zones = useMemo(() => splitAccountsIntoZones(accounts), [accounts]);
const zones = useMemo(() => splitAccountsIntoZones(visibleAccounts), [visibleAccounts]);
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 providerSize = useMemo(
() => getProviderSizeClass(visibleAccounts.length),
[visibleAccounts.length]
);
const renderAccountCards = (
accountList: typeof accounts,
accountList: typeof visibleAccounts,
zone: 'left' | 'right' | 'top' | 'bottom'
) =>
accountList.map((account) => {
const originalIndex = accounts.findIndex((a) => a.id === account.id);
const originalIndex = visibleAccounts.findIndex((a) => a.id === account.id);
return (
<AccountCard
key={account.id}
@@ -129,7 +164,13 @@ export function AccountFlowViz({
onBack={onBack}
showDetails={showDetails}
onToggleDetails={() => setShowDetails(!showDetails)}
hasCustomPositions={hasCustomPositions}
showPausedAccounts={showPausedAccounts}
pausedAccountsCount={pausedAccountsCount}
onTogglePausedAccounts={() => {
setHoveredAccount(null);
setShowPausedAccounts(!showPausedAccounts);
}}
hasCustomPositions={hasCustomPositions && hasVisibleCustomPositions}
onResetPositions={resetPositions}
/>
@@ -148,7 +189,7 @@ export function AccountFlowViz({
>
<FlowPaths
paths={paths}
accounts={accounts}
accounts={visibleAccounts}
maxRequests={maxRequests}
hoveredAccount={hoveredAccount}
pulsingAccounts={pulsingAccounts}
@@ -168,10 +209,12 @@ export function AccountFlowViz({
<div className={cn('z-10 flex items-center flex-shrink-0', providerSize)}>
<ProviderCard
providerData={providerData}
providerData={visibleProviderData}
providerColor={providerColor}
totalRequests={totalRequests}
maxRequests={maxRequests}
showVisibleMetrics={!showPausedAccounts && pausedAccountsCount > 0}
hiddenPausedCount={showPausedAccounts ? 0 : pausedAccountsCount}
isDragging={draggingId === 'provider'}
offset={getOffset('provider')}
hoveredAccount={hoveredAccount}
@@ -14,6 +14,8 @@ interface ProviderCardProps {
providerColor: string;
totalRequests: number;
maxRequests: number;
showVisibleMetrics?: boolean;
hiddenPausedCount?: number;
isDragging: boolean;
offset: DragOffset;
hoveredAccount: number | null;
@@ -30,6 +32,8 @@ export function ProviderCard({
providerColor,
totalRequests,
maxRequests,
showVisibleMetrics = false,
hiddenPausedCount = 0,
isDragging,
offset,
hoveredAccount,
@@ -42,6 +46,7 @@ export function ProviderCard({
}: ProviderCardProps) {
const { t } = useTranslation();
const { accounts } = providerData;
const progressDenominator = Math.max(1, maxRequests * Math.max(1, accounts.length));
return (
<div
@@ -125,22 +130,31 @@ export function ProviderCard({
<div className="space-y-2 relative z-10">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">{t('flowViz.totalRequests')}</span>
<span className="text-muted-foreground">
{showVisibleMetrics ? t('flowViz.visibleTotalRequests') : t('flowViz.totalRequests')}
</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">{t('flowViz.accounts')}</span>
<span className="text-muted-foreground">
{showVisibleMetrics ? t('flowViz.visibleAccounts') : t('flowViz.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)}%`,
width: `${Math.min(100, (totalRequests / progressDenominator) * 100)}%`,
backgroundColor: providerColor,
}}
/>
</div>
{showVisibleMetrics && hiddenPausedCount > 0 && (
<div className="text-[10px] text-muted-foreground">
{t('flowViz.excludingPausedAccounts', { count: hiddenPausedCount })}
</div>
)}
</div>
</div>
);
+20
View File
@@ -673,6 +673,11 @@ const resources = {
backToProviders: 'Back to providers',
showDetails: 'Show Details',
hideDetails: 'Hide Details',
showPausedAccounts: 'Show Paused ({{count}})',
hidePausedAccounts: 'Hide Paused ({{count}})',
visibleTotalRequests: 'Visible Requests',
visibleAccounts: 'Visible Accounts',
excludingPausedAccounts: 'Excluding {{count}} paused',
resetLayout: 'Reset layout',
provider: 'Provider',
totalRequests: 'Total Requests',
@@ -1986,6 +1991,11 @@ const resources = {
backToProviders: '返回提供商',
showDetails: '显示详情',
hideDetails: '隐藏详情',
showPausedAccounts: '显示已暂停 ({{count}})',
hidePausedAccounts: '隐藏已暂停 ({{count}})',
visibleTotalRequests: '可见请求数',
visibleAccounts: '可见账号',
excludingPausedAccounts: '已排除 {{count}} 个已暂停',
resetLayout: '重置布局',
provider: '提供商',
totalRequests: '总请求数',
@@ -3345,6 +3355,11 @@ const resources = {
backToProviders: 'Quay lại nhà cung cấp',
showDetails: 'Hiển thị chi tiết',
hideDetails: 'Ẩn chi tiết',
showPausedAccounts: 'Hiện tạm dừng ({{count}})',
hidePausedAccounts: 'Ẩn tạm dừng ({{count}})',
visibleTotalRequests: 'Yêu cầu hiển thị',
visibleAccounts: 'Tài khoản hiển thị',
excludingPausedAccounts: 'Đang loại trừ {{count}} tài khoản tạm dừng',
resetLayout: 'Đặt lại bố cục',
provider: 'Nhà cung cấp',
totalRequests: 'Tổng số yêu cầu',
@@ -4729,6 +4744,11 @@ const resources = {
backToProviders: 'プロバイダーに戻る',
showDetails: '詳細を表示',
hideDetails: '詳細を隠す',
showPausedAccounts: '一時停止を表示 ({{count}})',
hidePausedAccounts: '一時停止を非表示 ({{count}})',
visibleTotalRequests: '表示中のリクエスト',
visibleAccounts: '表示中のアカウント',
excludingPausedAccounts: '一時停止 {{count}} 件を除外中',
resetLayout: 'レイアウトをリセット',
provider: 'プロバイダー',
totalRequests: '総リクエスト数',
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { AccountFlowViz } from '@/components/account-flow-viz';
import type { ProviderData } from '@/components/account/flow-viz/types';
vi.mock(import('react-i18next'), async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { count?: number }) => {
const translations: Record<string, string> = {
'flowViz.showDetails': 'Show Details',
'flowViz.hideDetails': 'Hide Details',
'flowViz.backToProviders': 'Back to providers',
'flowViz.resetLayout': 'Reset layout',
'flowViz.provider': 'Provider',
'flowViz.totalRequests': 'Total Requests',
'flowViz.accounts': 'Accounts',
'flowViz.visibleTotalRequests': 'Visible Requests',
'flowViz.visibleAccounts': 'Visible Accounts',
};
if (key === 'flowViz.showPausedAccounts') {
return `Show Paused (${options?.count ?? 0})`;
}
if (key === 'flowViz.hidePausedAccounts') {
return `Hide Paused (${options?.count ?? 0})`;
}
if (key === 'flowViz.excludingPausedAccounts') {
return `Excluding ${options?.count ?? 0} paused`;
}
return translations[key] ?? key;
},
}),
};
});
vi.mock('@/components/account/flow-viz/account-card', () => ({
AccountCard: ({
account,
originalIndex,
zone,
}: {
account: { email: string; paused?: boolean };
originalIndex: number;
zone: string;
}) => (
<div data-account-index={originalIndex} data-zone={zone}>
{account.email}
{account.paused ? ' (paused)' : ' (active)'}
</div>
),
}));
vi.mock('@/components/shared/provider-icon', () => ({
ProviderIcon: ({ provider }: { provider: string }) => <div>{provider}</div>,
}));
vi.mock('@/components/account/flow-viz/connection-timeline', () => ({
ConnectionTimeline: ({ events }: { events: Array<{ id: string }> }) => (
<div>{events.length} timeline events</div>
),
}));
vi.mock('@/components/account/flow-viz/flow-paths', () => ({
FlowPaths: ({ accounts }: { accounts: Array<{ id: string }> }) => (
<div>paths:{accounts.map((account) => account.id).join(',')}</div>
),
}));
vi.mock('@/components/account/flow-viz/path-utils', () => ({
calculateBezierPaths: () => [],
}));
const providerData: ProviderData = {
provider: 'codex',
displayName: 'OpenAI Codex',
totalRequests: 7,
accounts: [
{
id: 'paused-account',
email: 'paused@company.com',
provider: 'codex',
successCount: 4,
failureCount: 0,
color: '#f59e0b',
paused: true,
},
{
id: 'active-account',
email: 'active@company.com',
provider: 'codex',
successCount: 2,
failureCount: 1,
color: '#10a37f',
},
],
};
describe('AccountFlowViz paused account visibility', () => {
beforeEach(() => {
vi.mocked(window.localStorage.getItem).mockReturnValue(null);
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 0)
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('hides and restores paused accounts from the provider detail visualization', async () => {
render(<AccountFlowViz providerData={providerData} />);
expect(screen.getByRole('button', { name: 'Show Details' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument();
expect(screen.getByText('active@company.com (active)')).toBeInTheDocument();
expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument();
expect(screen.getByText('Total Requests')).toBeInTheDocument();
expect(screen.getByText('Accounts')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('7 timeline events')).toBeInTheDocument();
expect(screen.getByText('paths:paused-account,active-account')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Hide Paused (1)' }));
expect(screen.getByRole('button', { name: 'Show Paused (1)' })).toBeInTheDocument();
expect(screen.getByText('active@company.com (active)')).toBeInTheDocument();
expect(screen.queryByText('paused@company.com (paused)')).not.toBeInTheDocument();
expect(screen.getByText('Visible Requests')).toBeInTheDocument();
expect(screen.getByText('Visible Accounts')).toBeInTheDocument();
expect(screen.getByText('Excluding 1 paused')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('3 timeline events')).toBeInTheDocument();
expect(screen.getByText('paths:active-account')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Show Paused (1)' }));
expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument();
expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument();
expect(screen.queryByText('Excluding 1 paused')).not.toBeInTheDocument();
});
it('ignores hidden paused-account drag offsets when deciding whether reset layout should stay visible', async () => {
vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
key === 'ccs-flow-positions-codex'
? JSON.stringify({
'paused-account': { x: 0, y: 160 },
})
: null
);
render(<AccountFlowViz providerData={providerData} />);
expect(screen.getByRole('button', { name: 'Reset layout' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Hide Paused (1)' }));
expect(screen.queryByRole('button', { name: 'Reset layout' })).not.toBeInTheDocument();
});
});