fix(traffic): отличать измеренный ноль от отсутствия наблюдений
This commit is contained in:
1 parent
a28f871fd4
commit
9e0f695684
12 files changed
+111
-30
No files matched your search
@@ -217,13 +217,13 @@ export default function DashboardPage() {
|
||||
<div>
|
||||
<OpsPanel
|
||||
title="Пропускная способность"
|
||||
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарный RX / TX серверов · 1 ч"}
|
||||
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
||||
headerRight={
|
||||
dash.traffic ? (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : null}
|
||||
<span className="tabular-nums">RX {fmtRate(dash.traffic.rxNow)}</span>
|
||||
<span className="tabular-nums">TX {fmtRate(dash.traffic.txNow)}</span>
|
||||
{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 {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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)
|
||||
@@ -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<string, TrafficSampleLike[]>()
|
||||
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<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 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(),
|
||||
}}:{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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 показывает её штатное пустое состояние.
|
||||
|
||||
@@ -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 измерения и внешняя приёмка остаются отдельными.
|
||||
@@ -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
|
||||
|
||||
@@ -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}]}))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user