fix(traffic): отличать измеренный ноль от отсутствия наблюдений
This commit is contained in:
1 parent
a28f871fd4
commit
9e0f695684
12 files changed
+111
-30
No files matched your search
@@ -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(),
|
||||
}}:{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user