diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 7a58736d..521296d1 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react" +import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { PageHeader } from "@/components/page-header" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" @@ -800,8 +801,8 @@ export default function TrafficPage() { const [serverIfaces, setServerIfaces] = useState([]) const [selectedIface, setSelectedIface] = useState("__all__") const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true) - const [detailBusy, setDetailBusy] = useState(false) - const [liveDetailServer, setLiveDetailServer] = useState(null) + const [detailRetry, setDetailRetry] = useState(0) + const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null) const [liveUsers, setLiveUsers] = useState([]) const [liveBoundIfaces, setLiveBoundIfaces] = useState([]) const [flowStats, setFlowStats] = useState(null) @@ -816,6 +817,9 @@ export default function TrafficPage() { const [overlayOpen, setOverlayOpen] = useState(false) const [catalogServers, setCatalogServers] = useState([]) const effectiveMode: GroupMode = groupMode + const detailKey = JSON.stringify([isLive, backendUrl, effectiveMode, selectedId, range, selectedIface, detailRetry]) + const currentDetail = detailResult?.key === detailKey ? detailResult : null + const detailBusy = isLive && effectiveMode === "servers" && Boolean(selectedId) && currentDetail === null const { sample: liveSample, error: liveStreamError } = useTrafficLive({ enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId), backendUrl, @@ -993,30 +997,35 @@ export default function TrafficPage() { // eslint-disable-next-line react-hooks/set-state-in-effect setServerIfaces([]) setSelectedIface("__all__") - setLiveDetailServer(null) return } + let cancelled = false + setServerIfaces([]) setSelectedIface("__all__") apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`) - .then((res) => setServerIfaces(res.interfaces)) - .catch(() => setServerIfaces([])) + .then((res) => { if (!cancelled) setServerIfaces(res.interfaces) }) + .catch(() => { if (!cancelled) setServerIfaces([]) }) + return () => { cancelled = true } }, [isLive, selectedId, effectiveMode, apiFetch]) useEffect(() => { if (!isLive || effectiveMode !== "servers" || !selectedId) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setLiveDetailServer(null) return } - setDetailBusy(true) + let cancelled = false apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`) - .then((res) => setLiveDetailServer({ - ...toLiveServer(res.server), - boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId), - })) - .catch(() => setLiveDetailServer(null)) - .finally(() => setDetailBusy(false)) - }, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces]) + .then((res) => { + if (cancelled) return + setDetailResult({ key: detailKey, error: null, server: { + ...toLiveServer(res.server), + boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId), + } }) + }) + .catch((error: unknown) => { + if (!cancelled) setDetailResult({ key: detailKey, server: null, error: error instanceof Error ? error.message : "Не удалось загрузить детали трафика" }) + }) + return () => { cancelled = true } + }, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces, detailKey]) const handleModeChange = (next: GroupMode) => { setGroupMode(next) @@ -1106,7 +1115,7 @@ export default function TrafficPage() { const displayedFlow = flowLiveSample ?? flowAnalytics const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) - const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer + const detailServer = isLive ? currentDetail?.server : selServer const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic]) const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces]) @@ -1457,6 +1466,11 @@ export default function TrafficPage() { )} + {isLive && effectiveMode === "servers" && !detailServer && <> +

{selServer?.name ?? "Трафик сервера"}

+ {detailBusy &&

Загрузка выбранного периода и интерфейса…

} + {currentDetail?.error && Детали трафика недоступны{currentDetail.error}} + } {effectiveMode === "servers" && detailServer && ( {} } + const gate = new Promise(resolve => { control.release = resolve }) + const server = { id: '1', name: 'Первый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] } + await page.route('**/health', route => route.fulfill({ json: { ok: true } })) + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()), path = url.pathname + if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} }) + if (path === '/api/traffic/servers/1') { + const range = url.searchParams.get('range'), iface = url.searchParams.get('iface') + if (control.holdRange && range === control.holdRange) await gate + if (control.failIface && iface === 'ether2') return route.fulfill({ status: 500, json: { error: 'Интерфейс временно недоступен' } }) + return route.fulfill({ json: { server: { ...server, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } } }) + } + const json = path === '/api/auth/config' ? { required: false } + : path === '/api/traffic/servers' ? { servers: [server] } + : path === '/api/traffic/servers/1/interfaces' ? { interfaces: [{ name: 'ether2', running: true, disabled: false, rxNow: 0, txNow: 0 }] } + : path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] } + : path === '/api/traffic/users' ? { users: [] } + : path === '/api/servers' ? [] : {} + await route.fulfill({ json }) + }) + await page.goto('/traffic') + await expect(page.getByRole('heading', { name: 'Детали 1h __all__', exact: true })).toBeVisible() + return control +} + +test('Детали трафика: поздний период не заменяет текущий', async ({ page }) => { + const control = await setup(page) + control.holdRange = '24h' + const started = page.waitForRequest(r => new URL(r.url()).pathname === '/api/traffic/servers/1' && r.url().includes('range=24h')) + await page.getByRole('button', { name: '24ч', exact: true }).click() + await started + await expect(page.getByText('Загрузка выбранного периода и интерфейса…', { exact: true })).toBeVisible() + await expect(page.getByLabel('Скорость выбранного сервера')).toHaveCount(0) + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Детали 15m __all__', exact: true })).toBeVisible() + const late = page.waitForResponse(r => new URL(r.url()).pathname === '/api/traffic/servers/1' && r.url().includes('range=24h')) + control.release() + await late + await expect(page.getByRole('heading', { name: 'Детали 15m __all__', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Детали 24h __all__', exact: true })).toHaveCount(0) +}) + +test('Детали трафика: ошибка интерфейса не подменяется общими данными; повтор сохраняет контекст', async ({ page }) => { + const control = await setup(page) + control.failIface = true + await page.getByRole('button', { name: 'ether2', exact: true }).click() + await expect(page.getByRole('alert').filter({ hasText: 'Детали трафика недоступны' })).toContainText('Интерфейс временно недоступен') + await expect(page.getByLabel('Скорость выбранного сервера')).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'Детали 1h __all__', exact: true })).toHaveCount(0) + control.failIface = false + await page.getByRole('button', { name: 'Повторить загрузку деталей', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Детали 1h ether2', exact: true })).toBeVisible() + await expect(page.getByLabel('Скорость выбранного сервера')).toBeVisible() +})