From 564aae21f0913583e6e2ff017e779a418dbd5304 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 12 Sep 2026 13:22:06 +0700 Subject: [PATCH] =?UTF-8?q?fix(statistics):=20=D0=B2=D1=8B=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D1=8F=D1=82=D1=8C=20=D1=82=D1=80=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA-=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D0=B9=20=D1=81=D0=B5=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены). - Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна. - Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service. - Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток. --- .../src/services/statistics-aggregate.test.ts | 39 ++++++++++++++++++- backend/src/services/statistics-aggregate.ts | 33 ++++++++++++---- components/statistics/period-selector.tsx | 36 +++++++++++++---- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/backend/src/services/statistics-aggregate.test.ts b/backend/src/services/statistics-aggregate.test.ts index c3f9d27..c2f5377 100644 --- a/backend/src/services/statistics-aggregate.test.ts +++ b/backend/src/services/statistics-aggregate.test.ts @@ -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) diff --git a/backend/src/services/statistics-aggregate.ts b/backend/src/services/statistics-aggregate.ts index 0d2ffd3..34b22b3 100644 --- a/backend/src/services/statistics-aggregate.ts +++ b/backend/src/services/statistics-aggregate.ts @@ -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 ({ - id: r.id, - label: r.id, - bytes: Number(r.bytes) || 0, - packets: Number(r.packets) || 0, - })), + Object.entries(serviceRows.reduce>((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 { + // «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