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

+11 -4
View File
@@ -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}]}))
})
+9 -5
View File
@@ -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&&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
}