Files
MikrotikManager/backend/src/services/traffic-rate.ts
T

316 lines
11 KiB
TypeScript

/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
import { mergeTrafficObservations } from "./traffic-freshness.js"
export const SERIES_POINTS = 60
export interface TrafficSampleLike {
interfaceName: string
peerPublicKey?: string
sampledAt: string
rxBytes: number
txBytes: number
rxBps: number
txBps: number
running: boolean
disabled: boolean
countersKnown?: boolean
}
export interface RatePoint {
t: number
rxMbps: number
txMbps: number
}
export interface TrafficObservations {rxNow:boolean;txNow:boolean;rxSeries:boolean[];txSeries:boolean[];sampledAt:string|null;expiresAt?:string|null;
currentState?: import("./traffic-freshness.js").TrafficCurrentState;lastAttemptAt?:string|null;lastSucceededAt?:string|null;error?:string|null}
export interface BuiltTrafficSeries {
observations?:TrafficObservations
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotalGiB: number
txTotalGiB: number
sessions: number
rxSeries: number[]
txSeries: number[]
}
export interface MonitorLiveSample {
rxMbps: number
txMbps: number
at: string
timestampKind: "backend-receipt"
}
export function isLoopbackName(name: string): boolean {
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
}
export function shouldIncludeIface(
name: string,
running: boolean,
disabled: boolean,
onlyInterface?: string,
): boolean {
if (onlyInterface) return name === onlyInterface
if (isLoopbackName(name)) return false
return running && !disabled
}
export function bpsToMbps(bps: number): number {
if (!Number.isFinite(bps) || bps <= 0) return 0
return bps / 1_000_000
}
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
export function rateBpsFromDelta(
prevBytes: number,
nextBytes: number,
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
const rate=((nextBytes - prevBytes) * 8) / dtSec
return Number.isFinite(rate)?rate:null
}
export function bucketAvg(
points: Array<{ t: number; v: number }>,
rangeStartMs: number,
rangeEndMs: number,
target = SERIES_POINTS,
): number[] {
const buckets = Array.from({ length: target }, () => 0)
const counts = Array.from({ length: target }, () => 0)
const span = rangeEndMs - rangeStartMs
if (span <= 0 || points.length === 0) return buckets
for (const p of points) {
const ratio = (p.t - rangeStartMs) / span
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
buckets[i] += p.v
counts[i] += 1
}
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
}
function parseIsoMs(iso: string): number {
return Date.parse(iso)
}
export function sampleSeriesKey(interfaceName: string, peerPublicKey = ""): string {
return `${interfaceName}\0${peerPublicKey}`
}
export function buildTrafficFromSamples(
rows: TrafficSampleLike[],
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string | readonly string[],
peerPublicKey?: string,
includeObservations=false,
): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
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) {
const peer = r.peerPublicKey ?? ""
const arr = bySeries.get(sampleSeriesKey(r.interfaceName, peer)) ?? []
arr.push(r)
bySeries.set(sampleSeriesKey(r.interfaceName, peer), arr)
}
const allowList = Array.isArray(onlyInterface)
? onlyInterface
: (typeof onlyInterface === "string" ? [onlyInterface] : null)
const rxPoints: Array<{ t: number; v: number }> = []
const txPoints: Array<{ t: number; v: 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
let sessions = 0
for (const [key, arr] of bySeries) {
const sep = key.indexOf("\0")
const name = sep >= 0 ? key.slice(0, sep) : key
const peer = sep >= 0 ? key.slice(sep + 1) : ""
if (allowList) {
if (!allowList.includes(name)) continue
} else if (isLoopbackName(name)) {
continue
}
if (peerPublicKey === undefined) {
if (peer !== "") continue
} else if (peer !== peerPublicKey) {
continue
}
const sorted = [...arr].sort((a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt))
const last = sorted[sorted.length - 1]
if (!last) continue
if (!allowList && (!last.running || last.disabled)) continue
includedSeries++
if (last.running && !last.disabled) sessions += 1
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const cur = sorted[i]
if (!prev || !cur) continue
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 countersKnown = prev.countersKnown !== false && cur.countersKnown !== false
const rxBps = countersKnown ? rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1) : null
const txBps = countersKnown ? rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1) : null
if (t1 >= rangeStartMs && t1 <= rangeEndMs) {
if (rxBps !== null) rxBytesDelta += cur.rxBytes - prev.rxBytes
if (txBps !== null) txBytesDelta += cur.txBytes - prev.txBytes
}
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,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)
}
}
for (const [t, v] of byTs) {
rxPoints.push({ t, v: v.rx })
txPoints.push({ t, v: v.tx })
}
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 rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
return {
rxNow: last?.rx ?? 0,
txNow: last?.tx ?? 0,
rxPeak,
txPeak,
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
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(),
}}:{}),
}
}
export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeries {
const empty: BuiltTrafficSeries = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
if (parts.length === 0) return empty
const acc = {
rxNow: 0,
txNow: 0,
rxPeak: 0,
txPeak: 0,
rxTotalGiB: 0,
txTotalGiB: 0,
sessions: 0,
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
}
for (const p of parts) {
acc.rxNow += p.rxNow
acc.txNow += p.txNow
acc.rxPeak += p.rxPeak
acc.txPeak += p.txPeak
acc.rxTotalGiB += p.rxTotalGiB
acc.txTotalGiB += p.txTotalGiB
acc.sessions += p.sessions
for (let i = 0; i < SERIES_POINTS; i++) {
acc.rxSeries[i] += p.rxSeries[i] ?? 0
acc.txSeries[i] += p.txSeries[i] ?? 0
}
}
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
return parts.every(p => p.observations) ? { ...acc, observations: mergeTrafficObservations(parts.map(p => p.observations!)) } : acc
}
export function parseMonitorTraffic(
raw: unknown,
opts?: { onlyInterface?: string; expectedInterfaces?: readonly string[] },
): MonitorLiveSample {
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
let rxBps = 0
let txBps = 0
const seen = new Set<string>()
const expected = opts?.expectedInterfaces ? new Set(opts.expectedInterfaces) : null
const rate = (raw: unknown) => {
if (typeof raw !== "number" && (typeof raw !== "string" || !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw.trim()))) throw new Error("Некорректная скорость monitor-traffic")
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) throw new Error("Некорректная скорость monitor-traffic")
return n
}
for (const item of items) {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("Некорректный ответ monitor-traffic")
const rec = item as Record<string, unknown>
const name = rec.name ?? rec.interface
if (typeof name !== "string" || !name.trim()) throw new Error("Не указан интерфейс monitor-traffic")
if (opts?.onlyInterface) {
if (name !== opts.onlyInterface) throw new Error("Ответ monitor-traffic относится к другому интерфейсу")
} else if (isLoopbackName(name)) {
continue
}
if (seen.has(name) || expected && !expected.has(name)) throw new Error("Некорректный набор интерфейсов monitor-traffic")
seen.add(name)
rxBps += rate(rec["rx-bits-per-second"])
txBps += rate(rec["tx-bits-per-second"])
}
if (!seen.size || expected && (expected.size !== opts!.expectedInterfaces!.length ||
[...expected].some(name => !seen.has(name)))) throw new Error("Не получены все live-измерения интерфейсов")
if (![rxBps, txBps].every(n => Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER)) throw new Error("Суммарная live-скорость вне допустимого диапазона")
return {
rxMbps: rxBps / 1_000_000,
txMbps: txBps / 1_000_000,
at: new Date().toISOString(),
timestampKind: "backend-receipt",
}
}