fix(ui): защитить списки трафика от устаревших ответов
This commit is contained in:
1 parent
480aa82f78
commit
94dd453b33
3 files changed
+157
-91
No files matched your search
+87
-91
@@ -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">
|
||||
|
||||
@@ -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, режиму, серверу, периоду, интерфейсу и повтору запроса. При смене контекста прежние метрики/график скрыты, период остаётся доступен для выбора. Поздний ответ отменённого эффекта не применяется; список интерфейсов также игнорирует ответ прежнего сервера.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user