From 94dd453b33c11981be373c2e664ce1b6d6f6f6e1 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 01:25:20 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0=20=D0=BE=D1=82=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/traffic/page.tsx | 178 ++++++++++++++++----------------- docs/ux-ui-progress.md | 7 ++ tests/e2e/traffic-list.spec.ts | 63 ++++++++++++ 3 files changed, 157 insertions(+), 91 deletions(-) create mode 100644 tests/e2e/traffic-list.spec.ts diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index 521296d1..445a55c3 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -784,6 +784,44 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod { field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] }, ] +const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { + return { + id: String(s.id), + name: s.name, + site: s.site || "—", + country: s.country || "UN", + status: s.status, + rxNow: s.rxNow, + txNow: s.txNow, + rxPeak: s.rxPeak, + txPeak: s.txPeak, + rxTotal: s.rxTotal, + txTotal: s.txTotal, + sessions: s.sessions, + rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0), + txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0), + boundIfaces: [], + } +} + +const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({ + ...c, + interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType, + rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0), + txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0), +}) + +const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => { + const ifaces = (u.interfaces ?? []).map(toLiveBound) + return { + ...u, + role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role, + interfaces: ifaces, + rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0), + txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0), + } +} + export default function TrafficPage() { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" @@ -795,6 +833,12 @@ export default function TrafficPage() { const [selectedId, setSelectedId] = useState("srv1") const [range, setRange] = useState("1h") const [search, setSearch] = useState("") + const [trafficRetry, setTrafficRetry] = useState(0) + const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry]) + const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; error?: string } | null>(null) + const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready" + const trafficLoading = trafficState?.key !== trafficKey + const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined const [liveServers, setLiveServers] = useState([]) const [liveBusy, setLiveBusy] = useState(false) const [liveError, setLiveError] = useState(null) @@ -839,87 +883,41 @@ export default function TrafficPage() { excludeOverlay: flowExcludeOverlay, }) - const toLiveServer = (s: LiveTrafficServer): ServerTraffic => { - return { - id: String(s.id), - name: s.name, - site: s.site || "—", - country: s.country || "UN", - status: s.status, - rxNow: s.rxNow, - txNow: s.txNow, - rxPeak: s.rxPeak, - txPeak: s.txPeak, - rxTotal: s.rxTotal, - txTotal: s.txTotal, - sessions: s.sessions, - rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0), - txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0), - boundIfaces: [], - } - } - - const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({ - ...c, - interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType, - rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0), - txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0), - }) - - const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => { - const ifaces = (u.interfaces ?? []).map(toLiveBound) - return { - ...u, - role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role, - interfaces: ifaces, - rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0), - txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0), - } - } - - const loadLiveTraffic = useCallback(async (targetRange: Range) => { - if (!isLive) return - setLiveBusy(true) - setLiveError(null) - try { - const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange) - const [srvRes, usersRes, ifacesRes] = await Promise.all([ - apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`), - apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`), - apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`), - ]) - const liveIfaces = ifacesRes.interfaces.map(toLiveBound) - const mapped: ServerTraffic[] = srvRes.servers.map((s) => { - const base = toLiveServer(s) - return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) } - }) - const mappedUsers = usersRes.users.map(toLiveUser) - setLiveServers(mapped) - setLiveUsers(mappedUsers) - setLiveBoundIfaces(liveIfaces) - setSelectedId((prev) => { - if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "") - if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "") - return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "") - }) - } catch (e) { - setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик") - } finally { - setLiveBusy(false) - } - }, [apiFetch, isLive, groupMode]) - useEffect(() => { - if (!isLive) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setLiveServers([]) - setLiveUsers([]) - setLiveBoundIfaces([]) - setLiveError(null) - return + if (!isLive || groupMode === "flows") return + let cancelled = false + const loadLiveTraffic = async (targetRange: Range) => { + const key = JSON.stringify([isLive, backendUrl, targetRange, groupMode, trafficRetry]) + try { + const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange) + const [srvRes, usersRes, ifacesRes] = await Promise.all([ + apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`), + apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`), + apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`), + ]) + if (cancelled) return + const liveIfaces = ifacesRes.interfaces.map(toLiveBound) + const mapped: ServerTraffic[] = srvRes.servers.map((s) => { + const base = toLiveServer(s) + return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) } + }) + const mappedUsers = usersRes.users.map(toLiveUser) + setLiveServers(mapped) + setLiveUsers(mappedUsers) + setLiveBoundIfaces(liveIfaces) + setTrafficState({ key, status: "ready" }) + setSelectedId((prev) => { + if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "") + if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "") + return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "") + }) + } catch (e) { + if (!cancelled) setTrafficState({ key, status: "error", error: e instanceof Error ? e.message : "Не удалось загрузить трафик" }) + } } void loadLiveTraffic(range) - }, [isLive, range, loadLiveTraffic]) + return () => { cancelled = true } + }, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range]) const loadFlows = useCallback(async () => { if (!isLive) return @@ -984,9 +982,9 @@ export default function TrafficPage() { void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([])) }, [isLive, backendUrl]) - const activeServerTraffic = isLive ? liveServers : serverTraffic - const activeUserTraffic = isLive ? liveUsers : userTraffic - const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces + const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers : []) : serverTraffic, [isLive, trafficReady, liveServers]) + const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers : []) : userTraffic, [isLive, trafficReady, liveUsers]) + const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces]) const visibleIfaces = useMemo( () => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces, [serverIfaces, hideDisabledIfaces], @@ -1210,11 +1208,11 @@ export default function TrafficPage() { size="sm" onClick={() => { if (effectiveMode === "flows") void loadFlows() - else void loadLiveTraffic(range) + else setTrafficRetry(value => value + 1) }} - disabled={isLive && liveBusy} + disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)} > - Обновить + Обновить @@ -1240,10 +1238,11 @@ export default function TrafficPage() {
- + />} {effectiveMode === "flows" ? (
@@ -1394,11 +1393,8 @@ export default function TrafficPage() { ))}
- {liveError && ( -
- {liveError} -
- )} + {isLive && trafficLoading &&

Загрузка списка трафика…

} + {trafficError && Список трафика недоступен{trafficError}} {/* entity list */}
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 5778b294..0cec5e33 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,6 +29,13 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 8.3Б2 — общие списки трафика (2026-09-24) + +- Списки серверов, пользователей и интерфейсов и их сводка привязаны к backend, режиму, периоду и повтору загрузки. Ответ отменённого эффекта не меняет данные и выбранный объект. Flow-запросы больше не делят состояние загрузки/ошибки с этими списками. +- Во время запроса показаны загрузка списка и skeleton сводки. При ошибке прежние карточки и сводка скрыты; Alert содержит причину и повтор с сохранением контекста. Подтверждённые детали сервера остаются отдельным запросом. +- 7 E2E прошли: поздний список за старый период, ошибка обновления и повтор, предыдущие проверки деталей и SSE. ESLint нового теста чистый; traffic — 3 прежние ошибки flow-эффектов, прежнее предупреждение зависимостей устранено. Production build/TypeScript прошли, 34 страницы. HTTP-моки не подтверждают реальный сбор RouterOS/IPFIX. +- Следующий шаг: 8.3Б3 — ключи контекста, ошибки и поздние ответы flow-аналитики и useFlowLive. Затем неизвестные значения, пустые состояния и мобильная приёмка 8.4. Смена режима/backend именно для списка ещё не покрыта отдельным E2E. На контрольной точке использовано 46% пятичасового и 54% недельного лимита. + ## Блок 8.3Б1 — контекст деталей трафика (2026-09-24) - Детали привязаны к backend, режиму, серверу, периоду, интерфейсу и повтору запроса. При смене контекста прежние метрики/график скрыты, период остаётся доступен для выбора. Поздний ответ отменённого эффекта не применяется; список интерфейсов также игнорирует ответ прежнего сервера. diff --git a/tests/e2e/traffic-list.spec.ts b/tests/e2e/traffic-list.spec.ts new file mode 100644 index 00000000..da68a784 --- /dev/null +++ b/tests/e2e/traffic-list.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, type Page } from '@playwright/test' + +async function setup(page: Page) { + const control = { holdRange: '', failList: false, release: () => {} } + 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') + return route.fulfill({ json: { server: { ...server, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } } }) + } + if (path === '/api/traffic/servers') { + const range = url.searchParams.get('range') + if (control.holdRange === range) await gate + if (control.failList) return route.fulfill({ status: 500, json: { error: 'Сводка временно недоступна' } }) + return route.fulfill({ json: { servers: [{ ...server, name: 'Список ' + range }] } }) + } + 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' && 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.getByRole('button').filter({ hasText: 'Список 1h' })).toHaveCount(0) + await page.getByRole('button', { name: '15м', exact: true }).click() + await expect(page.getByRole('button').filter({ hasText: 'Список 15m' })).toBeVisible() + const late = page.waitForResponse(r => new URL(r.url()).pathname === '/api/traffic/servers' && r.url().includes('range=24h')) + control.release() + await late + await expect(page.getByRole('button').filter({ hasText: 'Список 15m' })).toBeVisible() + await expect(page.getByRole('button').filter({ hasText: 'Список 24h' })).toHaveCount(0) +}) + +test('Список трафика: ошибка обновления скрывает сводку; повтор восстанавливает список', async ({ page }) => { + const control = await setup(page) + control.failList = true + await page.getByRole('button', { name: 'Обновить', exact: true }).click() + await expect(page.getByRole('alert').filter({ hasText: 'Список трафика недоступен' })).toContainText('Сводка временно недоступна') + await expect(page.getByLabel('Сводка трафика')).toHaveCount(0) + await expect(page.getByRole('button').filter({ hasText: 'Список 1h' })).toHaveCount(0) + control.failList = false + await page.getByRole('button', { name: 'Повторить загрузку списка', exact: true }).click() + await expect(page.getByRole('button').filter({ hasText: 'Список 1h' })).toBeVisible() + await expect(page.getByLabel('Сводка трафика')).toBeVisible() +})