fix(traffic): отличать измеренный ноль от отсутствия наблюдений

This commit is contained in:
Denozordec committed 2026-10-03 23:10:39 +07:00
1 parent a28f871fd4
commit 9e0f695684
12 files changed
+111 -30

No files matched your search

+4 -4
View File
@@ -217,13 +217,13 @@ export default function DashboardPage() {
<div> <div>
<OpsPanel <OpsPanel
title="Пропускная способность" title="Пропускная способность"
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарный RX / TX серверов · 1 ч"} description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
headerRight={ headerRight={
dash.traffic ? ( dash.traffic ? (
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : null} {dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
<span className="tabular-nums">RX {fmtRate(dash.traffic.rxNow)}</span> <span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
<span className="tabular-nums">TX {fmtRate(dash.traffic.txNow)}</span> <span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}> <Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
Трафик → Трафик →
</Link> </Link>
+4 -1
View File
@@ -18,6 +18,7 @@ import { getEnabledServerById } from "../services/wireguard-live.js"
import { import {
bpsToMbps, bpsToMbps,
buildTrafficFromSamples, buildTrafficFromSamples,
type TrafficObservations,
isLoopbackName, isLoopbackName,
parseMonitorTraffic, parseMonitorTraffic,
rateBpsFromDelta, rateBpsFromDelta,
@@ -30,6 +31,7 @@ import {
type SnapshotRow = typeof serverSnapshots.$inferSelect type SnapshotRow = typeof serverSnapshots.$inferSelect
interface TrafficServerDto { interface TrafficServerDto {
observations?:TrafficObservations
id: string id: string
name: string name: string
site: string site: string
@@ -94,13 +96,14 @@ function buildServerTraffic(
rangeEndMs: number, rangeEndMs: number,
onlyInterface?: string, onlyInterface?: string,
): TrafficServerDto { ): TrafficServerDto {
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface) const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface,undefined,true)
return { return {
id: String(s.id), id: String(s.id),
name: s.name || s.host, name: s.name || s.host,
site: s.site || "—", site: s.site || "—",
country: s.country || "UN", country: s.country || "UN",
status, status,
observations:built.observations,
rxNow: built.rxNow, rxNow: built.rxNow,
txNow: built.txNow, txNow: built.txNow,
rxPeak: built.rxPeak, rxPeak: built.rxPeak,
+23
View File
@@ -123,3 +123,26 @@ assert.equal(
) )
console.log("traffic-rate tests ok") 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<typeof observationSample>[])=>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)
+23 -6
View File
@@ -20,7 +20,10 @@ export interface RatePoint {
txMbps: number txMbps: number
} }
export interface TrafficObservations {rxNow:boolean;txNow:boolean;rxSeries:boolean[];txSeries:boolean[];sampledAt:string|null}
export interface BuiltTrafficSeries { export interface BuiltTrafficSeries {
observations?:TrafficObservations
rxNow: number rxNow: number
txNow: number txNow: number
rxPeak: number rxPeak: number
@@ -65,10 +68,12 @@ export function rateBpsFromDelta(
prevAtMs: number, prevAtMs: number,
nextAtMs: number, nextAtMs: number,
): number | null { ): number | null {
if(![prevBytes,nextBytes,prevAtMs,nextAtMs].every(Number.isFinite)||prevBytes<0||nextBytes<0)return null
const dtSec = (nextAtMs - prevAtMs) / 1000 const dtSec = (nextAtMs - prevAtMs) / 1000
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
if (nextBytes < prevBytes) 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( export function bucketAvg(
@@ -91,8 +96,7 @@ export function bucketAvg(
} }
function parseIsoMs(iso: string): number { function parseIsoMs(iso: string): number {
const t = Date.parse(iso) return Date.parse(iso)
return Number.isFinite(t) ? t : 0
} }
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string { export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
@@ -105,6 +109,7 @@ export function buildTrafficFromSamples(
rangeEndMs: number, rangeEndMs: number,
onlyInterface?: string | readonly string[], onlyInterface?: string | readonly string[],
peerPublicKey?: string, peerPublicKey?: string,
includeObservations=false,
): BuiltTrafficSeries { ): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = { const empty: BuiltTrafficSeries = {
rxNow: 0, rxNow: 0,
@@ -117,7 +122,8 @@ export function buildTrafficFromSamples(
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0), rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: 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<string, TrafficSampleLike[]>() const bySeries = new Map<string, TrafficSampleLike[]>()
for (const r of rows) { for (const r of rows) {
@@ -133,7 +139,8 @@ export function buildTrafficFromSamples(
const rxPoints: Array<{ t: number; v: number }> = [] const rxPoints: Array<{ t: number; v: number }> = []
const txPoints: Array<{ t: number; v: number }> = [] const txPoints: Array<{ t: number; v: number }> = []
const byTs = new Map<number, { rx: number; tx: number }>() const byTs = new Map<number, { rx: number; tx: number;rxObserved:boolean;txObserved:boolean;count:number }>()
let includedSeries=0,lastAttemptAt:number|null=null
let rxBytesDelta = 0 let rxBytesDelta = 0
let txBytesDelta = 0 let txBytesDelta = 0
@@ -159,6 +166,7 @@ export function buildTrafficFromSamples(
if (!last) continue if (!last) continue
if (!allowList && (!last.running || last.disabled)) continue if (!allowList && (!last.running || last.disabled)) continue
includedSeries++
if (last.running && !last.disabled) sessions += 1 if (last.running && !last.disabled) sessions += 1
const first = sorted[0] const first = sorted[0]
@@ -176,12 +184,14 @@ export function buildTrafficFromSamples(
if (!allowList && (!cur.running || cur.disabled)) continue if (!allowList && (!cur.running || cur.disabled)) continue
const t0 = parseIsoMs(prev.sampledAt) const t0 = parseIsoMs(prev.sampledAt)
const t1 = parseIsoMs(cur.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 rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1) const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
if (rxBps == null && txBps == null) continue if (rxBps == null && txBps == null) continue
const rxMbps = bpsToMbps(rxBps ?? 0) const rxMbps = bpsToMbps(rxBps ?? 0)
const txMbps = bpsToMbps(txBps ?? 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.rx += rxMbps
acc.tx += txMbps acc.tx += txMbps
byTs.set(t1, acc) byTs.set(t1, acc)
@@ -210,6 +220,13 @@ export function buildTrafficFromSamples(
sessions, sessions,
rxSeries, rxSeries,
txSeries, 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(),
}}:{}),
} }
} }
+5 -5
View File
@@ -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 n = Math.max(rx.length, tx.length, 1)
const denom = Math.max(n - 1, 1) const denom = Math.max(n - 1, 1)
return Array.from({ length: n }, (_, i) => { return Array.from({ length: n }, (_, i) => {
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes) const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
return { return {
time: formatRangeAgo(minutesAgo), time: formatRangeAgo(minutesAgo),
rx: Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null, rx: typeof rx[i] === "number" && Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null,
tx: Number.isFinite(tx[i]) && tx[i] >= 0 ? tx[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", range = "1h",
embedded = false, embedded = false,
}: { }: {
rx: number[] rx: (number|null)[]
tx: number[] tx: (number|null)[]
range?: string range?: string
/** Skip outer Frame when already inside OpsPanel / Frame. */ /** Skip outer Frame when already inside OpsPanel / Frame. */
embedded?: boolean embedded?: boolean
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03 ## Актуальная точка продолжения — 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). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+10
View File
@@ -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) ## 12.2.2 — строгий traffic DTO и агрегация серий (2026-10-03)
Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние.
+10
View File
@@ -43,3 +43,13 @@
Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. Перед публикацией 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. Проверки: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 измерения и внешняя приёмка остаются отдельными.
+2 -2
View File
@@ -282,7 +282,7 @@ export function useDashboardLive() {
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes } const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
for (const [key, result] of Object.entries(results)) { for (const [key, result] of Object.entries(results)) {
recordSource(key as DashboardSource, result.status === "rejected" 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 const failures = Object.values(results).filter(result => result.status === "rejected").length
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null) setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
@@ -560,7 +560,7 @@ export function useDashboardLive() {
tx: mockTraffic.tx, tx: mockTraffic.tx,
rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0, rxNow: mockTraffic.rx[mockTraffic.rx.length - 1] ?? 0,
txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0, txNow: mockTraffic.tx[mockTraffic.tx.length - 1] ?? 0,
demo: true, demo: true,observationsKnown:true,sampledAt:null,
} }
} }
if (!trafficSeries) return null if (!trafficSeries) return null
+11 -4
View File
@@ -1,12 +1,19 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import {test} from "node:test" import {test} from "node:test"
import {parseDashboardTraffic} from "./dashboard-traffic" 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",()=>{ 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:[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}) assert.deepEqual(parseDashboardTraffic({servers:[]}),{rx:[],tx:[],rxNow:0,txNow:0,observationsKnown:true,sampledAt:null})
assert.deepEqual(parseDashboardTraffic({servers:[{...row,rxSeries:[],txSeries:[]}]}),{rx:[],tx:[],rxNow:2,txNow:1}) 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",()=>{ 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)) 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}]}))
})
+9 -5
View File
@@ -1,14 +1,18 @@
import {z} from "zod" import {z} from "zod"
const rate=z.number().finite().nonnegative() 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)) 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} export interface DashboardTraffic {rx:(number|null)[];tx:(number|null)[];rxNow:number|null;txNow:number|null;observationsKnown:boolean;sampledAt:string|null}
/** API series share an index axis; unequal series cannot be completed with assumed zero. */ /** Numeric legacy zero alone cannot confirm that the collector observed an interval. */
export function parseDashboardTraffic(raw:unknown):DashboardTraffic { export function parseDashboardTraffic(raw:unknown):DashboardTraffic {
const parsed=payload.safeParse(raw) const parsed=payload.safeParse(raw)
if(!parsed.success)throw Error("Некорректные данные трафика: проверьте счётчики и согласованность серий") if(!parsed.success)throw Error("Некорректные данные трафика: проверьте счётчики и согласованность серий")
const rows=parsed.data.servers,length=rows[0]?.rxSeries.length??0 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)} const sum=(key:"rxNow"|"txNow")=>rows.every(r=>r.observations?.[key])?rows.reduce((sum,r)=>sum+r[key],0):null
if(![total.rxNow,total.txNow,...total.rx,...total.tx].every(Number.isFinite))throw Error("Суммарный трафик превышает допустимый диапазон") 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&&timestamps.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 return total
} }
+9 -2
View File
@@ -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/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] }
: path === "/api/wireguard" ? { interfaces: [], peers: [] } : path === "/api/wireguard" ? { interfaces: [], peers: [] }
: path === "/api/vxlan" ? { tunnels: [] } : 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/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot }
: path === "/api/uptime/speed-probes" ? { probes: [] } : 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: {} }] } : {} : 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})=>{ 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()
}) })