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

+6 -1
View File
@@ -1,6 +1,11 @@
import assert from "node:assert/strict"
import { fmtRate } from "./fmt-rate.js"
import { fmtRate, fmtGiB } from "./fmt-rate.js"
assert.equal(fmtRate(null),"—");assert.equal(fmtRate(undefined),"—");assert.equal(fmtRate(0),"0 Кбит/с")
assert.equal(fmtRate(0.000001),"<1 Кбит/с");assert.equal(fmtRate(0.001),"1 Кбит/с")
assert.equal(fmtRate(1),"1.00 Мбит/с");assert.equal(fmtRate(1000),"1.00 Гбит/с")
console.log("rate formatting unknown/zero/positive-small/units tests ok")
assert.equal(fmtGiB(null),"—");assert.equal(fmtGiB(0),"0 Б")
assert.equal(fmtGiB(1/1024**3),"1 Б");assert.equal(fmtGiB(1/1024**2),"1 КиБ")
assert.equal(fmtGiB(1/1024),"1 МиБ");assert.equal(fmtGiB(1),"1 ГиБ");assert.equal(fmtGiB(1024),"1 ТиБ")
assert.equal(fmtGiB(100/1024),"100 МиБ");assert.equal(fmtGiB(Number.MAX_VALUE),"—")
console.log("binary traffic volume precision/unit boundary tests ok")
+11 -4
View File
@@ -10,10 +10,17 @@ export function fmtRate(v: number | null | undefined): string {
return `${Math.round(v)} Мбит/с`
}
export function fmtGB(v: number | null | undefined): string {
if (v == null) return "—"
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
return `${v.toFixed(1)} ГБ`
const binaryFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 })
/** Traffic API volumeUnit=GiB: preserve binary units and never format positive bytes as zero. */
export function fmtGiB(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v) || v < 0) return "—"
const bytes = v * 1024 ** 3
if (!Number.isFinite(bytes)) return "—"
if (bytes === 0) return "0 Б"
if (bytes < 1) return "<1 Б"
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ"]
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
return `${binaryFormatter.format(bytes / 1024 ** index)} ${units[index]}`
}
export function formatBytes(n: number): string {
+3
View File
@@ -5,6 +5,9 @@ const row={rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal:0,txTotal:0,rxSeries:[0,0],
const obs={rxNow:true,txNow:true,rxSeries:[false,true],txSeries:[false,true],sampledAt,currentState:"current",expiresAt}
assert.equal(projectTrafficObservations(row,at).rxNow,null)
const known=projectTrafficObservations({...row,observations:obs},at);assert.equal(known.rxNow,0);assert.deepEqual(known.rxSeries,[null,0])
assert.equal(known.rxTotal,null)
assert.equal(projectTrafficObservations({...row,volumeUnit:"GiB",observations:obs},at).rxTotal,0)
assert.throws(()=>projectTrafficObservations({...row,volumeUnit:"GB",observations:obs},at))
const stale=projectTrafficObservations({...row,observations:obs},at+90_001);assert.equal(stale.rxNow,null);assert.equal(stale.observations?.currentState,"stale");assert.deepEqual(stale.rxSeries,[null,0])
assert.equal(projectTrafficObservations({...row,observations:{...obs,currentState:"error"}},at).rxNow,null)
for(const change of [{rxNow:-1},{observations:{...obs,sampledAt:"bad"}},{observations:{...obs,sampledAt:null}},{observations:{...obs,rxSeries:[]}}])assert.throws(()=>projectTrafficObservations({...row,...change},at))
+3 -3
View File
@@ -5,7 +5,7 @@ const meta = z.object({ rxNow: z.boolean(), txNow: z.boolean(), rxSeries: z.arra
sampledAt: timestamp, expiresAt: timestamp.optional(), lastAttemptAt: timestamp.optional(), lastSucceededAt: timestamp.optional(), error: z.string().nullable().optional(),
currentState: z.enum(["current", "unknown", "pending", "error", "stale", "disabled"]).optional() })
export type TrafficObservationMeta = z.infer<typeof meta>
const metrics = z.object({ rxNow: rate, txNow: rate, rxPeak: rate, txPeak: rate, rxTotal: rate, txTotal: rate,
const metrics = z.object({ volumeUnit: z.literal("GiB").optional(), rxNow: rate, txNow: rate, rxPeak: rate, txPeak: rate, rxTotal: rate, txTotal: rate,
rxSeries: z.array(rate), txSeries: z.array(rate), observations: meta.optional() }).refine(r =>
(!r.observations || r.observations.rxSeries.length === r.rxSeries.length && r.observations.txSeries.length === r.txSeries.length &&
(!(r.observations.rxNow || r.observations.txNow || r.observations.rxSeries.some(Boolean) || r.observations.txSeries.some(Boolean)) || r.observations.sampledAt !== null)))
@@ -18,8 +18,8 @@ export function projectTrafficObservations(raw: unknown, now = Date.now()) {
const current = !o?.currentState || o.currentState === "current"
const rxSeries = r.rxSeries.map((v, i) => o?.rxSeries[i] ? v : null)
const txSeries = r.txSeries.map((v, i) => o?.txSeries[i] ? v : null)
return { observations: o, rxNow: current && o?.rxNow ? r.rxNow : null, txNow: current && o?.txNow ? r.txNow : null,
return { observations: o, volumeUnit: r.volumeUnit, rxNow: current && o?.rxNow ? r.rxNow : null, txNow: current && o?.txNow ? r.txNow : null,
rxSeries, txSeries, rxPeak: rxSeries.some(v => v !== null) ? r.rxPeak : null,
txPeak: txSeries.some(v => v !== null) ? r.txPeak : null,
rxTotal: rxSeries.some(v => v !== null) ? r.rxTotal : null, txTotal: txSeries.some(v => v !== null) ? r.txTotal : null }
rxTotal: r.volumeUnit && rxSeries.some(v => v !== null) ? r.rxTotal : null, txTotal: r.volumeUnit && txSeries.some(v => v !== null) ? r.txTotal : null }
}