fix(traffic): строить непрерывную историю коротких периодов
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m3s
Docker images / frontend-image (push) Successful in 2m13s
Docker images / updater-image (push) Successful in 39s
Docker images / netflow-agent-image (push) Successful in 31s
Docker images / backend-image (push) Successful in 1m37s
Docker images / publish-release (push) Successful in 13s
Docker images / notify-webhook (push) Successful in 2s

This commit is contained in:
Denozordec committed 2026-10-16 19:27:30 +07:00
1 parent 2852b11234
commit e461dc21bd
6 files changed
+119 -11

No files matched your search

@@ -4,7 +4,7 @@ import {
ingestParsedFlowsForServerForTests,
resetFlowRingsForTests,
} from "./traffic-flow-ingest.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters, resetFlowAnalyticsCacheForTests } from "./traffic-flow-analytics.js"
import { withPgOrSkip } from "../test/pg.js"
import { dbQuery } from "../db/index.js"
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
@@ -556,6 +556,22 @@ try {
}
}
// Historical 5m/15m charts survive an empty live ring (e.g. immediately after restart).
resetFlowRingsForTests()
await dbQuery("DELETE FROM flow_buckets WHERE server_id=$1", [serverId])
for (const minutesAgo of [2, 3, 4, 10]) {
await dbQuery(`INSERT INTO flow_buckets (server_id,bucket_at,src,dst,in_iface,bytes,packets)
VALUES ($1,date_trunc('minute',now())-($2::int*interval '1 minute'),'10.1.1.8','8.8.8.8','2',60000,10)`, [serverId, minutesAgo])
}
for (const minutes of [5, 15]) for (const dedup of [true, false]) {
resetFlowAnalyticsCacheForTests()
const history = await buildFlowAnalytics({ minutes, serverId, dedup, excludeMesh:false, excludeOverlay:false })
assert.equal(history.rxSeries.length, minutes)
assert.equal(history.rxSeries.filter(v => v > 0).length, minutes === 5 ? 3 : 4)
const missing = await buildFlowAnalytics({ minutes, serverId, iface:"not-an-interface" })
assert(missing.rxSeries.every(v => v === 0), "Historical chart respects interface filter")
}
} finally {
for (const table of ["flow_daily_dims", "flow_hour_facts", "flow_daily_facts", "flow_buckets", "traffic_samples", "user_interface_bindings", "server_snapshots"]) {
await dbQuery("DELETE FROM " + table + " WHERE server_id = $1", [serverId])
@@ -423,9 +423,10 @@ async function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): Promise<FlowAn
? getRingMbps(ringServer, ifaceFilter === "" ? "__all__" : (ifacesMap.get(ifaceFilter)?.index || ifaceFilter))
: { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 }
// The live ring covers about one minute; historical charts must use the requested window.
const fromBuckets = seriesFromRows(matched, q.minutes)
const rxSeries = q.minutes <= 15 ? ring.rx : fromBuckets.rx
const txSeries = q.minutes <= 15 ? ring.tx : fromBuckets.tx
const rxSeries = fromBuckets.rx
const txSeries = fromBuckets.tx
const ifaceRows = [...ifacesMap.entries()]
.sort((a, b) => b[1].bytes - a[1].bytes)
+1 -1
View File
@@ -439,7 +439,7 @@ export async function listStoredFlowRows(sinceIso: string): Promise<PendingFlowR
export async function listFlowRowsForWindow(minutes: number): Promise<PendingFlowRow[]> {
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
if (minutes <= 15 && !worker) return await listLiveFlowRows(sinceIso)
// History remains available while the collector starts or is stopped; pending rows are merged below.
return await listStoredFlowRows(sinceIso)
}
+26 -1
View File
@@ -132,7 +132,7 @@ const observed=(rows:ReturnType<typeof observationSample>[])=>buildTrafficFromSa
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)
assert.equal(observedZero.rxNow,0);assert.equal(observedZero.observations?.rxNow,true);assert.equal(observedZero.observations?.rxSeries.filter(Boolean).length,30)
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)])
@@ -147,3 +147,28 @@ 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)
// A 30-second collector must produce a line across measured intervals on short ranges.
for (const minutes of [5, 15]) {
const span = minutes * 60_000
const rows = Array.from({ length: span / 30_000 + 1 }, (_, i) => ({
...observationSample(i * 30_000, i * 3_750_000, i * 1_875_000),
continuityKnown: true, nativeCounterIdentity: "ether1-v1",
}))
const history = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert(history.observations!.rxSeries.every(Boolean), `${minutes}m continuous measured RX`)
assert(history.observations!.txSeries.every(Boolean), `${minutes}m continuous measured TX`)
assert(history.rxSeries.every(v => v === 1)); assert(history.txSeries.every(v => v === 0.5))
assert.equal(history.rxTotalGiB, (span / 30_000) * 3_750_000 / 1024 ** 3)
// Explicit loss of continuity stays unknown; it must not be bridged by chart rendering.
rows[2].continuityKnown = false
const gap = buildTrafficFromSamples(rows, observationStart, observationStart + span, undefined, undefined, true)
assert.equal(gap.observations!.rxSeries.filter(v => !v).length, 30_000 / (span / 60))
assert(gap.observations!.rxSeries.slice(0, 30_000 / (span / 60)).every(Boolean))
const partial = buildTrafficFromSamples(rows.slice(4), observationStart, observationStart + span, undefined, undefined, true)
assert(partial.observations!.rxSeries.slice(0, 120_000 / (span / 60)).every(v => !v), "No history invented before first sample")
}
// Unequal poll intervals contribute by covered duration when sharing a chart bucket.
const weighted = observed([observationSample(0,0,0),observationSample(500,62_500,62_500),observationSample(1500,312_500,312_500)])
assert.equal(weighted.rxSeries[0], 1.5)
assert.equal(weighted.rxSeries[1], 2)
+34 -6
View File
@@ -114,6 +114,34 @@ function parseIsoMs(iso: string): number {
return Date.parse(iso)
}
/** A counter delta measures its entire interval, not just the next poll's timestamp. */
function intervalSeries(
waves: Map<number, PeakWave>, start: number, end: number, side: "rx" | "tx",
): { values: number[]; observed: boolean[] } {
const sums = Array(SERIES_POINTS).fill(0) as number[]
const durations = Array(SERIES_POINTS).fill(0) as number[]
const unknown = Array(SERIES_POINTS).fill(false) as boolean[]
const width = (end - start) / SERIES_POINTS
if (!(width > 0)) return { values: sums, observed: unknown }
for (const [at, wave] of waves) {
const from = Math.max(start, wave.start), to = Math.min(end, at)
if (!(to > from)) continue
const first = Math.max(0, Math.floor((from - start) / width))
const last = Math.min(SERIES_POINTS - 1, Math.ceil((to - start) / width) - 1)
for (let i = first; i <= last; i++) {
const overlap = Math.min(to, start + (i + 1) * width) - Math.max(from, start + i * width)
if (!(overlap > 0)) continue
sums[i] += wave[side] * overlap
durations[i] += overlap
unknown[i] ||= !wave[side === "rx" ? "rxKnown" : "txKnown"]
}
}
return {
values: sums.map((sum, i) => durations[i] > 0 ? sum / durations[i] : 0),
observed: durations.map((duration, i) => duration > 0 && !unknown[i]),
}
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
@@ -232,8 +260,6 @@ export function buildTrafficFromSamples(
const latestWave = membership.get(latestMembershipAt)
if (latestWave?.declared) sessions = latestWave.active
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
const last = lastTs != null ? byTs.get(lastTs) : undefined
const peak = (points: Array<{t:number;v:number}>, side: "rxObserved"|"txObserved") => points.reduce((m,p) => {
@@ -249,6 +275,8 @@ export function buildTrafficFromSamples(
const complete = wave.sameStart && wave.count === expectedAt(t)
waves.set(t,{start:wave.start,rx:wave.rx,tx:wave.tx,rxKnown:complete&&wave.rxObserved,txKnown:complete&&wave.txObserved})
}
const rxHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "rx")
const txHistory = intervalSeries(waves, rangeStartMs, rangeEndMs, "tx")
return retainPeakWaves({
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
@@ -257,13 +285,13 @@ export function buildTrafficFromSamples(
rxTotalGiB: rxBytesDelta / (1024 ** 3),
txTotalGiB: txBytesDelta / (1024 ** 3),
sessions,
rxSeries,
txSeries,
rxSeries: rxHistory.values,
txSeries: txHistory.values,
...(includeObservations?{observations:{
rxNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.rxObserved&&last.count===expectedAt(lastTs!),
txNow:lastAttemptAt!==null&&lastTs===lastAttemptAt&&!!last?.txObserved&&last.count===expectedAt(lastTs!),
rxSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.rxObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
txSeries:bucketAvg([...byTs].map(([t,v])=>({t,v:Number(v.txObserved&&v.count===expectedAt(t)&&t>=rangeStartMs&&t<=rangeEndMs)})),rangeStartMs,rangeEndMs).map(v=>v===1),
rxSeries:rxHistory.observed,
txSeries:txHistory.observed,
sampledAt:lastAttemptAt===null?null:new Date(lastAttemptAt).toISOString(),
measurementIntervalMs:lastTs===lastAttemptAt?last?.intervalMs??null:null,
rxPeak:[...waves.values()].some(w=>w.rxKnown),