From 7c6f937cec97d0d96a67a850939c6b4f6b37553d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 24 Sep 2026 10:02:50 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D1=82=D0=BB=D0=B8=D1=87=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20flow-=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=20=D0=BD=D1=83=D0=BB=D0=B5=D0=B2=D1=8B?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/traffic/flow-analytics-panel.tsx | 12 ++++++---- docs/ux-ui-progress.md | 6 +++++ tests/e2e/traffic-flow.spec.ts | 25 +++++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/components/traffic/flow-analytics-panel.tsx b/components/traffic/flow-analytics-panel.tsx index ed2cb5c8..9d8834e8 100644 --- a/components/traffic/flow-analytics-panel.tsx +++ b/components/traffic/flow-analytics-panel.tsx @@ -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: , 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: , 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: , iconClassName: "text-muted-foreground", diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 2994d9b1..f69ebb3b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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 удалена. diff --git a/tests/e2e/traffic-flow.spec.ts b/tests/e2e/traffic-flow.spec.ts index 332dd9e6..09d8be34 100644 --- a/tests/e2e/traffic-flow.spec.ts +++ b/tests/e2e/traffic-flow.spec.ts @@ -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, release: () => {} } const gate = new Promise(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 Б') + }) +}