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 -1
View File
@@ -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,
+23
View File
@@ -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)
+23 -6
View File
@@ -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(),
}}:{}),
}
}