fix(ui): привязать детали трафика к выбранному контексту
This commit is contained in:
1 parent
9eb6a3fad1
commit
480aa82f78
3 files changed
+95
-16
No files matched your search
+30
-16
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user