feat(statistics): добавить BI-разрез и сводную матрицу
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s
Сопоставить клиентов с ifIndex как на карте трафика, чтобы KPI пользователей не обнулялся. На экране — разрез остальных измерений и сводная матрица. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { statisticsQuerySchema } from "@mmapp/contracts/statistics"
|
||||
import { getStatistics } from "../services/statistics-aggregate.js"
|
||||
import { statisticsPivotQuerySchema, statisticsQuerySchema } from "@mmapp/contracts/statistics"
|
||||
import { getStatistics, getStatisticsPivot, pivotDimsConflict } from "../services/statistics-aggregate.js"
|
||||
|
||||
const statisticsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/statistics", async (req, reply) => {
|
||||
@@ -10,6 +10,17 @@ const statisticsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
return reply.send(await getStatistics(parsed.data))
|
||||
})
|
||||
|
||||
app.get("/statistics/pivot", async (req, reply) => {
|
||||
const parsed = statisticsPivotQuerySchema.safeParse(req.query ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректный период или измерения", details: parsed.error.flatten() })
|
||||
}
|
||||
if (pivotDimsConflict(parsed.data.row, parsed.data.col)) {
|
||||
return reply.status(400).send({ error: "Строки и колонки должны отличаться" })
|
||||
}
|
||||
return reply.send(await getStatisticsPivot(parsed.data))
|
||||
})
|
||||
}
|
||||
|
||||
export default statisticsRoutes
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { getStatistics, parseStatisticsPeriod } from "./statistics-aggregate.js"
|
||||
import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
|
||||
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.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")
|
||||
@@ -16,6 +18,8 @@ import { pool } from "../db/index.js"
|
||||
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)
|
||||
}
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
@@ -43,22 +47,30 @@ await dbQuery(`
|
||||
`)
|
||||
await dbQuery(`
|
||||
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||
VALUES ('bind-stats-1', 'u-stats-1', $1, 'ether1', 'ether')
|
||||
VALUES ('bind-stats-1', 'u-stats-1', $1, 'gre-client', 'gre')
|
||||
`, [serverId])
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
rememberServerIfaces(serverId, [{ name: "gre-client", ifindex: "2" }])
|
||||
|
||||
await dbQuery(`
|
||||
INSERT INTO flow_daily_facts (server_id, day, iface, country, service, asn, bytes, packets)
|
||||
VALUES
|
||||
($1, '2026-09-10', 'ether1', 'US', 'https', 15169, 800, 10),
|
||||
($1, '2026-09-10', 'ether1', 'DE', 'dns', 15133, 200, 4)
|
||||
($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)
|
||||
`, [serverId])
|
||||
|
||||
try {
|
||||
const all = await getStatistics({ from: "2026-09-01", to: "2026-09-30" })
|
||||
assert.equal(all.grain, "day")
|
||||
assert.equal(all.kpis.bytes, 1000)
|
||||
assert.equal(all.kpis.bytes, 1070)
|
||||
assert.equal(all.kpis.users, 1)
|
||||
assert.ok(all.countries.some((r) => r.id === "US"))
|
||||
assert.ok(all.users.some((r) => r.id === "u-stats-1"))
|
||||
const unbound = all.users.find((r) => r.id === STATISTICS_UNBOUND_USER_ID)
|
||||
assert.ok(unbound)
|
||||
assert.equal(unbound.bytes, 70)
|
||||
assert.ok(all.servers.some((r) => r.id === String(serverId)))
|
||||
|
||||
const sliced = await getStatistics({
|
||||
@@ -73,9 +85,30 @@ try {
|
||||
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 flow_hour_facts (server_id, bucket_at, iface, country, service, asn, bytes, packets)
|
||||
VALUES ($1, '2026-09-10T10:00:00Z', 'ether1', 'US', 'https', 15169, 40, 2)
|
||||
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",
|
||||
@@ -83,7 +116,9 @@ try {
|
||||
})
|
||||
assert.equal(hourly.grain, "hour")
|
||||
assert.equal(hourly.kpis.bytes, 40)
|
||||
assert.ok(hourly.users.some((r) => r.id === "u-stats-1"))
|
||||
} finally {
|
||||
resetIfaceCacheForTests()
|
||||
await dbQuery(`DELETE FROM flow_daily_facts WHERE server_id = $1`, [serverId])
|
||||
await dbQuery(`DELETE FROM flow_hour_facts WHERE server_id = $1`, [serverId])
|
||||
await dbQuery(`DELETE FROM servers WHERE id = $1`, [serverId])
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db, dbAll } from "../db/index.js"
|
||||
import { appUsers, flowAsnMeta, servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import type {
|
||||
StatisticsBreakdownRow,
|
||||
StatisticsDto,
|
||||
StatisticsQuery,
|
||||
import {
|
||||
STATISTICS_UNBOUND_USER_ID,
|
||||
type StatisticsBreakdownRow,
|
||||
type StatisticsDto,
|
||||
type StatisticsPivotDim,
|
||||
type StatisticsPivotDto,
|
||||
type StatisticsPivotQuery,
|
||||
type StatisticsQuery,
|
||||
} from "@mmapp/contracts/statistics"
|
||||
import {
|
||||
bindingIfaceAliases,
|
||||
bindingIfaceAliasesAllServers,
|
||||
expandBindingIfaces,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
const TOP_N = 200
|
||||
const HOUR_WINDOW_MS = 48 * 3600_000
|
||||
const PIVOT_ROW_CAP = 50
|
||||
const PIVOT_COL_CAP = 15
|
||||
const PIVOT_OTHER_ID = "__other__"
|
||||
|
||||
export interface ParsedPeriod {
|
||||
fromIso: string
|
||||
@@ -75,6 +87,15 @@ interface FilterCtx {
|
||||
service?: string
|
||||
asn?: number
|
||||
userIfaces: Array<{ serverId: number; iface: string }> | null
|
||||
unboundOnly: boolean
|
||||
boundIfaces: Array<{ serverId: number; iface: string }>
|
||||
}
|
||||
|
||||
function ifaceFilterAliases(iface: string, serverId?: number): string[] {
|
||||
const raw = iface.trim()
|
||||
if (!raw) return []
|
||||
if (serverId != null) return bindingIfaceAliases(serverId, raw)
|
||||
return bindingIfaceAliasesAllServers(raw)
|
||||
}
|
||||
|
||||
function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql: string; params: unknown[] } {
|
||||
@@ -92,8 +113,14 @@ function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql:
|
||||
params.push(ctx.serverId)
|
||||
}
|
||||
if (ctx.iface) {
|
||||
parts.push(`${alias}.iface = ?`)
|
||||
params.push(ctx.iface)
|
||||
const aliases = ifaceFilterAliases(ctx.iface, ctx.serverId)
|
||||
if (aliases.length <= 1) {
|
||||
parts.push(`${alias}.iface = ?`)
|
||||
params.push(aliases[0] ?? ctx.iface)
|
||||
} else {
|
||||
parts.push(`${alias}.iface IN (${aliases.map(() => "?").join(", ")})`)
|
||||
params.push(...aliases)
|
||||
}
|
||||
}
|
||||
if (ctx.country) {
|
||||
parts.push(`${alias}.country = ?`)
|
||||
@@ -118,11 +145,20 @@ function factWhere(alias: string, grain: "hour" | "day", ctx: FilterCtx): { sql:
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ctx.unboundOnly) {
|
||||
if (ctx.boundIfaces.length === 0) {
|
||||
/* весь трафик без привязок */
|
||||
} else {
|
||||
const tuples = ctx.boundIfaces.map(() => "(?, ?)").join(", ")
|
||||
parts.push(`(${alias}.server_id, ${alias}.iface) NOT IN (${tuples})`)
|
||||
for (const u of ctx.boundIfaces) {
|
||||
params.push(u.serverId, u.iface)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sql: parts.join(" AND "), params }
|
||||
}
|
||||
|
||||
type FilterCtxFull = FilterCtx
|
||||
|
||||
function emptyDto(period: ParsedPeriod): StatisticsDto {
|
||||
return {
|
||||
from: period.fromIso,
|
||||
@@ -167,10 +203,71 @@ function toBreakdown(
|
||||
}))
|
||||
}
|
||||
|
||||
interface UserBindTuple {
|
||||
userId: string
|
||||
serverId: number
|
||||
iface: string
|
||||
}
|
||||
|
||||
async function loadBindUserTuples(): Promise<UserBindTuple[]> {
|
||||
const binds = await db.select().from(userInterfaceBindings)
|
||||
const seen = new Set<string>()
|
||||
const out: UserBindTuple[] = []
|
||||
for (const b of binds) {
|
||||
for (const iface of bindingIfaceAliases(b.serverId, b.interfaceName)) {
|
||||
const k = `${b.userId}\0${b.serverId}\0${iface}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push({ userId: b.userId, serverId: b.serverId, iface })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function uniqueBoundIfaces(tuples: UserBindTuple[]): Array<{ serverId: number; iface: string }> {
|
||||
const seen = new Set<string>()
|
||||
const out: Array<{ serverId: number; iface: string }> = []
|
||||
for (const t of tuples) {
|
||||
const k = `${t.serverId}\0${t.iface}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push({ serverId: t.serverId, iface: t.iface })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function resolveUserIfaces(userId?: string): Promise<Array<{ serverId: number; iface: string }> | null> {
|
||||
if (!userId) return null
|
||||
if (!userId || userId === STATISTICS_UNBOUND_USER_ID) return null
|
||||
const binds = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
|
||||
return binds.map((b) => ({ serverId: b.serverId, iface: b.interfaceName }))
|
||||
return expandBindingIfaces(binds.map((b) => ({ serverId: b.serverId, iface: b.interfaceName })))
|
||||
}
|
||||
|
||||
function userBindJoinSql(tuples: UserBindTuple[]): { sql: string; params: unknown[] } {
|
||||
const values = tuples.map(() => "(?::text, ?::int, ?::text)").join(", ")
|
||||
const params = tuples.flatMap((t) => [t.userId, t.serverId, t.iface])
|
||||
return {
|
||||
sql: `JOIN (VALUES ${values}) AS b(user_id, server_id, iface) ON b.server_id = f.server_id AND b.iface = f.iface`,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFilterCtx(query: StatisticsQuery, period: ParsedPeriod): Promise<FilterCtx | null> {
|
||||
const bindTuples = await loadBindUserTuples()
|
||||
const boundIfaces = uniqueBoundIfaces(bindTuples)
|
||||
const unboundOnly = query.userId === STATISTICS_UNBOUND_USER_ID
|
||||
const userIfaces = unboundOnly ? null : await resolveUserIfaces(query.userId)
|
||||
if (userIfaces && userIfaces.length === 0) return null
|
||||
return {
|
||||
...period,
|
||||
serverId: query.serverId,
|
||||
iface: query.iface,
|
||||
country: query.country,
|
||||
service: query.service,
|
||||
asn: query.asn,
|
||||
userIfaces,
|
||||
unboundOnly,
|
||||
boundIfaces,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatistics(query: StatisticsQuery): Promise<StatisticsDto> {
|
||||
@@ -184,17 +281,9 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
windowSec: 1,
|
||||
})
|
||||
|
||||
const userIfaces = await resolveUserIfaces(query.userId)
|
||||
const ctx: FilterCtxFull = {
|
||||
...period,
|
||||
serverId: query.serverId,
|
||||
iface: query.iface,
|
||||
country: query.country,
|
||||
service: query.service,
|
||||
asn: query.asn,
|
||||
userIfaces,
|
||||
}
|
||||
if (userIfaces && userIfaces.length === 0) return emptyDto(period)
|
||||
const bindTuples = await loadBindUserTuples()
|
||||
const ctx = await buildFilterCtx(query, period)
|
||||
if (!ctx) return emptyDto(period)
|
||||
|
||||
const table = period.grain === "hour" ? "flow_hour_facts" : "flow_daily_facts"
|
||||
const timeCol = period.grain === "hour" ? "bucket_at" : "day"
|
||||
@@ -258,14 +347,17 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
GROUP BY f.server_id, f.iface
|
||||
`, where.params)
|
||||
|
||||
const userRows = await dbAll<{ id: string; bytes: number; packets: number }>(`
|
||||
SELECT b.user_id AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
|
||||
FROM ${table} f
|
||||
JOIN user_interface_bindings b
|
||||
ON b.server_id = f.server_id AND b.interface_name = f.iface
|
||||
WHERE ${where.sql}
|
||||
GROUP BY b.user_id
|
||||
`, where.params)
|
||||
let userRows: Array<{ id: string; bytes: number; packets: number }> = []
|
||||
if (bindTuples.length && !ctx.unboundOnly) {
|
||||
const join = userBindJoinSql(bindTuples)
|
||||
userRows = await dbAll<{ id: string; bytes: number; packets: number }>(`
|
||||
SELECT b.user_id AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
|
||||
FROM ${table} f
|
||||
${join.sql}
|
||||
WHERE ${where.sql}
|
||||
GROUP BY b.user_id
|
||||
`, [...join.params, ...where.params])
|
||||
}
|
||||
|
||||
const serverNames = new Map<number, string>()
|
||||
const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers)
|
||||
@@ -333,7 +425,7 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
bytes,
|
||||
period.windowSec,
|
||||
)
|
||||
const users = toBreakdown(
|
||||
const matchedUsers = toBreakdown(
|
||||
userRows.map((r) => ({
|
||||
id: r.id,
|
||||
label: userNames.get(r.id) || r.id,
|
||||
@@ -344,6 +436,40 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
period.windowSec,
|
||||
)
|
||||
|
||||
const users = [...matchedUsers]
|
||||
if (!ctx.unboundOnly && !ctx.userIfaces) {
|
||||
let unboundBytes = 0
|
||||
let unboundPackets = 0
|
||||
if (ctx.boundIfaces.length === 0) {
|
||||
unboundBytes = bytes
|
||||
unboundPackets = packets
|
||||
} else {
|
||||
const tuples = ctx.boundIfaces.map(() => "(?, ?)").join(", ")
|
||||
const unboundParams = [...where.params]
|
||||
for (const u of ctx.boundIfaces) unboundParams.push(u.serverId, u.iface)
|
||||
const unboundRows = await dbAll<{ bytes: number; packets: number }>(`
|
||||
SELECT COALESCE(SUM(f.bytes), 0) AS bytes, COALESCE(SUM(f.packets), 0) AS packets
|
||||
FROM ${table} f
|
||||
WHERE ${where.sql}
|
||||
AND (f.server_id, f.iface) NOT IN (${tuples})
|
||||
`, unboundParams)
|
||||
unboundBytes = Number(unboundRows[0]?.bytes) || 0
|
||||
unboundPackets = Number(unboundRows[0]?.packets) || 0
|
||||
}
|
||||
if (unboundBytes > 0) {
|
||||
const denom = bytes || 1
|
||||
users.push({
|
||||
id: STATISTICS_UNBOUND_USER_ID,
|
||||
label: "Без привязки",
|
||||
bytes: unboundBytes,
|
||||
packets: unboundPackets,
|
||||
bps: (unboundBytes * 8) / period.windowSec,
|
||||
percent: (unboundBytes / denom) * 100,
|
||||
})
|
||||
users.sort((a, b) => b.bytes - a.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: period.fromIso,
|
||||
to: period.toIso,
|
||||
@@ -352,7 +478,7 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
bytes,
|
||||
packets,
|
||||
avgBps: (bytes * 8) / period.windowSec,
|
||||
users: users.length,
|
||||
users: matchedUsers.length,
|
||||
servers: serverCount,
|
||||
ifaces: ifaceCount,
|
||||
topCountry: countries[0]?.label || "—",
|
||||
@@ -367,3 +493,205 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
|
||||
asns,
|
||||
}
|
||||
}
|
||||
|
||||
function dimSql(dim: StatisticsPivotDim, factAlias: string, bindAlias: string): string {
|
||||
if (dim === "country") return `${factAlias}.country`
|
||||
if (dim === "service") return `${factAlias}.service`
|
||||
if (dim === "asn") return `${factAlias}.asn::text`
|
||||
if (dim === "server") return `${factAlias}.server_id::text`
|
||||
if (dim === "iface") return `(${factAlias}.server_id::text || ':' || ${factAlias}.iface)`
|
||||
return `${bindAlias}.user_id`
|
||||
}
|
||||
|
||||
function emptyPivot(query: StatisticsPivotQuery): StatisticsPivotDto {
|
||||
return {
|
||||
rowDim: query.row,
|
||||
colDim: query.col,
|
||||
metric: query.metric,
|
||||
columns: [],
|
||||
rows: [],
|
||||
otherBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function pivotDimsConflict(row: StatisticsPivotDim, col: StatisticsPivotDim): boolean {
|
||||
return row === col
|
||||
}
|
||||
|
||||
export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<StatisticsPivotDto> {
|
||||
if (pivotDimsConflict(query.row, query.col)) return emptyPivot(query)
|
||||
const period = parseStatisticsPeriod(query.from, query.to)
|
||||
if (!period) return emptyPivot(query)
|
||||
const bindTuples = await loadBindUserTuples()
|
||||
const ctx = await buildFilterCtx(query, period)
|
||||
if (!ctx) return emptyPivot(query)
|
||||
const needsUser = query.row === "user" || query.col === "user"
|
||||
if (needsUser && bindTuples.length === 0) return emptyPivot(query)
|
||||
|
||||
const table = period.grain === "hour" ? "flow_hour_facts" : "flow_daily_facts"
|
||||
const where = factWhere("f", period.grain, ctx)
|
||||
const rowExpr = dimSql(query.row, "f", "b")
|
||||
const colExpr = dimSql(query.col, "f", "b")
|
||||
const join = needsUser ? userBindJoinSql(bindTuples) : { sql: "", params: [] as unknown[] }
|
||||
|
||||
const raw = await dbAll<{ row_id: string; col_id: string; bytes: number; packets: number }>(`
|
||||
SELECT ${rowExpr} AS row_id, ${colExpr} AS col_id,
|
||||
SUM(f.bytes) AS bytes, SUM(f.packets) AS packets
|
||||
FROM ${table} f
|
||||
${join.sql}
|
||||
WHERE ${where.sql}
|
||||
GROUP BY 1, 2
|
||||
`, [...join.params, ...where.params])
|
||||
|
||||
const metric = query.metric
|
||||
type Acc = { bytes: number; packets: number }
|
||||
const cell = new Map<string, Map<string, Acc>>()
|
||||
const colTotals = new Map<string, number>()
|
||||
for (const r of raw) {
|
||||
const rid = String(r.row_id ?? "")
|
||||
const cid = String(r.col_id ?? "")
|
||||
const acc: Acc = { bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0 }
|
||||
const val = metric === "packets" ? acc.packets : acc.bytes
|
||||
let rowMap = cell.get(rid)
|
||||
if (!rowMap) {
|
||||
rowMap = new Map()
|
||||
cell.set(rid, rowMap)
|
||||
}
|
||||
const prev = rowMap.get(cid)
|
||||
if (prev) {
|
||||
prev.bytes += acc.bytes
|
||||
prev.packets += acc.packets
|
||||
} else {
|
||||
rowMap.set(cid, acc)
|
||||
}
|
||||
colTotals.set(cid, (colTotals.get(cid) ?? 0) + val)
|
||||
}
|
||||
|
||||
const topCols = [...colTotals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, PIVOT_COL_CAP)
|
||||
.map(([id]) => id)
|
||||
const topColSet = new Set(topCols)
|
||||
const folded = new Map<string, Map<string, number>>()
|
||||
const foldedColTotals = new Map<string, number>()
|
||||
let otherBytes = 0
|
||||
for (const [rid, cols] of cell) {
|
||||
const rowMap = new Map<string, number>()
|
||||
for (const [cid, acc] of cols) {
|
||||
const val = metric === "packets" ? acc.packets : acc.bytes
|
||||
const dest = topColSet.has(cid) ? cid : PIVOT_OTHER_ID
|
||||
if (dest === PIVOT_OTHER_ID) otherBytes += val
|
||||
rowMap.set(dest, (rowMap.get(dest) ?? 0) + val)
|
||||
foldedColTotals.set(dest, (foldedColTotals.get(dest) ?? 0) + val)
|
||||
}
|
||||
folded.set(rid, rowMap)
|
||||
}
|
||||
|
||||
const rowTotals = new Map<string, number>()
|
||||
for (const [rid, cols] of folded) {
|
||||
let t = 0
|
||||
for (const v of cols.values()) t += v
|
||||
rowTotals.set(rid, t)
|
||||
}
|
||||
const topRows = [...rowTotals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, PIVOT_ROW_CAP)
|
||||
.map(([id]) => id)
|
||||
const topRowSet = new Set(topRows)
|
||||
const finalRows = new Map<string, Map<string, number>>()
|
||||
const finalRowTotals = new Map<string, number>()
|
||||
for (const [rid, cols] of folded) {
|
||||
const dest = topRowSet.has(rid) ? rid : PIVOT_OTHER_ID
|
||||
if (dest === PIVOT_OTHER_ID) {
|
||||
for (const [cid, v] of cols) {
|
||||
if (cid !== PIVOT_OTHER_ID) otherBytes += v
|
||||
}
|
||||
}
|
||||
let rowMap = finalRows.get(dest)
|
||||
if (!rowMap) {
|
||||
rowMap = new Map()
|
||||
finalRows.set(dest, rowMap)
|
||||
}
|
||||
for (const [cid, v] of cols) {
|
||||
rowMap.set(cid, (rowMap.get(cid) ?? 0) + v)
|
||||
}
|
||||
}
|
||||
for (const [rid, cols] of finalRows) {
|
||||
let t = 0
|
||||
for (const v of cols.values()) t += v
|
||||
finalRowTotals.set(rid, t)
|
||||
}
|
||||
|
||||
const colIds = [...topCols]
|
||||
if (foldedColTotals.has(PIVOT_OTHER_ID)) colIds.push(PIVOT_OTHER_ID)
|
||||
const rowIds = [...topRows]
|
||||
if (finalRows.has(PIVOT_OTHER_ID) && !topRowSet.has(PIVOT_OTHER_ID)) rowIds.push(PIVOT_OTHER_ID)
|
||||
|
||||
const labels = await loadPivotLabels(query.row, query.col, rowIds, colIds)
|
||||
|
||||
return {
|
||||
rowDim: query.row,
|
||||
colDim: query.col,
|
||||
metric,
|
||||
columns: colIds.map((id) => ({
|
||||
id,
|
||||
label: labels.col.get(id) ?? (id === PIVOT_OTHER_ID ? "Прочие" : id),
|
||||
total: foldedColTotals.get(id) ?? 0,
|
||||
})),
|
||||
rows: rowIds.map((id) => {
|
||||
const cols = finalRows.get(id) ?? new Map()
|
||||
const cells: Record<string, number> = {}
|
||||
for (const cid of colIds) cells[cid] = cols.get(cid) ?? 0
|
||||
return {
|
||||
id,
|
||||
label: labels.row.get(id) ?? (id === PIVOT_OTHER_ID ? "Прочие" : id),
|
||||
total: finalRowTotals.get(id) ?? 0,
|
||||
cells,
|
||||
}
|
||||
}),
|
||||
otherBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPivotLabels(
|
||||
rowDim: StatisticsPivotDim,
|
||||
colDim: StatisticsPivotDim,
|
||||
rowIds: string[],
|
||||
colIds: string[],
|
||||
): Promise<{ row: Map<string, string>; col: Map<string, string> }> {
|
||||
const serverNames = new Map<string, string>()
|
||||
const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers)
|
||||
for (const s of allServers) serverNames.set(String(s.id), s.name || s.host)
|
||||
const userNames = new Map<string, string>()
|
||||
const allUsers = await db.select({ id: appUsers.id, name: appUsers.name, login: appUsers.login }).from(appUsers)
|
||||
for (const u of allUsers) userNames.set(u.id, u.name || u.login)
|
||||
const asnHolders = new Map<string, string>()
|
||||
const asnMeta = await db.select({ asn: flowAsnMeta.asn, holder: flowAsnMeta.holder }).from(flowAsnMeta)
|
||||
for (const a of asnMeta) asnHolders.set(String(a.asn), a.holder)
|
||||
|
||||
function label(dim: StatisticsPivotDim, id: string): string {
|
||||
if (id === PIVOT_OTHER_ID) return "Прочие"
|
||||
if (dim === "country") return id === "XX" ? "Неизвестно" : id
|
||||
if (dim === "server") return serverNames.get(id) || id
|
||||
if (dim === "user") return userNames.get(id) || id
|
||||
if (dim === "asn") {
|
||||
if (id === "0") return "other"
|
||||
const holder = asnHolders.get(id)
|
||||
return holder ? `AS${id} · ${holder}` : `AS${id}`
|
||||
}
|
||||
if (dim === "iface") {
|
||||
const colon = id.indexOf(":")
|
||||
if (colon < 0) return id
|
||||
const sid = id.slice(0, colon)
|
||||
const iface = id.slice(colon + 1)
|
||||
return `${serverNames.get(sid) || sid} · ${iface}`
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
const row = new Map<string, string>()
|
||||
const col = new Map<string, string>()
|
||||
for (const id of rowIds) row.set(id, label(rowDim, id))
|
||||
for (const id of colIds) col.set(id, label(colDim, id))
|
||||
return { row, col }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { resolveFlowIp } from "./traffic-flow-geoip.js"
|
||||
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||
import { canonicalFactIface } from "./traffic-flow-ifindex.js"
|
||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||
import {
|
||||
bumpFlowFact,
|
||||
@@ -360,7 +361,7 @@ export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): vo
|
||||
bumpFlowFact({
|
||||
serverId,
|
||||
bucketAt: hourAt,
|
||||
iface: flow.inIface,
|
||||
iface: canonicalFactIface(serverId, flow.inIface),
|
||||
country: country || "XX",
|
||||
service: classified.service,
|
||||
asn: ripe?.ok && ripe.asn ? ripe.asn : 0,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bindingIfaceAliases,
|
||||
bindingIfaceAliasesAllServers,
|
||||
canonicalFactIface,
|
||||
expandBindingIfaces,
|
||||
rememberServerIfaces,
|
||||
resetIfaceCacheForTests,
|
||||
resolveIfaceName,
|
||||
} from "./traffic-flow-ifindex.js"
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
assert.equal(canonicalFactIface(1, "2"), "2")
|
||||
assert.deepEqual(bindingIfaceAliases(1, "gre-client"), ["gre-client"])
|
||||
|
||||
rememberServerIfaces(1, [{ name: "gre-client", ifindex: "2" }])
|
||||
assert.equal(canonicalFactIface(1, "2"), "gre-client")
|
||||
assert.equal(canonicalFactIface(1, "gre-client"), "gre-client")
|
||||
assert.equal(canonicalFactIface(1, "9"), "9")
|
||||
assert.equal(resolveIfaceName(1, "9").name, "#9")
|
||||
|
||||
const aliases = bindingIfaceAliases(1, "gre-client")
|
||||
assert.ok(aliases.includes("gre-client"))
|
||||
assert.ok(aliases.includes("2"))
|
||||
assert.ok(aliases.includes("#2"))
|
||||
|
||||
const all = bindingIfaceAliasesAllServers("gre-client")
|
||||
assert.ok(all.includes("2"))
|
||||
|
||||
const expanded = expandBindingIfaces([{ serverId: 1, iface: "gre-client" }])
|
||||
assert.ok(expanded.some((x) => x.iface === "2"))
|
||||
assert.ok(expanded.some((x) => x.iface === "gre-client"))
|
||||
|
||||
resetIfaceCacheForTests()
|
||||
console.log("traffic-flow-ifindex.test.ts: ok")
|
||||
@@ -44,6 +44,55 @@ export function resolveIfaceName(serverId: number, indexOrName: string): { name:
|
||||
return { name: `#${trimmed}`, index: trimmed }
|
||||
}
|
||||
|
||||
/** Имя iface для факта куба: ifIndex→имя, без `#13` при пустом кэше. */
|
||||
export function canonicalFactIface(serverId: number, inIface: string): string {
|
||||
const trimmed = String(inIface ?? "").trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (!/^\d+$/.test(trimmed)) return trimmed
|
||||
const name = cache.get(serverId)?.get(Number(trimmed))
|
||||
return name || trimmed
|
||||
}
|
||||
|
||||
/** Имя + ifIndex + `#n` — тот же матч, что карта `/traffic`. */
|
||||
export function bindingIfaceAliases(serverId: number, interfaceName: string): string[] {
|
||||
const name = String(interfaceName ?? "").trim()
|
||||
if (!name) return []
|
||||
const out = new Set<string>([name])
|
||||
const map = cache.get(serverId)
|
||||
if (!map) return [...out]
|
||||
for (const [idx, n] of map) {
|
||||
if (n !== name) continue
|
||||
out.add(String(idx))
|
||||
out.add(`#${idx}`)
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
export function bindingIfaceAliasesAllServers(interfaceName: string): string[] {
|
||||
const name = String(interfaceName ?? "").trim()
|
||||
const out = new Set<string>(name ? [name] : [])
|
||||
for (const serverId of cache.keys()) {
|
||||
for (const alias of bindingIfaceAliases(serverId, name)) out.add(alias)
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
export function expandBindingIfaces(
|
||||
binds: Array<{ serverId: number; iface: string }>,
|
||||
): Array<{ serverId: number; iface: string }> {
|
||||
const seen = new Set<string>()
|
||||
const out: Array<{ serverId: number; iface: string }> = []
|
||||
for (const b of binds) {
|
||||
for (const iface of bindingIfaceAliases(b.serverId, b.iface)) {
|
||||
const k = `${b.serverId}\0${iface}`
|
||||
if (seen.has(k)) continue
|
||||
seen.add(k)
|
||||
out.push({ serverId: b.serverId, iface })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function ifaceCacheHas(serverId: number): boolean {
|
||||
return cache.has(serverId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user