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 Б')
+ })
+}