fix(ui): защитить списки трафика от устаревших ответов

This commit is contained in:
Denozordec committed 2026-09-24 01:25:20 +07:00
1 parent 480aa82f78
commit 94dd453b33
3 files changed
+157 -91

No files matched your search

+87 -91
View File
@@ -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<Range>("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<ServerTraffic[]>([])
const [liveBusy, setLiveBusy] = useState(false)
const [liveError, setLiveError] = useState<string | null>(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)}
>
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
<RefreshCwIcon className={cn("size-4", isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading) && "animate-spin")} />Обновить
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
@@ -1240,10 +1238,11 @@ export default function TrafficPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
<KpiStatGrid
{(!isLive || effectiveMode === "flows" || !trafficError) && <KpiStatGrid
isLoading={isLive && effectiveMode !== "flows" && trafficLoading}
aria-label="Сводка трафика"
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>
/>}
{effectiveMode === "flows" ? (
<div className="flex flex-col gap-3">
@@ -1394,11 +1393,8 @@ export default function TrafficPage() {
</button>
))}
</div>
{liveError && (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{liveError}
</div>
)}
{isLive && trafficLoading && <p role="status" className="text-sm text-muted-foreground">Загрузка списка трафика…</p>}
{trafficError && <Alert variant="destructive" role="alert"><AlertTitle>Список трафика недоступен</AlertTitle><AlertDescription>{trafficError}<Button type="button" size="sm" variant="outline" onClick={() => setTrafficRetry(value => value + 1)}>Повторить загрузку списка</Button></AlertDescription></Alert>}
{/* entity list */}
<div className="flex flex-col gap-2">
+7
View File
@@ -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, режиму, серверу, периоду, интерфейсу и повтору запроса. При смене контекста прежние метрики/график скрыты, период остаётся доступен для выбора. Поздний ответ отменённого эффекта не применяется; список интерфейсов также игнорирует ответ прежнего сервера.
+63
View File
@@ -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<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')
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()
})