fix(ui): привязать детали трафика к выбранному контексту

This commit is contained in:
Denozordec committed 2026-09-24 01:18:13 +07:00
1 parent 9eb6a3fad1
commit 480aa82f78
3 files changed
+95 -16

No files matched your search

+30 -16
View File
@@ -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<LiveTrafficInterface[]>([])
const [selectedIface, setSelectedIface] = useState("__all__")
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
const [detailBusy, setDetailBusy] = useState(false)
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
const [detailRetry, setDetailRetry] = useState(0)
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
@@ -816,6 +817,9 @@ export default function TrafficPage() {
const [overlayOpen, setOverlayOpen] = useState(false)
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
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() {
</div>
</div>
)}
{isLive && effectiveMode === "servers" && !detailServer && <>
<DetailHeader range={range} setRange={setRange}><h2 className="text-base font-semibold">{selServer?.name ?? "Трафик сервера"}</h2></DetailHeader>
{detailBusy && <p role="status" className="text-sm text-muted-foreground">Загрузка выбранного периода и интерфейса…</p>}
{currentDetail?.error && <Alert variant="destructive" role="alert"><AlertTitle>Детали трафика недоступны</AlertTitle><AlertDescription>{currentDetail.error}<Button type="button" size="sm" variant="outline" onClick={() => setDetailRetry(n => n + 1)}>Повторить загрузку деталей</Button></AlertDescription></Alert>}
</>}
{effectiveMode === "servers" && detailServer && (
<ServerDetail
sel={detailServer}
+7
View File
@@ -29,6 +29,13 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.3Б1 — контекст деталей трафика (2026-09-24)
- Детали привязаны к backend, режиму, серверу, периоду, интерфейсу и повтору запроса. При смене контекста прежние метрики/график скрыты, период остаётся доступен для выбора. Поздний ответ отменённого эффекта не применяется; список интерфейсов также игнорирует ответ прежнего сервера.
- Ошибка деталей показывает Alert и «Повторить загрузку деталей», сохраняя выбранный период/интерфейс. Общая сводка сервера больше не подставляется вместо неудавшегося запроса отдельного интерфейса.
- 5 E2E деталей/SSE прошли: задержанный период, ошибка интерфейса и повтор плюс предыдущие проверки потока. ESLint теста чистый; traffic — прежние 3 ошибки/1 предупреждение. Production build и TypeScript прошли: 34 страницы. Локальный коммит без push.
- Далее 8.3Б2: общие списки/сводка трафика и гонки при смене периода/режима; затем flow-аналитика. Проверка позднего списка интерфейсов отдельным E2E ещё не выполнена. На контрольной точке использовано 23% пятичасового и 50% недельного лимита.
## Блок 8.3А — достоверность live-подписей трафика (2026-09-24)
- useTrafficLive сообщает завершение SSE, проверяет отмену после fetch/read и не применяет некорректные sample (нечисловые/отрицательные RX/TX или неверное время). Последний корректный sample сохраняется при сбое, но подписан «Последнее live-измерение · поток недоступен»; история при отсутствии sample помечена отдельно, включая ожидание live.
+58
View File
@@ -0,0 +1,58 @@
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { holdRange: '', failIface: false, release: () => {} }
const gate = new Promise<void>(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()
})