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()
})