fix(traffic): сохранять точность объёмов и обозначать бинарные единицы

This commit is contained in:
Denozordec committed 2026-10-12 21:59:50 +07:00
1 parent 2358ecef60
commit 2b3d583e3b
29 files changed
+1452 -39

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
+2
View File
@@ -28,6 +28,7 @@ import {
} from "../services/traffic-users.js"
interface TrafficServerDto {
volumeUnit: "GiB"
observations?:TrafficObservations
id: string
name: string
@@ -95,6 +96,7 @@ function buildServerTraffic(
): TrafficServerDto {
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface,undefined,true)
return {
volumeUnit: "GiB",
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
@@ -58,6 +58,7 @@ const samples: TrafficSampleLike[] = [
const built = buildTrafficFromSamples(samples, start, end)
assert.ok(built.rxPeak > 0, "peak RX from delta")
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
assert.equal(built.rxTotalGiB,3_750_000/1024**3,"only observed positive delta counts; reset is not volume")
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
+2 -4
View File
@@ -216,8 +216,8 @@ export function buildTrafficFromSamples(
txNow: last?.tx ?? 0,
rxPeak,
txPeak,
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
rxTotalGiB: rxBytesDelta / (1024 ** 3),
txTotalGiB: txBytesDelta / (1024 ** 3),
sessions,
rxSeries,
txSeries,
@@ -268,8 +268,6 @@ export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeri
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
}
+4 -8
View File
@@ -1,4 +1,3 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
@@ -13,6 +12,7 @@ import { applyTrafficFreshness } from "./traffic-freshness.js"
import type { TrafficObservations } from "./traffic-rate.js"
export interface BoundIfaceTrafficDto {
volumeUnit: "GiB"
observations?: TrafficObservations
id: string
bindingId: string
@@ -40,6 +40,7 @@ export interface BoundIfaceTrafficDto {
}
export interface UserTrafficDto {
volumeUnit: "GiB"
observations?: TrafficObservations
id: string
login: string
@@ -59,6 +60,7 @@ export interface UserTrafficDto {
function seriesFromBuilt(built: BuiltTrafficSeries) {
return {
volumeUnit: "GiB" as const,
observations: built.observations,
rxNow: built.rxNow,
txNow: built.txNow,
@@ -71,12 +73,6 @@ function seriesFromBuilt(built: BuiltTrafficSeries) {
}
}
async function serverStatus(serverId: number): Promise<"online" | "offline"> {
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row?.enabled) return "offline"
return "online"
}
export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): Promise<UserTrafficDto[]> {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = await listUsers()
@@ -122,7 +118,7 @@ export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: num
serverSite: b.serverSite,
serverCountry: b.serverCountry,
...seriesFromBuilt(built),
status: running && (await serverStatus(b.serverId)) === "online" ? "online" : "offline",
status: running && server?.enabled ? "online" : "offline",
})
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { pool, closePool } from "../db/index.js"
import { initDatabase } from "../db/bootstrap.js"
import trafficRoutes from "../routes/traffic.js"
const database=process.env.MMAPP_OWN_SQL_DB
if(!database)console.log("traffic volume SQL: skipped; explicit owned database required")
else {
assert(/^mm_accept_166_\d+$/.test(database));assert.equal(new URL(process.env.DATABASE_URL!).pathname,"/"+database)
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name,database)
const prefix="own-volume180-"+Date.now()+"-",userId=prefix+"user",at=new Date(Date.now()-5000).toISOString(),updatedAt=new Date(Date.now()-3600_000).toISOString()
let serverId:number|undefined,cases=0
const app=Fastify();await app.register(trafficRoutes)
const get=async(path:string)=>{const r=await app.inject({method:"GET",url:path});assert.equal(r.statusCode,200);return r.json()}
const put=async(name:string,offset:number,bytes:number)=>pool.query("INSERT INTO traffic_samples(server_id,interface_name,sampled_at,rx_bytes,tx_bytes,flags) VALUES($1,$2,$3,$4,$4,5)",[serverId,name,new Date(Date.parse(at)+offset).toISOString(),bytes])
try {
await initDatabase()
serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,updated_at) VALUES($1,'127.0.0.1',1,false,true,'fixture','fixture',$2) RETURNING id",[prefix+"server",updatedAt])).rows[0].id)
await pool.query("INSERT INTO traffic_settings(id,enabled,interval_sec) VALUES(1,true,30) ON CONFLICT(id) DO UPDATE SET enabled=true,interval_sec=30")
await pool.query("INSERT INTO traffic_collection_state(server_id,server_updated_at,last_attempt_at,last_succeeded_at,status) VALUES($1,$2,$3,$3,'ok')",[serverId,updatedAt,at])
await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$2,'Volume fixture')",[userId,prefix+"login"])
await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,'ether1','ether')",[prefix+"binding",userId,serverId])
for(const bytes of[1,1024,1024**2,1024**3]){
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await put("ether1",-30_000,0);await put("ether1",0,bytes)
const s=(await get(`/traffic/servers/${serverId}?range=1h`)).server
assert.equal(s.volumeUnit,"GiB");assert.equal(s.rxTotal,bytes/1024**3);assert.equal(s.observations.rxNow,true)
const user=(await get("/traffic/users?range=1h")).users.find((u:{id:string})=>u.id===userId)
assert.equal(user.volumeUnit,"GiB");assert.equal(user.rxTotal,s.rxTotal)
const iface=(await get("/traffic/bound-interfaces?range=1h")).interfaces.find((i:{userId:string})=>i.userId===userId)
assert.equal(iface.volumeUnit,"GiB");assert.equal(iface.rxTotal,s.rxTotal);cases++
}
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await pool.query("DELETE FROM user_interface_bindings WHERE user_id=$1",[userId])
for(let i=0;i<100;i++){const name=`ether${i}`;await put(name,-30_000,0);await put(name,0,1024**2);await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,$4,'ether')",[prefix+"binding"+i,userId,serverId,name])}
let s=(await get(`/traffic/servers/${serverId}?range=1h`)).server;assert.equal(s.rxTotal,100/1024)
const user=(await get("/traffic/users?range=1h")).users.find((u:{id:string})=>u.id===userId);assert.equal(user.rxTotal,100/1024);cases++
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await put("ether1",-90_000,0);await put("ether1",-60_000,1024**2);await put("ether1",-30_000,512);await put("ether1",0,1024)
s=(await get(`/traffic/servers/${serverId}?range=1h`)).server;assert.equal(s.rxTotal,(1024**2+512)/1024**3);cases++
console.log(`traffic volume owned SQL: ${cases} cases; exact bytes/binary unit/server/user/binding/100-part aggregate/reset passed`)
}finally{await app.close();await pool.query("DELETE FROM app_users WHERE id=$1",[userId]);if(serverId!==undefined){await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await pool.query("DELETE FROM servers WHERE id=$1",[serverId])}await closePool()}
}