diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 3accbbdd..2d773064 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -217,13 +217,13 @@ export default function DashboardPage() {
- {dash.traffic.demo ? демо : null} - RX {fmtRate(dash.traffic.rxNow)} - TX {fmtRate(dash.traffic.txNow)} + {dash.traffic.demo ? демо : !dash.traffic.observationsKnown ? Измерения не подтверждены API : null} + RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)} + TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)} Трафик → diff --git a/backend/src/routes/traffic.ts b/backend/src/routes/traffic.ts index 312f3e41..eb70a36c 100644 --- a/backend/src/routes/traffic.ts +++ b/backend/src/routes/traffic.ts @@ -18,6 +18,7 @@ import { getEnabledServerById } from "../services/wireguard-live.js" import { bpsToMbps, buildTrafficFromSamples, + type TrafficObservations, isLoopbackName, parseMonitorTraffic, rateBpsFromDelta, @@ -30,6 +31,7 @@ import { type SnapshotRow = typeof serverSnapshots.$inferSelect interface TrafficServerDto { + observations?:TrafficObservations id: string name: string site: string @@ -94,13 +96,14 @@ function buildServerTraffic( rangeEndMs: number, onlyInterface?: string, ): TrafficServerDto { - const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface) + const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface,undefined,true) return { id: String(s.id), name: s.name || s.host, site: s.site || "—", country: s.country || "UN", status, + observations:built.observations, rxNow: built.rxNow, txNow: built.txNow, rxPeak: built.rxPeak, diff --git a/backend/src/services/traffic-rate.test.ts b/backend/src/services/traffic-rate.test.ts index ff1e1cf0..6ae44f1c 100644 --- a/backend/src/services/traffic-rate.test.ts +++ b/backend/src/services/traffic-rate.test.ts @@ -123,3 +123,26 @@ assert.equal( ) console.log("traffic-rate tests ok") + +// Observation metadata is opt-in: existing numeric consumers retain their DTO. +const observationStart=Date.parse("2026-10-03T00:00:00Z"),observationEnd=observationStart+60_000 +const observationSample=(offset:number,rxBytes:number,txBytes:number,interfaceName="ether1")=>({interfaceName,sampledAt:new Date(observationStart+offset).toISOString(),rxBytes,txBytes,rxBps:0,txBps:0,running:true,disabled:false}) +const observed=(rows:ReturnType[])=>buildTrafficFromSamples(rows,observationStart,observationEnd,undefined,undefined,true) +assert.ok(observed([]).observations?.rxSeries.every(v=>!v));assert.equal(observed([]).observations?.rxNow,false) +assert.equal(observed([observationSample(0,10,10)]).observations?.rxNow,false) +const observedZero=observed([observationSample(0,10,10),observationSample(30_000,10,10)]) +assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,1) +const partialReset=observed([observationSample(0,10,10),observationSample(30_000,1,20)]) +assert.equal(partialReset.observations?.rxNow,false);assert.equal(partialReset.observations?.txNow,true) +const fullReset=observed([observationSample(0,10,10),observationSample(20_000,20,20),observationSample(40_000,1,1)]) +assert.equal(fullReset.observations?.rxNow,false);assert.equal(fullReset.observations?.sampledAt,new Date(observationStart+40_000).toISOString()) +const incomplete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2")]) +assert.equal(incomplete.observations?.rxNow,false);assert.ok(incomplete.observations?.rxSeries.every(v=>!v)) +const legacyObservedZero=buildTrafficFromSamples([observationSample(0,10,10),observationSample(30_000,10,10)],observationStart,observationEnd) +const {observations:extra,...numericZero}=observedZero;assert.deepEqual(numericZero,legacyObservedZero);assert.ok(extra) +assert.equal(rateBpsFromDelta(NaN,10,0,1000),null);assert.equal(rateBpsFromDelta(-1,10,0,1000),null) +console.log("traffic observation metadata tests ok") + +const complete=observed([observationSample(0,10,10),observationSample(30_000,20,20),observationSample(0,10,10,"ether2"),observationSample(30_000,20,20,"ether2")]);assert.equal(complete.observations?.rxNow,true) +const badTime=observed([{...observationSample(0,10,10),sampledAt:"invalid"},observationSample(30_000,20,20)]);assert.equal(badTime.observations?.rxNow,false) +assert.equal(rateBpsFromDelta(0,Number.MAX_VALUE,0,1000),null) diff --git a/backend/src/services/traffic-rate.ts b/backend/src/services/traffic-rate.ts index de5e16d5..32ed81af 100644 --- a/backend/src/services/traffic-rate.ts +++ b/backend/src/services/traffic-rate.ts @@ -20,7 +20,10 @@ export interface RatePoint { txMbps: number } +export interface TrafficObservations {rxNow:boolean;txNow:boolean;rxSeries:boolean[];txSeries:boolean[];sampledAt:string|null} + export interface BuiltTrafficSeries { + observations?:TrafficObservations rxNow: number txNow: number rxPeak: number @@ -65,10 +68,12 @@ export function rateBpsFromDelta( prevAtMs: number, nextAtMs: number, ): number | null { + if(![prevBytes,nextBytes,prevAtMs,nextAtMs].every(Number.isFinite)||prevBytes<0||nextBytes<0)return null const dtSec = (nextAtMs - prevAtMs) / 1000 if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null if (nextBytes < prevBytes) return null - return Math.round(((nextBytes - prevBytes) * 8) / dtSec) + const rate=Math.round(((nextBytes - prevBytes) * 8) / dtSec) + return Number.isFinite(rate)?rate:null } export function bucketAvg( @@ -91,8 +96,7 @@ export function bucketAvg( } function parseIsoMs(iso: string): number { - const t = Date.parse(iso) - return Number.isFinite(t) ? t : 0 + return Date.parse(iso) } export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string { @@ -105,6 +109,7 @@ export function buildTrafficFromSamples( rangeEndMs: number, onlyInterface?: string | readonly string[], peerPublicKey?: string, + includeObservations=false, ): BuiltTrafficSeries { const empty: BuiltTrafficSeries = { rxNow: 0, @@ -117,7 +122,8 @@ export function buildTrafficFromSamples( rxSeries: Array.from({ length: SERIES_POINTS }, () => 0), txSeries: Array.from({ length: SERIES_POINTS }, () => 0), } - if (rows.length === 0) return empty + const noObservations:TrafficObservations={rxNow:false,txNow:false,rxSeries:Array(SERIES_POINTS).fill(false),txSeries:Array(SERIES_POINTS).fill(false),sampledAt:null} + if (rows.length === 0) return includeObservations?{...empty,observations:noObservations}:empty const bySeries = new Map() for (const r of rows) { @@ -133,7 +139,8 @@ export function buildTrafficFromSamples( const rxPoints: Array<{ t: number; v: number }> = [] const txPoints: Array<{ t: number; v: number }> = [] - const byTs = new Map() + const byTs = new Map() + let includedSeries=0,lastAttemptAt:number|null=null let rxBytesDelta = 0 let txBytesDelta = 0 @@ -159,6 +166,7 @@ export function buildTrafficFromSamples( if (!last) continue if (!allowList && (!last.running || last.disabled)) continue + includedSeries++ if (last.running && !last.disabled) sessions += 1 const first = sorted[0] @@ -176,12 +184,14 @@ export function buildTrafficFromSamples( if (!allowList && (!cur.running || cur.disabled)) continue const t0 = parseIsoMs(prev.sampledAt) const t1 = parseIsoMs(cur.sampledAt) + if(Number.isFinite(Date.parse(cur.sampledAt))&&t1>=rangeStartMs&&t1<=rangeEndMs)lastAttemptAt=Math.max(lastAttemptAt??t1,t1) const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1) const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1) if (rxBps == null && txBps == null) continue const rxMbps = bpsToMbps(rxBps ?? 0) const txMbps = bpsToMbps(txBps ?? 0) - const acc = byTs.get(t1) ?? { rx: 0, tx: 0 } + const acc = byTs.get(t1) ?? { rx: 0, tx: 0,rxObserved:true,txObserved:true,count:0 } + acc.count++;acc.rxObserved&&=rxBps!==null;acc.txObserved&&=txBps!==null acc.rx += rxMbps acc.tx += txMbps byTs.set(t1, acc) @@ -210,6 +220,13 @@ export function buildTrafficFromSamples( sessions, rxSeries, txSeries, + ...(includeObservations?{observations:{ + rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===includedSeries, + txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===includedSeries, + rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===includedSeries&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1), + txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===includedSeries&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1), + sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(), + }}:{}), } } diff --git a/components/reui-kit/traffic-rx-tx-chart.tsx b/components/reui-kit/traffic-rx-tx-chart.tsx index 2afad04c..92a86fe2 100644 --- a/components/reui-kit/traffic-rx-tx-chart.tsx +++ b/components/reui-kit/traffic-rx-tx-chart.tsx @@ -53,15 +53,15 @@ function CustomTooltip({ ) } -function toChartData(rx: number[], tx: number[], rangeMinutes: number) { +function toChartData(rx: (number|null)[], tx: (number|null)[], rangeMinutes: number) { const n = Math.max(rx.length, tx.length, 1) const denom = Math.max(n - 1, 1) return Array.from({ length: n }, (_, i) => { const minutesAgo = Math.round((1 - i / denom) * rangeMinutes) return { time: formatRangeAgo(minutesAgo), - rx: Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null, - tx: Number.isFinite(tx[i]) && tx[i] >= 0 ? tx[i] : null, + rx: typeof rx[i] === "number" && Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null, + tx: typeof tx[i] === "number" && Number.isFinite(tx[i]) && tx[i] >= 0 ? tx[i] : null, } }) } @@ -72,8 +72,8 @@ export function TrafficRxTxChart({ range = "1h", embedded = false, }: { - rx: number[] - tx: number[] + rx: (number|null)[] + tx: (number|null)[] range?: string /** Skip outer Frame when already inside OpsPanel / Frame. */ embedded?: boolean diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index af6dfbb1..c49f89ee 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. Следующий блок12.2.3: признаки реальных измерений в backend traffic API; затем остальные источники, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. +Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. Следующий блок12.2.4: strict servers/probes и unknown status; затем overlay/Internet path, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 672fc104..4072078c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,16 @@ +## 12.2.3 — наблюдаемость трафика от backend до дашборда (2026-10-03) + +/traffic/servers добавляет совместимое observations: независимые признаки RX/TX для последнего снимка и60 временных корзин, реальный sampledAt. Старые числовые поля и default buildTrafficFromSamples сохранены; наблюдаемость opt-in для DTO серверов. Корзина считается измеренной только при валидных дельтах всех включённых интерфейсов; отсутствие, сброс, неверная дата/счётчик и переполнение не подтверждают0. Новые guards не меняют корректные дельты. + +Дашборд валидирует masks/timestamp, неизвестные скорости показываются как—, в графике null/разрывы; измеренный0 видим. Legacy ответ без observations принят структурно, но измерения неизвестны с объяснением. По нескольким устройствам любой неизвестный вклад сохраняет неизвестную сумму; время измерения — самое раннее из агрегируемых снимков. Общий ReUI chart поддерживает nullable series; композиция/подписи единиц сохранены, описание отделяет историю и последний измеренный снимок. Другие потребители legacy numeric traffic продолжают прежнее поведение; расширение не является их повторной приёмкой. + +Проверки:27 browser dashboard/permissions,12 traffic detail/list (периоды, ошибки, одиночные значения, измеренный0 и пропуски),6 unit BGP/traffic/ACL; backend test:traffic-rate с новыми сценариями empty/single/zero/reset/partial/all interfaces/invalid-time/overflow и совместимостью numeric DTO прошли. Frontend typecheck/lint, backend/contracts build прошли. Не запускалась общая backend suite/реальная БД. ReUI checklist применён к chart/KPI, primitives/theme сохранены. + +Далее12.2.4: серверы и пробы дашборда — строгие DTO и unknown статус, затем overlay/Internet path. Реальные RouterOS измерения и внешняя приёмка остаются отдельными. + ## 12.2.2 — строгий traffic DTO и агрегация серий (2026-10-03) Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index cc05ba82..7e5d590d 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -43,3 +43,13 @@ Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. Проверки:21 browser dashboard/permissions,5 unit dashboard BGP/traffic/ACL, typecheck и ESLint прошли. ReUI chart/KPI композиции сохранены. Обнаруженное ограничение: backend buildTrafficFromSamples сам возвращает0 для ненаблюдавшихся интервалов; строгий reader не может отличить их от измеренного0 без расширения API. Далее12.2.3: добавить совместимые признаки наблюдений и использовать их на dashboard; затем servers/probes/overlay/Internet path. + +## 12.2.3 — наблюдаемость трафика от backend до дашборда (2026-10-03) + +/traffic/servers добавляет совместимое observations: независимые признаки RX/TX для последнего снимка и60 временных корзин, реальный sampledAt. Старые числовые поля и default buildTrafficFromSamples сохранены; наблюдаемость opt-in для DTO серверов. Корзина считается измеренной только при валидных дельтах всех включённых интерфейсов; отсутствие, сброс, неверная дата/счётчик и переполнение не подтверждают0. Новые guards не меняют корректные дельты. + +Дашборд валидирует masks/timestamp, неизвестные скорости показываются как—, в графике null/разрывы; измеренный0 видим. Legacy ответ без observations принят структурно, но измерения неизвестны с объяснением. По нескольким устройствам любой неизвестный вклад сохраняет неизвестную сумму; время измерения — самое раннее из агрегируемых снимков. Общий ReUI chart поддерживает nullable series; композиция/подписи единиц сохранены, описание отделяет историю и последний измеренный снимок. Другие потребители legacy numeric traffic продолжают прежнее поведение; расширение не является их повторной приёмкой. + +Проверки:27 browser dashboard/permissions,12 traffic detail/list (периоды, ошибки, одиночные значения, измеренный0 и пропуски),6 unit BGP/traffic/ACL; backend test:traffic-rate с новыми сценариями empty/single/zero/reset/partial/all interfaces/invalid-time/overflow и совместимостью numeric DTO прошли. Frontend typecheck/lint, backend/contracts build прошли. Не запускалась общая backend suite/реальная БД. ReUI checklist применён к chart/KPI, primitives/theme сохранены. + +Далее12.2.4: серверы и пробы дашборда — строгие DTO и unknown статус, затем overlay/Internet path. Реальные RouterOS измерения и внешняя приёмка остаются отдельными. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index f6c8e1f2..3537f48f 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -282,7 +282,7 @@ export function useDashboardLive() { const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes } for (const [key, result] of Object.entries(results)) { recordSource(key as DashboardSource, result.status === "rejected" - ? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null) + ? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null,key==="traffic"&&trafficRes.status==="fulfilled"?trafficRes.value.sampledAt??undefined:undefined) } const failures = Object.values(results).filter(result => result.status === "rejected").length setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null) @@ -560,7 +560,7 @@ export function useDashboardLive() { tx: mockTraffic.tx, rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0, txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0, - demo: true, + demo: true,observationsKnown:true,sampledAt:null, } } if (!trafficSeries) return null diff --git a/lib/dashboard-traffic.test.ts b/lib/dashboard-traffic.test.ts index 0960a955..9c3e2ac8 100644 --- a/lib/dashboard-traffic.test.ts +++ b/lib/dashboard-traffic.test.ts @@ -1,12 +1,19 @@ import assert from "node:assert/strict" import {test} from "node:test" import {parseDashboardTraffic} from "./dashboard-traffic" -const row={id:"1",rxNow:2,txNow:1,rxSeries:[1,2],txSeries:[0,1]} +const row={id:"1",rxNow:2,txNow:1,rxSeries:[1,2],txSeries:[0,1],observations:{rxNow:true,txNow:true,rxSeries:[true,true],txSeries:[true,true],sampledAt:"2026-09-20T00:00:00Z"}} test("traffic sums aligned observed series and does not invent sixty samples",()=>{ - assert.deepEqual(parseDashboardTraffic({servers:[row,{...row,id:"2"}]}),{rx:[2,4],tx:[0,2],rxNow:4,txNow:2}) - assert.deepEqual(parseDashboardTraffic({servers:[]}),{rx:[],tx:[],rxNow:0,txNow:0}) - assert.deepEqual(parseDashboardTraffic({servers:[{...row,rxSeries:[],txSeries:[]}]}),{rx:[],tx:[],rxNow:2,txNow:1}) + assert.deepEqual(parseDashboardTraffic({servers:[row,{...row,id:"2"}]}),{rx:[2,4],tx:[0,2],rxNow:4,txNow:2,observationsKnown:true,sampledAt:new Date(row.observations.sampledAt).toISOString()}) + assert.deepEqual(parseDashboardTraffic({servers:[]}),{rx:[],tx:[],rxNow:0,txNow:0,observationsKnown:true,sampledAt:null}) + assert.deepEqual(parseDashboardTraffic({servers:[{...row,rxSeries:[],txSeries:[],observations:{...row.observations,rxSeries:[],txSeries:[]}}]}),{rx:[],tx:[],rxNow:2,txNow:1,observationsKnown:true,sampledAt:new Date(row.observations.sampledAt).toISOString()}) }) test("missing, duplicate, null, invalid, misaligned and overflowing traffic cannot replace a snapshot",()=>{ for(const raw of [{},{servers:[row,row]},{servers:[{...row,rxNow:null}]},{servers:[{...row,txNow:-1}]},{servers:[{...row,rxSeries:[null,1]}]},{servers:[{...row,txSeries:[1]}]},{servers:[row,{...row,id:"2",rxSeries:[],txSeries:[]}]},{servers:[{...row,rxNow:Number.MAX_VALUE},{...row,id:"2",rxNow:Number.MAX_VALUE}]}])assert.throws(()=>parseDashboardTraffic(raw)) }) + +test("legacy rates and absent observations remain unknown while measured zero remains zero",()=>{ + const legacy={...row,observations:undefined};assert.equal(parseDashboardTraffic({servers:[legacy]}).rxNow,null);assert.deepEqual(parseDashboardTraffic({servers:[legacy]}).rx,[null,null]);assert.equal(parseDashboardTraffic({servers:[legacy]}).observationsKnown,false) + const holes={...row,rxNow:0,txNow:0,rxSeries:[0,0],txSeries:[0,0],observations:{...row.observations,rxSeries:[false,true],txSeries:[false,true]}};const known=parseDashboardTraffic({servers:[holes]});assert.deepEqual(known.rx,[null,0]);assert.equal(known.rxNow,0) + assert.equal(parseDashboardTraffic({servers:[row,{...legacy,id:"2"}]}).rxNow,null) + for(const observations of [{...row.observations,rxSeries:[]},{...row.observations,sampledAt:null},{...row.observations,sampledAt:"invalid"}])assert.throws(()=>parseDashboardTraffic({servers:[{...row,observations}]})) +}) diff --git a/lib/dashboard-traffic.ts b/lib/dashboard-traffic.ts index 69446caf..e2790883 100644 --- a/lib/dashboard-traffic.ts +++ b/lib/dashboard-traffic.ts @@ -1,14 +1,18 @@ import {z} from "zod" const rate=z.number().finite().nonnegative() -const row=z.object({id:z.string().trim().min(1),rxNow:rate,txNow:rate,rxSeries:z.array(rate),txSeries:z.array(rate)}) +const observations=z.object({rxNow:z.boolean(),txNow:z.boolean(),rxSeries:z.array(z.boolean()),txSeries:z.array(z.boolean()),sampledAt:z.string().refine(v=>Number.isFinite(Date.parse(v))).nullable()}) +const row=z.object({id:z.string().trim().min(1),rxNow:rate,txNow:rate,rxSeries:z.array(rate),txSeries:z.array(rate),observations:observations.optional()}).refine(r=>!r.observations||(r.observations.rxSeries.length===r.rxSeries.length&&r.observations.txSeries.length===r.txSeries.length&&(!(r.observations.rxNow||r.observations.txNow||r.observations.rxSeries.some(Boolean)||r.observations.txSeries.some(Boolean))||r.observations.sampledAt!==null))) const payload=z.object({servers:z.array(row)}).refine(data=>new Set(data.servers.map(r=>r.id)).size===data.servers.length&&data.servers.every(r=>r.rxSeries.length===r.txSeries.length&&r.rxSeries.length===data.servers[0].rxSeries.length)) -export interface DashboardTraffic {rx:number[];tx:number[];rxNow:number;txNow:number} -/** API series share an index axis; unequal series cannot be completed with assumed zero. */ +export interface DashboardTraffic {rx:(number|null)[];tx:(number|null)[];rxNow:number|null;txNow:number|null;observationsKnown:boolean;sampledAt:string|null} +/** Numeric legacy zero alone cannot confirm that the collector observed an interval. */ export function parseDashboardTraffic(raw:unknown):DashboardTraffic { const parsed=payload.safeParse(raw) if(!parsed.success)throw Error("Некорректные данные трафика: проверьте счётчики и согласованность серий") const rows=parsed.data.servers,length=rows[0]?.rxSeries.length??0 - const total={rx:Array.from({length},(_,i)=>rows.reduce((sum,r)=>sum+r.rxSeries[i],0)),tx:Array.from({length},(_,i)=>rows.reduce((sum,r)=>sum+r.txSeries[i],0)),rxNow:rows.reduce((sum,r)=>sum+r.rxNow,0),txNow:rows.reduce((sum,r)=>sum+r.txNow,0)} - if(![total.rxNow,total.txNow,...total.rx,...total.tx].every(Number.isFinite))throw Error("Суммарный трафик превышает допустимый диапазон") + const sum=(key:"rxNow"|"txNow")=>rows.every(r=>r.observations?.[key])?rows.reduce((sum,r)=>sum+r[key],0):null + const series=(key:"rxSeries"|"txSeries")=>Array.from({length},(_,i)=>rows.every(r=>r.observations?.[key][i])?rows.reduce((sum,r)=>sum+r[key][i],0):null) + const timestamps=rows.map(r=>r.observations?.sampledAt).filter((v):v is string=>!!v) + const total={rx:series("rxSeries"),tx:series("txSeries"),rxNow:sum("rxNow"),txNow:sum("txNow"),observationsKnown:rows.every(r=>!!r.observations),sampledAt:timestamps.length===rows.length&×tamps.length?new Date(timestamps.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null} + if(![total.rxNow,total.txNow,...total.rx,...total.tx].every(v=>v===null||Number.isFinite(v)))throw Error("Суммарный трафик превышает допустимый диапазон") return total } diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 3f3e2b10..a0e1017a 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -33,7 +33,7 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1, initi : path === "/api/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] } : path === "/api/wireguard" ? { interfaces: [], peers: [] } : path === "/api/vxlan" ? { tunnels: [] } - : path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500] }] } + : path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"} }] } : path === "/api/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot } : path === "/api/uptime/speed-probes" ? { probes: [] } : path === "/api/events" ? { events: [{ id: "e1", title: "Событие сохранено", message: "Тестовое событие", level: "info", sourceModule: "system", eventType: "test", createdAt: "2026-09-20T00:00:00Z", entityId: null, entityType: null, payload: {} }] } : {} @@ -218,5 +218,12 @@ for(const unknown of ["prefix","state"])test("Dashboard BGP accepts observed unk }) for(const broken of ["missing","duplicate","counter","null","length"])test("Dashboard traffic rejects unconfirmed totals and keeps last good/"+broken,async({page})=>{ - const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(source.locator("time")).toHaveCount(1);const receivedAt=await source.locator("time").getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500]};control.trafficPayload=broken==="missing"?{}:broken==="duplicate"?{servers:[rate,rate]}:broken==="counter"?{servers:[{...rate,rxNow:-1}]}:broken==="null"?{servers:[{...rate,rxNow:null}]}:{servers:[{...rate,txSeries:[]}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");control.trafficPayload={servers:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible();await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible(); + const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(source.locator("time")).toHaveCount(2);const receivedAt=await source.locator("time").first().getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"}};control.trafficPayload=broken==="missing"?{}:broken==="duplicate"?{servers:[rate,rate]}:broken==="counter"?{servers:[{...rate,rxNow:-1}]}:broken==="null"?{servers:[{...rate,rxNow:null}]}:{servers:[{...rate,txSeries:[]}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time").first()).toHaveAttribute("datetime",receivedAt!);await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");control.trafficPayload={servers:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible();await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible(); +}) + +for(const state of ["legacy","unobserved","measured-zero","reset-rx","invalid-time","invalid-mask"])test("Dashboard traffic observation metadata/"+state,async({page})=>{ + const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();const rate={id:"1",rxNow:0,txNow:0,rxSeries:[0],txSeries:[0]};const observations={rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-10-03T00:00:00Z"};control.trafficPayload={servers:[state==="legacy"?rate:{...rate,observations:state==="unobserved"?{rxNow:false,txNow:false,rxSeries:[false],txSeries:[false],sampledAt:null}:state==="reset-rx"?{...observations,rxNow:false,rxSeries:[false]}:state==="invalid-time"?{...observations,sampledAt:"invalid"}:state==="invalid-mask"?{...observations,rxSeries:[]}:observations}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled(); + if(state.startsWith("invalid")){await expect(source).toContainText("ошибка обновления");await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();return} + await expect(source).not.toContainText("ошибка");if(state==="measured-zero"){await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByRole("region",{name:"История RX/TX",exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toHaveCount(0)}else{await expect(page.getByText("RX —",{exact:true})).toBeVisible();if(state==="reset-rx"){await expect(page.getByText("TX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByText("Нет измерений RX за выбранный период",{exact:true})).toBeVisible()}else{await expect(page.getByText("TX —",{exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible()}} + if(state==="legacy")await expect(page.getByText("Измерения не подтверждены API",{exact:true})).toBeVisible() })