mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user