fix(statistics): выровнять трафик-данные с картой сети
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены). - Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна. - Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service. - Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
|
||||
import { getStatistics, getStatisticsPivot, normalizeFactService, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
|
||||
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||||
import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
|
||||
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||
@@ -24,6 +24,43 @@ import { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics"
|
||||
assert.equal(pivotDimsConflict("country", "service"), false)
|
||||
}
|
||||
|
||||
{
|
||||
const nowMs = Date.parse("2026-09-12T12:00:00Z")
|
||||
// «Сегодня»: окно до сейчас, не до конца суток — иначе avgBps размывается будущими часами.
|
||||
const today = parseStatisticsPeriod("2026-09-12", "2026-09-12", nowMs)
|
||||
assert.ok(today)
|
||||
assert.equal(today.windowSec, 12 * 3600)
|
||||
assert.equal(today.grain, "hour")
|
||||
assert.equal(today.toDayExclusive, "2026-09-13", "дневные факты текущего дня не теряем")
|
||||
// Прошлые периоды не клампятся.
|
||||
const past = parseStatisticsPeriod("2026-09-10", "2026-09-10", nowMs)
|
||||
assert.ok(past)
|
||||
assert.equal(past.windowSec, 86_400)
|
||||
// ISO-диапазон ровно 24 часа.
|
||||
const iso24 = parseStatisticsPeriod("2026-09-11T12:00:00Z", "2026-09-12T12:00:00Z", nowMs)
|
||||
assert.ok(iso24)
|
||||
assert.equal(iso24.windowSec, 86_400)
|
||||
assert.equal(iso24.grain, "hour")
|
||||
// «to» далеко в будущем клампится к сейчас.
|
||||
const futureTo = parseStatisticsPeriod("2026-09-11", "2026-09-20", nowMs)
|
||||
assert.ok(futureTo)
|
||||
assert.equal(futureTo.windowSec, 86_400 + 12 * 3600)
|
||||
// Полностью будущий диапазон невалиден.
|
||||
assert.equal(parseStatisticsPeriod("2026-09-13", "2026-09-14", nowMs), null)
|
||||
}
|
||||
|
||||
{
|
||||
// Таксономия сервисов как на карте: skip-список сворачивается в «Прочее».
|
||||
assert.equal(normalizeFactService("Google"), "Google")
|
||||
assert.equal(normalizeFactService("DNS"), "Прочее")
|
||||
assert.equal(normalizeFactService("SSH"), "Прочее")
|
||||
assert.equal(normalizeFactService("BGP"), "Прочее")
|
||||
assert.equal(normalizeFactService("WireGuard"), "Прочее")
|
||||
assert.equal(normalizeFactService("GRE"), "Прочее")
|
||||
assert.equal(normalizeFactService("Прочее"), "Прочее")
|
||||
assert.equal(normalizeFactService(""), "Прочее")
|
||||
}
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("statistics-aggregate.test.ts: skip")
|
||||
process.exit(0)
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
wanIfaceLabel,
|
||||
} from "./traffic-flow-facts-filter.js"
|
||||
import { getServerCatalog, loadFlowTopology, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
import { OTHER_SERVICE, isNamedInternetService } from "./traffic-flow-brands.js"
|
||||
|
||||
/** Так же, как на карте сети: DNS/SSH/BGP/туннели и пустые метки — не отдельные сервисы, а «Прочее». */
|
||||
export function normalizeFactService(label: string): string {
|
||||
const s = String(label ?? "").trim()
|
||||
return isNamedInternetService(s, "") ? s : OTHER_SERVICE
|
||||
}
|
||||
|
||||
const TOP_N = 200
|
||||
const HOUR_WINDOW_MS = 48 * 3600_000
|
||||
@@ -58,7 +65,7 @@ function addUtcDays(day: string, n: number): string {
|
||||
}
|
||||
|
||||
/** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */
|
||||
export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPeriod | null {
|
||||
export function parseStatisticsPeriod(fromRaw: string, toRaw: string, nowMs: number = Date.now()): ParsedPeriod | null {
|
||||
const from = Date.parse(fromRaw.includes("T") ? fromRaw : `${fromRaw}T00:00:00Z`)
|
||||
const toHasTime = toRaw.includes("T")
|
||||
const to = Date.parse(toHasTime ? toRaw : `${toRaw}T00:00:00Z`)
|
||||
@@ -75,6 +82,9 @@ export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPer
|
||||
toDayExclusive = addUtcDays(toUtcDay(toDate), 1)
|
||||
toDate = new Date(`${toDayExclusive}T00:00:00Z`)
|
||||
}
|
||||
// Конец периода в будущем (например, «сегодня»): окно длится только до сейчас,
|
||||
// иначе avgBps размывается ещё не наступившими часами суток.
|
||||
if (toDate.getTime() > nowMs) toDate = new Date(nowMs)
|
||||
if (toDate.getTime() <= from) return null
|
||||
const windowSec = Math.max(1, Math.round((toDate.getTime() - from) / 1000))
|
||||
const grain: "hour" | "day" = toDate.getTime() - from <= HOUR_WINDOW_MS ? "hour" : "day"
|
||||
@@ -553,12 +563,14 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
period.windowSec,
|
||||
)
|
||||
const services = toBreakdown(
|
||||
serviceRows.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.id,
|
||||
bytes: Number(r.bytes) || 0,
|
||||
packets: Number(r.packets) || 0,
|
||||
})),
|
||||
Object.entries(serviceRows.reduce<Record<string, { bytes: number; packets: number }>>((acc, r) => {
|
||||
const key = normalizeFactService(r.id)
|
||||
const prev = acc[key] ?? { bytes: 0, packets: 0 }
|
||||
prev.bytes += Number(r.bytes) || 0
|
||||
prev.packets += Number(r.packets) || 0
|
||||
acc[key] = prev
|
||||
return acc
|
||||
}, {})).map(([label, v]) => ({ id: label, label, bytes: v.bytes, packets: v.packets })),
|
||||
bytes,
|
||||
period.windowSec,
|
||||
)
|
||||
@@ -702,6 +714,13 @@ export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<S
|
||||
GROUP BY 1, 2
|
||||
`, [...join.params, ...where.params])
|
||||
|
||||
if (query.row === "service" || query.col === "service") {
|
||||
for (const r of raw) {
|
||||
if (query.row === "service") r.row_id = normalizeFactService(String(r.row_id ?? ""))
|
||||
if (query.col === "service") r.col_id = normalizeFactService(String(r.col_id ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
if (query.row === "iface" || query.col === "iface") {
|
||||
const ifaceServerIds: number[] = []
|
||||
for (const r of raw) {
|
||||
|
||||
@@ -48,13 +48,24 @@ export function parseYmd(s: string): Date | null {
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
|
||||
export function parseRangeDate(s: string): Date | null {
|
||||
if (s.includes("T")) {
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
return parseYmd(s)
|
||||
}
|
||||
|
||||
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
|
||||
const to = formatYmd(now)
|
||||
if (preset === "today") return { from: to, to }
|
||||
if (preset === "24h") {
|
||||
const from = new Date(now)
|
||||
from.setDate(from.getDate() - 1)
|
||||
return { from: formatYmd(from), to }
|
||||
// Последние 24 часа от сейчас: полный ISO, иначе date-only «вчера…сегодня» даёт до ~48 ч.
|
||||
return {
|
||||
from: new Date(now.getTime() - 24 * 3600_000).toISOString(),
|
||||
to: now.toISOString(),
|
||||
}
|
||||
}
|
||||
if (preset === "7d") {
|
||||
const from = new Date(now)
|
||||
@@ -110,15 +121,20 @@ export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
|
||||
return {
|
||||
period: "day",
|
||||
operator: "between",
|
||||
startDate: parseYmd(range.from) ?? undefined,
|
||||
endDate: parseYmd(range.to) ?? undefined,
|
||||
startDate: parseRangeDate(range.from) ?? undefined,
|
||||
endDate: parseRangeDate(range.to) ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRangeLabel(range: DateRangeYmd): string {
|
||||
const from = parseYmd(range.from)
|
||||
const to = parseYmd(range.to)
|
||||
const from = parseRangeDate(range.from)
|
||||
const to = parseRangeDate(range.to)
|
||||
if (!from || !to) return "Период"
|
||||
if (range.from.includes("T") || range.to.includes("T")) {
|
||||
return formatYmd(from) === formatYmd(to)
|
||||
? `${format(from, "d MMM yyyy HH:mm", { locale: ru })} – ${format(to, "HH:mm", { locale: ru })}`
|
||||
: `${format(from, "d MMM HH:mm", { locale: ru })} – ${format(to, "d MMM HH:mm", { locale: ru })}`
|
||||
}
|
||||
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
|
||||
return `${format(from, "d MMM", { locale: ru })} – ${format(to, "d MMM yyyy", { locale: ru })}`
|
||||
}
|
||||
@@ -145,6 +161,12 @@ export function PeriodSelector({
|
||||
)
|
||||
|
||||
const activePreset = PRESETS.find((p) => {
|
||||
// «24 ч» хранится ISO-таймстампами: сверяем разбором (длительность окна), а не строками.
|
||||
if (p.id === "24h") {
|
||||
const f = parseRangeDate(range.from)
|
||||
const t = parseRangeDate(range.to)
|
||||
return Boolean(f && t && Math.abs((t.getTime() - f.getTime()) - 24 * 3600_000) < 60_000)
|
||||
}
|
||||
const r = rangeForPreset(p.id)
|
||||
return r.from === range.from && r.to === range.to
|
||||
})?.id
|
||||
|
||||
Reference in New Issue
Block a user