fix(ui): отличать неизвестные flow-метрики от нулевых

This commit is contained in:
Denozordec committed 2026-09-24 10:02:50 +07:00
1 parent a03909e504
commit 7c6f937cec
3 files changed
+37 -6

No files matched your search

+8 -4
View File
@@ -25,6 +25,10 @@ import { Flag } from "@/components/flag"
import { fmtRate } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
function hasMeasurement(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0
}
function formatBytes(n: number): string {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
@@ -513,15 +517,15 @@ export function FlowAnalyticsDetail({
{
id: "overlay",
label: "JH↔EN overlay",
value: fmtRate((analytics?.bpsOverlay ?? 0) / 1_000_000),
hint: analytics?.bytesOverlay ? formatBytes(analytics.bytesOverlay) : undefined,
value: hasMeasurement(analytics?.bpsOverlay) ? fmtRate(analytics.bpsOverlay / 1_000_000) : "Нет данных",
hint: hasMeasurement(analytics?.bytesOverlay) ? formatBytes(analytics.bytesOverlay) : undefined,
icon: <ShieldIcon className="size-4" />,
iconClassName: "text-warning",
},
{
id: "wire",
label: "Wire GRE",
value: fmtRate((analytics?.bpsWire ?? 0) / 1_000_000),
value: hasMeasurement(analytics?.bpsWire) ? fmtRate(analytics.bpsWire / 1_000_000) : "Нет данных",
hint: "счётчик iface",
icon: <RouteIcon className="size-4" />,
iconClassName: "text-primary",
@@ -530,7 +534,7 @@ export function FlowAnalyticsDetail({
? [{
id: "mesh",
label: "Mesh",
value: formatBytes(analytics?.bytesMesh ?? 0),
value: hasMeasurement(analytics?.bytesMesh) ? formatBytes(analytics.bytesMesh) : "Нет данных",
hint: "клиент↔клиент",
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
+6
View File
@@ -29,6 +29,12 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.4Г — неизвестные дополнительные flow-метрики (2026-09-24)
- Overlay/Wire GRE/mesh больше не подставляют ноль при отсутствии измерения. Отсутствующие, отрицательные и нечисловые значения показывают «Нет данных». Подтверждённый ноль сохраняется как 0 Кбит/с или 0 Б; нулевой объём overlay теперь виден в подписи.
- 17 flow-E2E прошли: предыдущие 13 плюс четыре набора optional-метрик (отсутствующие, нулевые, положительные и некорректные REST). Проверены карточки и преобразование бит/с в Мбит/с. Lint изменённых файлов и diff-check чистые. Production build/TypeScript прошли (34 страницы).
- Далее: пустые исторические серии и их подстановка нулями; затем остальные проверки контекста/backend/SSE и итоговая приёмка этапа 8. Это точечная защита отображения optional-метрик, а не полная валидация REST. Реальные IPFIX/RouterOS не проверялись. На контрольной точке использовано 76% пятичасового и 74% недельного лимита; локальный коммит без push.
## Блок 8.4В — проверка live-контракта flow (2026-09-24)
- useFlowLive проверяет полный объект через существующую flowAnalyticsDtoSchema, дополнительно отклоняя отрицательные/нечисловые значения рядов RX/TX. Прежняя проверка только uniqueSrc/destinations удалена.
+23 -2
View File
@@ -1,7 +1,7 @@
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { hold: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, release: () => {} }
const control = { hold: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record<string, unknown>, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const card = { id: '1', name: 'Экспортёр тест', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3], bytes: 200 }
const data = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false }
@@ -12,7 +12,7 @@ async function setup(page: Page) {
if (path === '/api/traffic/flow/analytics') {
if (control.hold === range) await gate
if (control.fail) return route.fulfill({ status: 500, json: { error: 'Срез временно недоступен' } })
return route.fulfill({ json: { ...data, topCategory: 'Категория ' + range } })
return route.fulfill({ json: { ...data, ...control.metrics, topCategory: 'Категория ' + range } })
}
if (path === '/api/traffic/flow/live') return route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify({ ...data, live: true }) + '\n\n' + (control.invalidSample === undefined ? '' : 'event: sample\ndata: ' + JSON.stringify(control.mergeSample ? { ...data, ...(control.invalidSample as object) } : control.invalidSample) + '\n\n') })
if (path === '/api/traffic/flow/exporters' && control.failList) return route.fulfill({ status: 403, json: { error: 'Нет доступа к экспортёрам' } })
@@ -145,3 +145,24 @@ for (const [name, invalid, merge] of [
await expect(page.getByText('live', { exact: true })).toHaveCount(0)
})
}
for (const [name, metrics, rate, bytes] of [
['отсутствующие', {}, 'Нет данных', 'Нет данных'],
['нулевые', { bpsOverlay: 0, bpsWire: 0, bytesMesh: 0, bytesOverlay: 0 }, '0 Кбит/с', '0 Б'],
['положительные', { bpsOverlay: 2000000, bpsWire: 2000000, bytesMesh: 1500, bytesOverlay: 1500 }, '2.00 Мбит/с', '1.5 КБ'],
['некорректные REST', { bpsOverlay: -1, bpsWire: null, bytesMesh: -1 }, 'Нет данных', 'Нет данных'],
] as const) {
test('Flow: optional-метрики — ' + name, async ({ page }) => {
const control = await setup(page)
control.metrics = metrics
await page.getByRole('button', { name: '15м', exact: true }).click()
await page.getByRole('switch', { name: 'Без mesh', exact: true }).click()
const grid = page.getByLabel('Скорость потоков')
const card = (label: string) => grid.locator('[data-slot="frame-panel"]').filter({ has: page.getByText(label, { exact: true }) })
await expect(card('JH↔EN overlay')).toContainText(rate)
await expect(card('Wire GRE')).toContainText(rate)
await expect(card('Mesh')).toContainText(bytes)
if (name === 'нулевые') await expect(card('JH↔EN overlay')).toContainText('0 Б')
})
}