import { eq } from "drizzle-orm" import { db, dbAll } from "../db/index.js" import { appUsers, flowAsnMeta, servers, userInterfaceBindings } from "../db/schema.js" import { STATISTICS_UNBOUND_USER_ID, type StatisticsBreakdownRow, type StatisticsDto, type StatisticsPivotDim, type StatisticsPivotDto, type StatisticsPivotQuery, type StatisticsQuery, } from "@mmapp/contracts/statistics" import { collapseServerIfaceRows, displayFactIface, expandBindingIfaces, factIfaceAliases, listCachedIfaceNames, } from "./traffic-flow-ifindex.js" import { refreshServerIfaces } from "./traffic-flow-ifaces.js" import { isDashDisplayIface, isJunkFactIface, isOverlayTunnelIface, isWanFactIface, overlayDupLabel, 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 const PIVOT_ROW_CAP = 50 const PIVOT_COL_CAP = 15 const PIVOT_OTHER_ID = "__other__" export interface ParsedPeriod { fromIso: string toIso: string fromDay: string toDayExclusive: string grain: "hour" | "day" windowSec: number } function pad2(n: number): string { return String(n).padStart(2, "0") } function toUtcDay(d: Date): string { return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}` } function addUtcDays(day: string, n: number): string { const d = new Date(`${day}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + n) return toUtcDay(d) } /** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */ 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`) if (!Number.isFinite(from) || !Number.isFinite(to)) return null const fromDate = new Date(from) let toDate = new Date(to) let toDayExclusive: string if (toHasTime) { toDayExclusive = toUtcDay(toDate) if (toDate.getUTCHours() !== 0 || toDate.getUTCMinutes() !== 0 || toDate.getUTCSeconds() !== 0) { toDayExclusive = addUtcDays(toDayExclusive, 1) } } else { 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" return { fromIso: fromDate.toISOString(), toIso: toDate.toISOString(), fromDay: toUtcDay(fromDate), toDayExclusive, grain, windowSec, } } type FactScope = "unique" | "wan" | "overlay" interface FilterCtx { fromIso: string toIso: string fromDay: string toDayExclusive: string serverId?: number iface?: string country?: string service?: string asn?: number planes: "unique" | "all" userIfaces: Array<{ serverId: number; iface: string }> | null unboundOnly: boolean boundIfaces: Array<{ serverId: number; iface: string }> overlayIfaces: Array<{ serverId: number; iface: string }> wanIfaces: Array<{ serverId: number; iface: string }> excludeServerIds: number[] topo: FlowTopology | null } function ifaceFilterAliases(iface: string, serverId?: number): string[] { return factIfaceAliases(iface.trim(), serverId) } function looksLikeIfIndex(iface: string): boolean { const raw = iface.trim() return /^\d+$/.test(raw) || /^#\d+$/.test(raw) } async function warmIfaceCache(ids: Iterable): Promise { const uniq = [...new Set(ids)].filter((id) => Number.isFinite(id) && id > 0) if (!uniq.length) return await Promise.all(uniq.map((id) => refreshServerIfaces(id))) } async function warmBindingIfaceCache(): Promise { const rows = await db.select({ serverId: userInterfaceBindings.serverId }).from(userInterfaceBindings) await warmIfaceCache(rows.map((r) => r.serverId)) } function canonicalIfaceDimId(id: string): string { const colon = id.indexOf(":") if (colon < 0) return id const sid = Number(id.slice(0, colon)) if (!Number.isFinite(sid)) return id return `${sid}:${displayFactIface(sid, id.slice(colon + 1))}` } function pushIfaceTuples( parts: string[], params: unknown[], alias: string, tuples: Array<{ serverId: number; iface: string }>, op: "IN" | "NOT IN", ): void { if (!tuples.length) { if (op === "IN") parts.push("FALSE") return } const sql = tuples.map(() => "(?, ?)").join(", ") parts.push(`(${alias}.server_id, ${alias}.iface) ${op} (${sql})`) for (const t of tuples) { params.push(t.serverId, t.iface) } } function factWhere( alias: string, grain: "hour" | "day", ctx: FilterCtx, scope: FactScope = "unique", ): { sql: string; params: unknown[] } { const params: unknown[] = [] const parts: string[] = [] if (grain === "hour") { params.push(ctx.fromIso, ctx.toIso) parts.push(`${alias}.bucket_at >= ? AND ${alias}.bucket_at < ?`) } else { params.push(ctx.fromDay, ctx.toDayExclusive) parts.push(`${alias}.day >= ? AND ${alias}.day < ?`) } if (ctx.serverId != null) { parts.push(`${alias}.server_id = ?`) params.push(ctx.serverId) } if (ctx.country) { parts.push(`${alias}.country = ?`) params.push(ctx.country.toUpperCase()) } if (ctx.service) { parts.push(`${alias}.service = ?`) params.push(ctx.service) } if (ctx.asn != null) { parts.push(`${alias}.asn = ?`) params.push(ctx.asn) } parts.push(`${alias}.iface NOT IN ('0', '—', '__unknown__', 'wg-flow', '')`) if (scope === "wan") { pushIfaceTuples(parts, params, alias, ctx.wanIfaces, "IN") return { sql: parts.join(" AND "), params } } if (scope === "overlay") { pushIfaceTuples(parts, params, alias, ctx.overlayIfaces, "IN") return { sql: parts.join(" AND "), params } } if (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) } return { sql: parts.join(" AND "), params } } if (ctx.userIfaces) { pushIfaceTuples(parts, params, alias, ctx.userIfaces, "IN") return { sql: parts.join(" AND "), params } } if (ctx.unboundOnly) { parts.push("FALSE") return { sql: parts.join(" AND "), params } } pushIfaceTuples(parts, params, alias, ctx.boundIfaces, "IN") if (ctx.excludeServerIds.length) { parts.push(`${alias}.server_id NOT IN (${ctx.excludeServerIds.map(() => "?").join(", ")})`) params.push(...ctx.excludeServerIds) } return { sql: parts.join(" AND "), params } } function emptyDto(period: ParsedPeriod): StatisticsDto { return { from: period.fromIso, to: period.toIso, grain: period.grain, kpis: { bytes: 0, packets: 0, avgBps: 0, users: 0, servers: 0, ifaces: 0, topCountry: "—", topService: "—", }, series: [], users: [], servers: [], interfaces: [], countries: [], services: [], asns: [], } } function toBreakdown( rows: Array<{ id: string; label: string; bytes: number; packets: number }>, totalBytes: number, windowSec: number, ): StatisticsBreakdownRow[] { const denom = totalBytes || 1 return rows .sort((a, b) => b.bytes - a.bytes) .slice(0, TOP_N) .map((r) => ({ id: r.id, label: r.label, bytes: r.bytes, packets: r.packets, bps: (r.bytes * 8) / windowSec, percent: (r.bytes / denom) * 100, })) } interface UserBindTuple { userId: string serverId: number iface: string } async function loadBindUserTuples(): Promise { const binds = await db.select().from(userInterfaceBindings) const seen = new Set() const out: UserBindTuple[] = [] for (const b of binds) { for (const iface of factIfaceAliases(b.interfaceName, b.serverId)) { 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() 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 | null> { if (!userId || userId === STATISTICS_UNBOUND_USER_ID) return null const binds = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId)) 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, } } function expandIfaceTuples( items: Array<{ serverId: number; iface: string }>, ): Array<{ serverId: number; iface: string }> { const seen = new Set() const out: Array<{ serverId: number; iface: string }> = [] for (const t of items) { for (const iface of factIfaceAliases(t.iface, t.serverId)) { const k = `${t.serverId}\0${iface}` if (seen.has(k)) continue seen.add(k) out.push({ serverId: t.serverId, iface }) } } return out } async function loadPayloadScope(serverId?: number): Promise<{ overlayIfaces: Array<{ serverId: number; iface: string }> wanIfaces: Array<{ serverId: number; iface: string }> excludeServerIds: number[] topo: FlowTopology }> { const topo = await loadFlowTopology() const catalog = await getServerCatalog() await warmIfaceCache(catalog.list.map((s) => s.id)) const overlayRaw: Array<{ serverId: number; iface: string }> = [] const wanRaw: Array<{ serverId: number; iface: string }> = [] for (const s of catalog.list) { if (serverId != null && s.id !== serverId) continue const wanSet = topo.wanIfaces.get(s.id) const wanNames = wanSet && wanSet.size > 0 ? [...wanSet] : s.type === "home-router" ? [] : ["ether1"] for (const name of wanNames) wanRaw.push({ serverId: s.id, iface: name }) const names = new Set(listCachedIfaceNames(s.id)) for (const name of topo.tunnelIfaces?.get(s.id) ?? []) names.add(name) for (const name of names) { if (isOverlayTunnelIface(topo, s.id, name)) overlayRaw.push({ serverId: s.id, iface: name }) } } return { overlayIfaces: expandIfaceTuples(overlayRaw), wanIfaces: expandIfaceTuples(wanRaw), excludeServerIds: serverId != null ? [] : catalog.list.filter((s) => s.type === "exit-node").map((s) => s.id), topo, } } async function buildFilterCtx(query: StatisticsQuery, period: ParsedPeriod): Promise { 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 if (unboundOnly) return null const scope = await loadPayloadScope(query.serverId) return { ...period, serverId: query.serverId, iface: query.iface, country: query.country, service: query.service, asn: query.asn, planes: query.planes ?? "unique", userIfaces, unboundOnly, boundIfaces, overlayIfaces: scope.overlayIfaces, wanIfaces: scope.wanIfaces, excludeServerIds: scope.excludeServerIds, topo: scope.topo, } } export async function getStatistics(query: StatisticsQuery): Promise { const period = parseStatisticsPeriod(query.from, query.to) if (!period) return emptyDto({ fromIso: query.from, toIso: query.to, fromDay: query.from.slice(0, 10), toDayExclusive: query.to.slice(0, 10), grain: "day", windowSec: 1, }) await warmBindingIfaceCache() if (query.serverId) await warmIfaceCache([query.serverId]) 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" const where = factWhere("f", period.grain, ctx) const totals = await dbAll<{ bytes: number; packets: number; servers: number }>(` SELECT COALESCE(SUM(f.bytes), 0) AS bytes, COALESCE(SUM(f.packets), 0) AS packets, COUNT(DISTINCT f.server_id)::int AS servers FROM ${table} f WHERE ${where.sql} `, where.params) const bytes = Number(totals[0]?.bytes) || 0 const packets = Number(totals[0]?.packets) || 0 const serverCount = Number(totals[0]?.servers) || 0 const seriesRows = await dbAll<{ t: string; bytes: number }>(` SELECT ${timeCol}::text AS t, SUM(f.bytes) AS bytes FROM ${table} f WHERE ${where.sql} GROUP BY ${timeCol} ORDER BY ${timeCol} `, where.params) const countryRows = await dbAll<{ id: string; bytes: number; packets: number }>(` SELECT f.country AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${where.sql} GROUP BY f.country `, where.params) const serviceRows = await dbAll<{ id: string; bytes: number; packets: number }>(` SELECT f.service AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${where.sql} GROUP BY f.service `, where.params) const asnRows = await dbAll<{ id: number; bytes: number; packets: number }>(` SELECT f.asn AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${where.sql} GROUP BY f.asn `, where.params) const serverRows = await dbAll<{ id: number; bytes: number; packets: number }>(` SELECT f.server_id AS id, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${where.sql} GROUP BY f.server_id `, where.params) const ifaceRowsRaw = await dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(` SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${where.sql} GROUP BY f.server_id, f.iface `, where.params) await warmIfaceCache(ifaceRowsRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId)) const ifaceRows = collapseServerIfaceRows(ifaceRowsRaw).filter((r) => { if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) return false if (ctx.iface) return true if (ctx.topo && isOverlayTunnelIface(ctx.topo, r.serverId, r.iface)) return false if (ctx.topo && isWanFactIface(ctx.topo, r.serverId, r.iface)) return false return true }) const ifaceCount = ifaceRows.length let dupeIfaceRows: Array<{ serverId: number; iface: string; bytes: number; packets: number; kind: "wan" | "overlay" }> = [] if (ctx.planes === "all" && !ctx.iface) { const wanWhere = factWhere("f", period.grain, ctx, "wan") const overlayWhere = factWhere("f", period.grain, ctx, "overlay") const [wanRaw, overlayRaw] = await Promise.all([ dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(` SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${wanWhere.sql} GROUP BY f.server_id, f.iface `, wanWhere.params), dbAll<{ serverId: number; iface: string; bytes: number; packets: number }>(` SELECT f.server_id AS "serverId", f.iface AS iface, SUM(f.bytes) AS bytes, SUM(f.packets) AS packets FROM ${table} f WHERE ${overlayWhere.sql} GROUP BY f.server_id, f.iface `, overlayWhere.params), ]) await warmIfaceCache([ ...wanRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId), ...overlayRaw.filter((r) => looksLikeIfIndex(r.iface)).map((r) => r.serverId), ]) const seen = new Set(ifaceRows.map((r) => `${r.serverId}:${r.iface}`)) for (const r of collapseServerIfaceRows(wanRaw)) { if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) continue const key = `${r.serverId}:${r.iface}` if (seen.has(key)) continue seen.add(key) dupeIfaceRows.push({ ...r, kind: "wan" }) } for (const r of collapseServerIfaceRows(overlayRaw)) { if (isJunkFactIface(r.iface) || isDashDisplayIface(r.iface)) continue const key = `${r.serverId}:${r.iface}` if (seen.has(key)) continue seen.add(key) dupeIfaceRows.push({ ...r, kind: "overlay" }) } } 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() const allServers = await db.select({ id: servers.id, name: servers.name, host: servers.host }).from(servers) for (const s of allServers) serverNames.set(s.id, s.name || s.host) const userNames = new Map() 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() const asnMeta = await db.select({ asn: flowAsnMeta.asn, holder: flowAsnMeta.holder }).from(flowAsnMeta) for (const a of asnMeta) asnHolders.set(a.asn, a.holder) const countries = toBreakdown( countryRows.map((r) => ({ id: r.id, label: r.id === "XX" ? "Неизвестно" : r.id, bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0, })), bytes, period.windowSec, ) const services = toBreakdown( 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, ) const asns = toBreakdown( asnRows.map((r) => { const id = Number(r.id) || 0 const holder = asnHolders.get(id) return { id: String(id), label: id === 0 ? "other" : holder ? `AS${id} · ${holder}` : `AS${id}`, bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0, } }), bytes, period.windowSec, ) const serverBreakdown = toBreakdown( serverRows.map((r) => ({ id: String(r.id), label: serverNames.get(r.id) || String(r.id), bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0, })), bytes, period.windowSec, ) const uniqueInterfaces = toBreakdown( ifaceRows.map((r) => { const serverName = serverNames.get(r.serverId) || String(r.serverId) const wan = ctx.topo ? isWanFactIface(ctx.topo, r.serverId, r.iface) : false return { id: `${r.serverId}:${r.iface}`, label: wan ? wanIfaceLabel(serverName, r.iface) : `${serverName} · ${r.iface}`, bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0, } }), bytes, period.windowSec, ) const dupeInterfaces: StatisticsBreakdownRow[] = dupeIfaceRows.map((r) => { const serverName = serverNames.get(r.serverId) || String(r.serverId) const rowBytes = Number(r.bytes) || 0 const rowPackets = Number(r.packets) || 0 return { id: `${r.serverId}:${r.iface}`, label: r.kind === "wan" ? wanIfaceLabel(serverName, r.iface) : overlayDupLabel(serverName, r.iface), bytes: rowBytes, packets: rowPackets, bps: (rowBytes * 8) / period.windowSec, percent: 0, } }) const interfaces = [...uniqueInterfaces, ...dupeInterfaces] const matchedUsers = toBreakdown( userRows.map((r) => ({ id: r.id, label: userNames.get(r.id) || r.id, bytes: Number(r.bytes) || 0, packets: Number(r.packets) || 0, })), bytes, period.windowSec, ) const users = [...matchedUsers] return { from: period.fromIso, to: period.toIso, grain: period.grain, kpis: { bytes, packets, avgBps: (bytes * 8) / period.windowSec, users: matchedUsers.length, servers: serverCount, ifaces: ifaceCount, topCountry: countries[0]?.label || "—", topService: services[0]?.label || "—", }, series: seriesRows.map((r) => ({ t: r.t, bytes: Number(r.bytes) || 0 })), users, servers: serverBreakdown, interfaces, countries, services, 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 { if (pivotDimsConflict(query.row, query.col)) return emptyPivot(query) const period = parseStatisticsPeriod(query.from, query.to) if (!period) return emptyPivot(query) await warmBindingIfaceCache() if (query.serverId) await warmIfaceCache([query.serverId]) 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]) 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) { for (const dim of [query.row, query.col] as const) { if (dim !== "iface") continue const id = dim === query.row ? String(r.row_id ?? "") : String(r.col_id ?? "") const colon = id.indexOf(":") if (colon < 0) continue const sid = Number(id.slice(0, colon)) if (looksLikeIfIndex(id.slice(colon + 1)) && Number.isFinite(sid)) ifaceServerIds.push(sid) } } await warmIfaceCache(ifaceServerIds) for (const r of raw) { if (query.row === "iface") r.row_id = canonicalIfaceDimId(String(r.row_id ?? "")) if (query.col === "iface") r.col_id = canonicalIfaceDimId(String(r.col_id ?? "")) } } const metric = query.metric type Acc = { bytes: number; packets: number } const cell = new Map>() const colTotals = new Map() 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>() const foldedColTotals = new Map() let otherBytes = 0 for (const [rid, cols] of cell) { const rowMap = new Map() 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() 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>() const finalRowTotals = new Map() 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 = {} 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; col: Map }> { const topo = await loadFlowTopology() const serverNames = new Map() 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() 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() 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) const sidNum = Number(sid) const name = Number.isFinite(sidNum) ? displayFactIface(sidNum, iface) : iface const serverName = serverNames.get(sid) || sid if (Number.isFinite(sidNum) && isWanFactIface(topo, sidNum, name)) { return wanIfaceLabel(serverName, name) } if (Number.isFinite(sidNum) && isOverlayTunnelIface(topo, sidNum, name)) { return overlayDupLabel(serverName, name) } return `${serverName} · ${name}` } return id } const row = new Map() const col = new Map() 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 } }