Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
Docker images / frontend-image (push) Successful in 3m28s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены). - Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна. - Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service. - Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import assert from "node:assert/strict"
|
|
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"
|
|
import { withPgOrSkip } from "../test/pg.js"
|
|
import { dbQuery } from "../db/index.js"
|
|
import { ensurePartitionFor } from "../db/partitions.js"
|
|
import { pool } from "../db/index.js"
|
|
import { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics"
|
|
|
|
{
|
|
const sameDay = parseStatisticsPeriod("2026-09-10", "2026-09-10")
|
|
assert.ok(sameDay)
|
|
assert.equal(sameDay.fromDay, "2026-09-10")
|
|
assert.equal(sameDay.toDayExclusive, "2026-09-11")
|
|
assert.equal(sameDay.grain, "hour")
|
|
const month = parseStatisticsPeriod("2026-08-01", "2026-08-31")
|
|
assert.ok(month)
|
|
assert.equal(month.grain, "day")
|
|
assert.equal(month.toDayExclusive, "2026-09-01")
|
|
assert.equal(parseStatisticsPeriod("2026-09-10", "2026-09-09"), null)
|
|
assert.equal(pivotDimsConflict("country", "country"), true)
|
|
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)
|
|
}
|
|
|
|
const inserted = await dbQuery<{ id: number }>(`
|
|
INSERT INTO servers (name, host, type, wan_uplinks)
|
|
VALUES ('stats-cube', '127.0.0.1', 'jump-host', '[{"iface":"wan1"}]'::jsonb)
|
|
RETURNING id
|
|
`)
|
|
const serverId = inserted.rows[0]?.id
|
|
if (serverId == null) throw new Error("no server")
|
|
|
|
const enInserted = await dbQuery<{ id: number }>(`
|
|
INSERT INTO servers (name, host, type)
|
|
VALUES ('stats-en', '198.51.100.1', 'exit-node')
|
|
RETURNING id
|
|
`)
|
|
const enId = enInserted.rows[0]?.id
|
|
if (enId == null) throw new Error("no en server")
|
|
|
|
await ensurePartitionFor(pool, "flow_daily_facts", "month", new Date("2026-09-01T00:00:00Z"))
|
|
await ensurePartitionFor(pool, "flow_hour_facts", "day", new Date("2026-09-10T00:00:00Z"))
|
|
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = $1`, [serverId])
|
|
await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM app_users WHERE id = 'u-stats-1'`)
|
|
|
|
await dbQuery(`
|
|
INSERT INTO app_users (id, name, login, role, active)
|
|
VALUES ('u-stats-1', 'Клиент', 'stats-user', 'viewer', TRUE)
|
|
ON CONFLICT (id) DO NOTHING
|
|
`)
|
|
await dbQuery(`
|
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
|
VALUES ('bind-stats-1', 'u-stats-1', $1, 'gre-client', 'gre')
|
|
`, [serverId])
|
|
|
|
await dbQuery(`
|
|
INSERT INTO server_snapshots (server_id, polled_at, status, raw_interfaces)
|
|
VALUES
|
|
($1, '2026-09-10T12:00:00Z', 'online', $3::jsonb),
|
|
($2, '2026-09-10T12:00:00Z', 'online', $4::jsonb)
|
|
`, [
|
|
serverId,
|
|
enId,
|
|
JSON.stringify([
|
|
{ name: "gre-client", type: "gre-tunnel" },
|
|
{ name: "wan1", type: "ether" },
|
|
{ name: "gre-en", type: "gre-tunnel" },
|
|
{ name: "NSK-SERVHOST-RTK", type: "gre-tunnel" },
|
|
{ name: "wg-mesh", type: "wg" },
|
|
{ name: "wg-server", type: "wg" },
|
|
{ name: "wg-flow", type: "wg" },
|
|
]),
|
|
JSON.stringify([
|
|
{ name: "ether1", type: "ether" },
|
|
{ name: "gre-jh", type: "gre-tunnel" },
|
|
]),
|
|
])
|
|
|
|
resetIfaceCacheForTests()
|
|
rememberServerIfaces(serverId, [
|
|
{ name: "gre-client", ifindex: "2" },
|
|
{ name: "wan1", ifindex: "8" },
|
|
{ name: "gre-en", ifindex: "9" },
|
|
{ name: "NSK-SERVHOST-RTK" },
|
|
{ name: "wg-mesh" },
|
|
{ name: "wg-server" },
|
|
{ name: "wg-flow" },
|
|
])
|
|
rememberServerIfaces(enId, [
|
|
{ name: "ether1", ifindex: "2" },
|
|
{ name: "gre-jh", ifindex: "5" },
|
|
])
|
|
setRefreshIfacesForTests(async () => {})
|
|
invalidateFlowCatalogCache()
|
|
|
|
await dbQuery(`
|
|
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
|
|
VALUES
|
|
($1, '2026-09-10', '2', 'US', 'https', 15169, 800, 10),
|
|
($1, '2026-09-10', '2', 'DE', 'dns', 15133, 200, 4),
|
|
($1, '2026-09-10', 'wan1', 'NL', 'other', 0, 70, 1),
|
|
($1, '2026-09-10', '0', 'US', 'https', 0, 999, 3),
|
|
($1, '2026-09-10', 'gre-en', 'US', 'https', 15169, 400, 2),
|
|
($1, '2026-09-10', 'NSK-SERVHOST-RTK', 'US', 'https', 15169, 300, 2),
|
|
($1, '2026-09-10', 'wg-mesh', 'US', 'https', 0, 250, 2),
|
|
($1, '2026-09-10', 'wg-flow', 'US', 'https', 0, 80, 1),
|
|
($2, '2026-09-10', 'gre-jh', 'US', 'https', 15169, 500, 5),
|
|
($2, '2026-09-10', 'ether1', 'US', 'https', 15169, 200, 2)
|
|
`, [serverId, enId])
|
|
|
|
try {
|
|
const unique = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" })
|
|
assert.equal(unique.grain, "day")
|
|
assert.equal(unique.kpis.bytes, 1000)
|
|
const uniqueAsnSum = unique.asns.reduce((s, r) => s + r.bytes, 0)
|
|
assert.equal(uniqueAsnSum, unique.kpis.bytes, "unique KPI = SUM dest ASN")
|
|
assert.equal(unique.kpis.users, 1)
|
|
assert.ok(unique.countries.some((r) => r.id === "US"))
|
|
assert.ok(unique.users.some((r) => r.id === "u-stats-1"))
|
|
assert.equal(unique.users.find((r) => r.id === STATISTICS_UNBOUND_USER_ID), undefined)
|
|
assert.ok(unique.servers.some((r) => r.id === String(serverId)))
|
|
assert.ok(!unique.servers.some((r) => r.id === String(enId)), "EN-транзит не в сетевом KPI")
|
|
const greIface = unique.interfaces.find((r) => r.label.includes("gre-client"))
|
|
assert.ok(greIface)
|
|
assert.equal(greIface.bytes, 1000)
|
|
assert.equal(greIface.id, `${serverId}:gre-client`)
|
|
assert.ok(!unique.interfaces.some((r) => /· (?:#)?\d+$/.test(r.label)))
|
|
assert.ok(!unique.interfaces.some((r) => r.label.includes(" · —") || r.label.endsWith("· —")))
|
|
assert.ok(!unique.interfaces.some((r) => r.label.includes("gre-en")))
|
|
assert.ok(!unique.interfaces.some((r) => r.label.includes("NSK-SERVHOST-RTK")))
|
|
assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-mesh")))
|
|
assert.ok(!unique.interfaces.some((r) => r.label.includes("wg-flow")))
|
|
assert.equal(unique.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined, "unique без WAN")
|
|
|
|
const allPlanes = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "all" })
|
|
assert.equal(allPlanes.kpis.bytes, 1000, "KPI unique и all одинаковый")
|
|
const wanRow = allPlanes.interfaces.find((r) => r.id === `${serverId}:wan1`)
|
|
assert.ok(wanRow)
|
|
assert.ok(wanRow.label.includes("WAN · интернет"))
|
|
assert.equal(wanRow.bytes, 70)
|
|
assert.equal(wanRow.percent, 0)
|
|
const overlayGre = allPlanes.interfaces.find((r) => r.id === `${serverId}:gre-en`)
|
|
assert.ok(overlayGre)
|
|
assert.ok(overlayGre.label.includes("дубль"))
|
|
assert.equal(overlayGre.percent, 0)
|
|
const overlayCustom = allPlanes.interfaces.find((r) => r.label.includes("NSK-SERVHOST-RTK"))
|
|
assert.ok(overlayCustom)
|
|
assert.ok(overlayCustom.label.includes("дубль"))
|
|
const overlayWg = allPlanes.interfaces.find((r) => r.label.includes("wg-mesh"))
|
|
assert.ok(overlayWg)
|
|
assert.ok(overlayWg.label.includes("дубль"))
|
|
assert.ok(!allPlanes.interfaces.some((r) => r.label.includes("wg-flow")))
|
|
|
|
const wanSlice = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
serverId,
|
|
iface: "wan1",
|
|
})
|
|
assert.equal(wanSlice.kpis.bytes, 70)
|
|
|
|
const nodeSlice = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
serverId,
|
|
planes: "unique",
|
|
})
|
|
assert.equal(nodeSlice.kpis.bytes, 1000)
|
|
assert.equal(nodeSlice.interfaces.find((r) => r.id === `${serverId}:wan1`), undefined)
|
|
assert.ok(!nodeSlice.users.some((r) => r.id === STATISTICS_UNBOUND_USER_ID))
|
|
|
|
const nodeAll = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
serverId,
|
|
planes: "all",
|
|
})
|
|
assert.equal(nodeAll.kpis.bytes, 1000)
|
|
const nodeWan = nodeAll.interfaces.find((r) => r.id === `${serverId}:wan1`)
|
|
assert.ok(nodeWan)
|
|
assert.equal(nodeWan.percent, 0)
|
|
assert.ok(nodeWan.label.includes("WAN · интернет"))
|
|
|
|
const enSlice = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
serverId: enId,
|
|
planes: "unique",
|
|
})
|
|
assert.equal(enSlice.kpis.bytes, 0)
|
|
assert.ok(!enSlice.interfaces.some((r) => r.label.includes("gre-jh")))
|
|
assert.ok(!enSlice.interfaces.some((r) => r.label.includes("WAN · интернет")))
|
|
|
|
const enAll = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
serverId: enId,
|
|
planes: "all",
|
|
})
|
|
assert.equal(enAll.kpis.bytes, 0)
|
|
assert.ok(enAll.interfaces.some((r) => r.label.includes("WAN · интернет") && r.label.includes("ether1") && r.percent === 0))
|
|
assert.ok(enAll.interfaces.some((r) => r.label.includes("gre-jh") && r.label.includes("дубль")))
|
|
|
|
const sliced = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
country: "US",
|
|
service: "https",
|
|
asn: 15169,
|
|
})
|
|
assert.equal(sliced.kpis.bytes, 800)
|
|
assert.equal(sliced.countries.length, 1)
|
|
assert.equal(sliced.countries[0]?.id, "US")
|
|
assert.ok(sliced.users.some((r) => r.id === "u-stats-1"))
|
|
|
|
const byUser = await getStatistics({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
userId: "u-stats-1",
|
|
})
|
|
assert.equal(byUser.kpis.bytes, 1000)
|
|
|
|
const pivot = await getStatisticsPivot({
|
|
from: "2026-09-01",
|
|
to: "2026-09-30",
|
|
row: "country",
|
|
col: "service",
|
|
metric: "bytes",
|
|
})
|
|
const us = pivot.rows.find((r) => r.id === "US")
|
|
const de = pivot.rows.find((r) => r.id === "DE")
|
|
assert.ok(us)
|
|
assert.ok(de)
|
|
assert.equal(us.cells.https, 800)
|
|
assert.equal(de.cells.dns, 200)
|
|
|
|
await dbQuery(`
|
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
|
VALUES ('bind-stats-wg', 'u-stats-1', $1, 'wg-server', 'wg')
|
|
`, [serverId])
|
|
await dbQuery(`
|
|
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
|
|
VALUES ($1, '2026-09-10', 'wg-server', 'US', 'https', 15169, 150, 2)
|
|
`, [serverId])
|
|
invalidateFlowCatalogCache()
|
|
|
|
const withWg = await getStatistics({ from: "2026-09-01", to: "2026-09-30", planes: "unique" })
|
|
assert.equal(withWg.kpis.bytes, 1150)
|
|
assert.ok(withWg.interfaces.some((r) => r.label.includes("wg-server") && r.bytes === 150))
|
|
assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-flow")))
|
|
assert.ok(!withWg.interfaces.some((r) => r.label.includes("wg-mesh")))
|
|
|
|
await dbQuery(`
|
|
INSERT INTO flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets)
|
|
VALUES ($1, '2026-09-10T10:00:00Z', '2', 'US', 'https', 15169, 40, 2)
|
|
`, [serverId])
|
|
const hourly = await getStatistics({
|
|
from: "2026-09-10T00:00:00.000Z",
|
|
to: "2026-09-10T23:00:00.000Z",
|
|
})
|
|
assert.equal(hourly.grain, "hour")
|
|
assert.equal(hourly.kpis.bytes, 40)
|
|
assert.ok(hourly.users.some((r) => r.id === "u-stats-1"))
|
|
} finally {
|
|
setRefreshIfacesForTests(null)
|
|
resetIfaceCacheForTests()
|
|
invalidateFlowCatalogCache()
|
|
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM server_snapshots WHERE server_id IN ($1, $2)`, [serverId, enId])
|
|
await dbQuery(`DELETE FROM servers WHERE id IN ($1, $2)`, [serverId, enId])
|
|
}
|
|
|
|
console.log("statistics-aggregate.test.ts: ok")
|