fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 57s
Docker images / publish-release (push) Successful in 12s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 09:50:24 +07:00
co-authored by Cursor
parent e0ddb17539
commit cb799da13a
24 changed files with 1884 additions and 530 deletions
+71 -17
View File
@@ -19,11 +19,11 @@ import { useDataSource } from "@/lib/data-source"
import { useTrafficLive } from "@/hooks/use-traffic-live" import { useTrafficLive } from "@/hooks/use-traffic-live"
import { useFlowLive } from "@/hooks/use-flow-live" import { useFlowLive } from "@/hooks/use-flow-live"
import { requestJson } from "@/shared/api/http-client" import { requestJson } from "@/shared/api/http-client"
import { getFlowAnalytics, getFlowClients, getFlowExporters, getTrafficFlows } from "@/shared/api/traffic-flow" import { getFlowAnalytics, getFlowClients, getFlowExporters, getFlowMonthly, getTrafficFlows } from "@/shared/api/traffic-flow"
import { listServers } from "@/shared/api/servers" import { listServers } from "@/shared/api/servers"
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet" import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel" import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel"
import type { FlowAnalyticsDto, FlowEntityCard, FlowStatsDto } from "@mmapp/contracts/traffic-flow" import type { FlowAnalyticsDto, FlowEntityCard, FlowMonthlyDto, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers" import type { ServerRead } from "@mmapp/contracts/servers"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { import {
@@ -63,18 +63,52 @@ function flowIngestLine(stats: FlowStatsDto | null): string | null {
return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}` return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}`
} }
function flowEmptyHint(stats: FlowStatsDto | null): string | undefined { function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): string | undefined {
if (!stats) return undefined if (!stats) return undefined
if (stats.lastError) return stats.lastError if (stats.lastError) return stats.lastError
if (stats.packetsReceived) { if (stats.packetsReceived) {
return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но сессии ещё не записаны.` return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но сессии ещё не записаны.`
} }
if (stats.listenerBound === false) { if (stats.listenerBound === false && !collectorAlive) {
return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически." return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически."
} }
if (stats.listenerBound || collectorAlive) {
return "Коллектор жив, IPFIX ещё не доходит. На jump-host у target Src должен быть 0.0.0.0 (авто)."
}
return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow." return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow."
} }
function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto {
const emptySeries = Array(60).fill(0) as number[]
return {
bpsNow: 0,
bytes: m.bytes,
packets: 0,
conversations: 0,
conversationsRaw: 0,
uniqueSrc: 0,
uniqueDst: 0,
topProto: "—",
topCategory: "—",
rxSeries: emptySeries,
txSeries: emptySeries,
applications: [],
protocols: [],
sources: [],
destinations: [],
interfaces: [],
asns: m.asns,
countries: m.countries,
categories: [],
services: m.services,
mapEdges: [],
conversationsList: [],
ifaces: [],
live: false,
degraded: false,
}
}
// ─── data model ─────────────────────────────────────────────────────────────── // ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic { interface BoundIfaceTraffic {
@@ -298,9 +332,9 @@ const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */ /** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
type Range = (typeof TRAFFIC_RANGE_KEYS)[number] type Range = (typeof TRAFFIC_RANGE_KEYS)[number] | "30d"
const TRAFFIC_RANGE_LABELS: Record<Range, string> = { const TRAFFIC_RANGE_LABELS: Record<(typeof TRAFFIC_RANGE_KEYS)[number], string> = {
"5m": "5м", "5m": "5м",
"15m": "15м", "15m": "15м",
"1h": "1ч", "1h": "1ч",
@@ -781,7 +815,7 @@ export default function TrafficPage() {
serverId: selectedId, serverId: selectedId,
iface: selectedIface, iface: selectedIface,
}) })
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({ const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
enabled: flowLiveEnabled, enabled: flowLiveEnabled,
backendUrl, backendUrl,
@@ -835,7 +869,7 @@ export default function TrafficPage() {
setLiveBusy(true) setLiveBusy(true)
setLiveError(null) setLiveError(null)
try { try {
const q = encodeURIComponent(targetRange) const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
const [srvRes, usersRes, ifacesRes] = await Promise.all([ const [srvRes, usersRes, ifacesRes] = await Promise.all([
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`), apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`), apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
@@ -902,8 +936,6 @@ export default function TrafficPage() {
useEffect(() => { useEffect(() => {
if (!isLive || effectiveMode !== "flows") return if (!isLive || effectiveMode !== "flows") return
void loadFlows() void loadFlows()
const t = window.setInterval(() => { void loadFlows() }, 5000)
return () => window.clearInterval(t)
}, [isLive, effectiveMode, loadFlows]) }, [isLive, effectiveMode, loadFlows])
useEffect(() => { useEffect(() => {
@@ -911,6 +943,18 @@ export default function TrafficPage() {
setFlowAnalytics(null) setFlowAnalytics(null)
return return
} }
if (range === "5m") {
setFlowAnalytics(null)
return
}
if (range === "30d") {
const month = new Date().toISOString().slice(0, 7)
void getFlowMonthly(backendUrl, {
month,
serverId: flowScope === "servers" ? selectedId : undefined,
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
return
}
void getFlowAnalytics(backendUrl, { void getFlowAnalytics(backendUrl, {
range, range,
serverId: flowScope === "servers" ? selectedId : undefined, serverId: flowScope === "servers" ? selectedId : undefined,
@@ -969,12 +1013,19 @@ export default function TrafficPage() {
const handleModeChange = (next: GroupMode) => { const handleModeChange = (next: GroupMode) => {
setGroupMode(next) setGroupMode(next)
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1") if (next === "servers") {
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
else if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "") if (range === "30d") setRange("1h")
else if (next === "flows") { } else if (next === "users") {
setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
if (range === "30d") setRange("1h")
} else if (next === "ifaces") {
setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
if (range === "30d") setRange("1h")
} else if (next === "flows") {
setFlowScope("servers") setFlowScope("servers")
setFlowIface("__all__") setFlowIface("__all__")
setRange("5m")
setSelectedId(flowExporters[0]?.id ?? "") setSelectedId(flowExporters[0]?.id ?? "")
} }
setSortField("rx") setSortField("rx")
@@ -1064,7 +1115,10 @@ export default function TrafficPage() {
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode)) const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
const ingestLine = flowIngestLine(flowStats) const ingestLine = flowIngestLine(flowStats)
const flowError = liveError || flowLiveError const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
const flowError = liveError
|| (flowLiveError && !(collectorAlive && /live HTTP 500/.test(flowLiveError)) ? flowLiveError : null)
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
const flowKpiItems = [ const flowKpiItems = [
{ {
@@ -1258,7 +1312,7 @@ export default function TrafficPage() {
))} ))}
{sortedFlowCards.length === 0 ? ( {sortedFlowCards.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{flowEmptyHint(flowStats) ?? "Нет экспортёров IPFIX. Подключите jump-host."} {flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -1275,7 +1329,7 @@ export default function TrafficPage() {
dedup={flowDedup} dedup={flowDedup}
onDedup={setFlowDedup} onDedup={setFlowDedup}
liveHint={displayedFlow?.live ? "live" : undefined} liveHint={displayedFlow?.live ? "live" : undefined}
emptyHint={flowEmptyHint(flowStats)} emptyHint={flowEmptyHint(flowStats, collectorAlive)}
/> />
</FramePanel> </FramePanel>
</Frame> </Frame>
+1 -1
View File
@@ -14,7 +14,7 @@
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts", "test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts", "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-hardening.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts" "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts"
}, },
"dependencies": { "dependencies": {
+62 -6
View File
@@ -7,11 +7,23 @@ import { drizzle } from "drizzle-orm/better-sqlite3"
import { env } from "../config.js" import { env } from "../config.js"
import * as schema from "./schema.js" import * as schema from "./schema.js"
const sqlite = new Database(env.DATABASE_PATH) export const SQLITE_BUSY_TIMEOUT_MS = 5000
export function applySqlitePragmas(handle: SqliteHandle): void {
handle.pragma("journal_mode = WAL")
handle.pragma("foreign_keys = ON")
handle.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
handle.pragma("synchronous = NORMAL")
}
function openSqlite(): SqliteHandle {
const handle = new Database(env.DATABASE_PATH)
applySqlitePragmas(handle)
return handle
}
let sqlite = openSqlite()
// WAL mode for better concurrent read performance
sqlite.pragma("journal_mode = WAL")
sqlite.pragma("foreign_keys = ON")
sqlite.exec(` sqlite.exec(`
CREATE TABLE IF NOT EXISTS servers ( CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -162,6 +174,39 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_flow_buckets_unique
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time
ON flow_buckets(server_id, bucket_at); ON flow_buckets(server_id, bucket_at);
CREATE TABLE IF NOT EXISTS flow_minute_stats (
server_id INTEGER NOT NULL,
bucket_at TEXT NOT NULL,
bytes INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
unique_src INTEGER NOT NULL DEFAULT 0,
unique_dst INTEGER NOT NULL DEFAULT 0,
conversations INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at)
);
CREATE TABLE IF NOT EXISTS flow_minute_dims (
server_id INTEGER NOT NULL,
bucket_at TEXT NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, bucket_at, dim, key)
);
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
CREATE TABLE IF NOT EXISTS flow_daily_dims (
server_id INTEGER NOT NULL,
day TEXT NOT NULL,
dim TEXT NOT NULL,
key TEXT NOT NULL,
bytes INTEGER NOT NULL DEFAULT 0,
packets INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, day, dim, key)
);
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
CREATE TABLE IF NOT EXISTS flow_ip_meta ( CREATE TABLE IF NOT EXISTS flow_ip_meta (
prefix TEXT PRIMARY KEY, prefix TEXT PRIMARY KEY,
asn INTEGER NOT NULL DEFAULT 0, asn INTEGER NOT NULL DEFAULT 0,
@@ -902,7 +947,18 @@ if (backupEntryCount.c === 0) {
} }
} }
export const db = drizzle(sqlite, { schema }) export let db = drizzle(sqlite, { schema })
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */ /** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
export const sqliteDatabase: SqliteHandle = sqlite export let sqliteDatabase: SqliteHandle = sqlite
export function reopenSqlite(): void {
try {
sqlite.close()
} catch {
/* already closed */
}
sqlite = openSqlite()
sqliteDatabase = sqlite
db = drizzle(sqlite, { schema })
}
+34
View File
@@ -182,6 +182,40 @@ export const trafficFlowSettings = sqliteTable("traffic_flow_settings", {
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
}) })
export const flowMinuteStats = sqliteTable("flow_minute_stats", {
serverId: integer("server_id").notNull(),
bucketAt: text("bucket_at").notNull(),
bytes: integer("bytes").notNull().default(0),
packets: integer("packets").notNull().default(0),
uniqueSrc: integer("unique_src").notNull().default(0),
uniqueDst: integer("unique_dst").notNull().default(0),
conversations: integer("conversations").notNull().default(0),
}, (t) => [
uniqueIndex("idx_flow_minute_stats_pk").on(t.serverId, t.bucketAt),
])
export const flowMinuteDims = sqliteTable("flow_minute_dims", {
serverId: integer("server_id").notNull(),
bucketAt: text("bucket_at").notNull(),
dim: text("dim").notNull(),
key: text("key").notNull(),
bytes: integer("bytes").notNull().default(0),
packets: integer("packets").notNull().default(0),
}, (t) => [
uniqueIndex("idx_flow_minute_dims_pk").on(t.serverId, t.bucketAt, t.dim, t.key),
])
export const flowDailyDims = sqliteTable("flow_daily_dims", {
serverId: integer("server_id").notNull(),
day: text("day").notNull(),
dim: text("dim").notNull(),
key: text("key").notNull(),
bytes: integer("bytes").notNull().default(0),
packets: integer("packets").notNull().default(0),
}, (t) => [
uniqueIndex("idx_flow_daily_dims_pk").on(t.serverId, t.day, t.dim, t.key),
])
export const flowBuckets = sqliteTable("flow_buckets", { export const flowBuckets = sqliteTable("flow_buckets", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id") serverId: integer("server_id")
+44 -3
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from "fastify" import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
import cors from "@fastify/cors" import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod" import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { monitorEventLoopDelay } from "node:perf_hooks"
import { env } from "./config.js" import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js" import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js" import serversRoutes from "./routes/servers.js"
@@ -28,7 +29,10 @@ import wireguardRoutes from "./routes/wireguard.js"
import firewallRoutes from "./routes/firewall.js" import firewallRoutes from "./routes/firewall.js"
import usersRoutes from "./routes/users.js" import usersRoutes from "./routes/users.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js" import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js" import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
eventLoopDelay.enable()
export async function buildApp(opts?: { export async function buildApp(opts?: {
logger?: boolean logger?: boolean
@@ -37,7 +41,7 @@ export async function buildApp(opts?: {
const usePrettyLogger = const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production" opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({ const app = Fastify({
bodyLimit: 512 * 1024 * 1024, bodyLimit: 2 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000, requestTimeout: 10 * 60 * 1000,
logger: logger:
opts?.logger === false opts?.logger === false
@@ -59,6 +63,18 @@ export async function buildApp(opts?: {
app.setValidatorCompiler(validatorCompiler) app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler) app.setSerializerCompiler(serializerCompiler)
app.setErrorHandler((error: FastifyError, request, reply) => {
const status = typeof error.statusCode === "number" && error.statusCode >= 400
? error.statusCode
: 500
if (status >= 500) {
request.log.error(error)
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
}
const message = error instanceof Error ? error.message : "Ошибка запроса"
return reply.status(status).send({ error: message })
})
await app.register(cors, { await app.register(cors, {
origin: env.CORS_ORIGIN, origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
@@ -70,6 +86,8 @@ export async function buildApp(opts?: {
status: "ok", status: "ok",
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev", version: process.env.APP_VERSION ?? "dev",
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
flowWorker: getFlowWorkerHealth(),
})) }))
app.get("/api/auth/config", async () => ({ app.get("/api/auth/config", async () => ({
@@ -132,6 +150,29 @@ const isMain =
if (isMain) { if (isMain) {
try { try {
const app = await buildApp() const app = await buildApp()
let shuttingDown = false
const shutdown = async (code: number) => {
if (shuttingDown) return
shuttingDown = true
try {
stopTrafficFlowListener()
await app.close()
} catch (err) {
console.error(err)
} finally {
process.exit(code)
}
}
process.on("SIGTERM", () => { void shutdown(0) })
process.on("SIGINT", () => { void shutdown(0) })
process.on("uncaughtException", (err) => {
console.error(err)
void shutdown(1)
})
process.on("unhandledRejection", (reason) => {
console.error(reason)
void shutdown(1)
})
await app.listen({ port: env.PORT, host: "0.0.0.0" }) await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log( console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`, `\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
+40 -1
View File
@@ -18,13 +18,31 @@ import {
} from "../services/traffic-flow-ingest.js" } from "../services/traffic-flow-ingest.js"
import { import {
buildFlowAnalytics, buildFlowAnalytics,
getFlowMonthly,
listFlowClients, listFlowClients,
listFlowExporters, listFlowExporters,
safeBuildLiveFlowSample,
} from "../services/traffic-flow-analytics.js" } from "../services/traffic-flow-analytics.js"
import { applyFlowOverlay } from "../services/traffic-flow-overlay.js" import { applyFlowOverlay } from "../services/traffic-flow-overlay.js"
import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js" import { listTrafficFlowHostFiles } from "../services/traffic-flow-host-files.js"
const LIVE_TICK_MS = 2000 const LIVE_TICK_MS = 2000
export const MAX_FLOW_LIVE_SUBSCRIBERS = 4
let liveSubscribers = 0
export function tryAcquireFlowLiveSlot(): boolean {
if (liveSubscribers >= MAX_FLOW_LIVE_SUBSCRIBERS) return false
liveSubscribers += 1
return true
}
export function releaseFlowLiveSlot(): void {
liveSubscribers = Math.max(0, liveSubscribers - 1)
}
export function resetFlowLiveSlotsForTests(): void {
liveSubscribers = 0
}
function rangeToMinutes(range: string | undefined): number { function rangeToMinutes(range: string | undefined): number {
switch ((range ?? "5m").toLowerCase()) { switch ((range ?? "5m").toLowerCase()) {
@@ -33,6 +51,7 @@ function rangeToMinutes(range: string | undefined): number {
case "1h": return 60 case "1h": return 60
case "4h": return 240 case "4h": return 240
case "24h": return 1440 case "24h": return 1440
case "30d": return 1440
default: return 5 default: return 5
} }
} }
@@ -162,8 +181,26 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
return reply.send(buildFlowAnalytics(analyticsQuery(req))) return reply.send(buildFlowAnalytics(analyticsQuery(req)))
}) })
app.get("/traffic/flow/monthly", async (req, reply) => {
const q = req.query as { month?: string; serverId?: string }
const now = new Date()
const month = /^\d{4}-\d{2}$/.test(q.month ?? "")
? (q.month as string)
: `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`
return reply.send(getFlowMonthly(month, parseId(q.serverId)))
})
app.get("/traffic/flow/live", async (req, reply) => { app.get("/traffic/flow/live", async (req, reply) => {
if (!tryAcquireFlowLiveSlot()) {
return reply.status(429).send({ error: "Слишком много live-подписок" })
}
const query = analyticsQuery(req) const query = analyticsQuery(req)
const liveQuery = {
serverId: query.serverId,
userId: query.userId,
iface: query.iface,
dedup: query.dedup,
}
const abort = new AbortController() const abort = new AbortController()
const onClose = () => abort.abort() const onClose = () => abort.abort()
req.raw.on("close", onClose) req.raw.on("close", onClose)
@@ -190,12 +227,14 @@ const trafficFlowRoutes: FastifyPluginAsyncZod = async (app) => {
try { try {
while (!abort.signal.aborted) { while (!abort.signal.aborted) {
writeSse(reply.raw, "sample", buildFlowAnalytics(query)) const payload = safeBuildLiveFlowSample(liveQuery)
writeSse(reply.raw, payload.event, payload.data)
await sleep(LIVE_TICK_MS, abort.signal) await sleep(LIVE_TICK_MS, abort.signal)
} }
} catch { } catch {
/* abort / disconnect */ /* abort / disconnect */
} finally { } finally {
releaseFlowLiveSlot()
req.raw.off("close", onClose) req.raw.off("close", onClose)
try { try {
reply.raw.end() reply.raw.end()
+15 -5
View File
@@ -11,6 +11,16 @@ import type {
FirewallFamily, FirewallTable, FirewallFamily, FirewallTable,
} from "../types/server.js" } from "../types/server.js"
const MAX_ROS_BODY_BYTES = 8 * 1024 * 1024
function appendRosBody(body: string, chunk: string, req?: http.ClientRequest): string {
if (body.length + chunk.length > MAX_ROS_BODY_BYTES) {
req?.destroy(new Error("RouterOS: ответ больше 8 МиБ"))
return body
}
return body + chunk
}
// ── connection params ───────────────────────────────────────────────────────── // ── connection params ─────────────────────────────────────────────────────────
export interface MikrotikConnectParams { export interface MikrotikConnectParams {
@@ -52,7 +62,7 @@ function rosRequest(
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {
let body = "" let body = ""
res.setEncoding("utf8") res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk }) res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
res.on("end", () => { res.on("end", () => {
clearTimeout(timer) clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
@@ -135,7 +145,7 @@ function rosPost(
req = lib.request(options, (res) => { req = lib.request(options, (res) => {
let buf = "" let buf = ""
res.setEncoding("utf8") res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk }) res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
res.on("end", () => { res.on("end", () => {
settle(() => { settle(() => {
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
@@ -189,7 +199,7 @@ function rosPut(
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {
let buf = "" let buf = ""
res.setEncoding("utf8") res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk }) res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
res.on("end", () => { res.on("end", () => {
clearTimeout(timer) clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
@@ -239,7 +249,7 @@ function rosDelete(
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {
let body = "" let body = ""
res.setEncoding("utf8") res.setEncoding("utf8")
res.on("data", (chunk: string) => { body += chunk }) res.on("data", (chunk: string) => { body = appendRosBody(body, chunk, req) })
res.on("end", () => { res.on("end", () => {
clearTimeout(timer) clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
@@ -289,7 +299,7 @@ function rosPatch(
const req = lib.request(options, (res) => { const req = lib.request(options, (res) => {
let buf = "" let buf = ""
res.setEncoding("utf8") res.setEncoding("utf8")
res.on("data", (chunk: string) => { buf += chunk }) res.on("data", (chunk: string) => { buf = appendRosBody(buf, chunk, req) })
res.on("end", () => { res.on("end", () => {
clearTimeout(timer) clearTimeout(timer)
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
+10 -1
View File
@@ -4,8 +4,13 @@ import os from "node:os"
import path from "node:path" import path from "node:path"
import Database from "better-sqlite3" import Database from "better-sqlite3"
import { env } from "../config.js" import { env } from "../config.js"
import { sqliteDatabase } from "../db/index.js" import { reopenSqlite, sqliteDatabase } from "../db/index.js"
import { refreshScheduler, stopScheduler } from "./scheduler.js" import { refreshScheduler, stopScheduler } from "./scheduler.js"
import {
reattachFlowSqlite,
startTrafficFlowListener,
stopTrafficFlowListener,
} from "./traffic-flow-ingest.js"
const SQLITE_MAGIC = Buffer.from("SQLite format 3\0") const SQLITE_MAGIC = Buffer.from("SQLite format 3\0")
const MAX_RESTORE_BYTES = 512 * 1024 * 1024 const MAX_RESTORE_BYTES = 512 * 1024 * 1024
@@ -37,10 +42,12 @@ async function withDatabaseOperation<T>(fn: () => Promise<T> | T): Promise<T> {
throw new Error("Операция с базой данных уже выполняется") throw new Error("Операция с базой данных уже выполняется")
} }
operationInFlight = true operationInFlight = true
stopTrafficFlowListener()
stopScheduler() stopScheduler()
try { try {
return await fn() return await fn()
} finally { } finally {
startTrafficFlowListener()
refreshScheduler() refreshScheduler()
operationInFlight = false operationInFlight = false
} }
@@ -80,6 +87,8 @@ export async function restoreSystemDatabaseBackup(buffer: Buffer): Promise<void>
await writeFile(tempPath, buffer) await writeFile(tempPath, buffer)
source = new Database(tempPath, { readonly: true, fileMustExist: true }) source = new Database(tempPath, { readonly: true, fileMustExist: true })
await source.backup(resolveDatabasePath()) await source.backup(resolveDatabasePath())
reopenSqlite()
reattachFlowSqlite()
sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)") sqliteDatabase.pragma("wal_checkpoint(TRUNCATE)")
} finally { } finally {
source?.close() source?.close()
@@ -4,7 +4,8 @@ import {
ingestParsedFlowsForServerForTests, ingestParsedFlowsForServerForTests,
resetFlowRingsForTests, resetFlowRingsForTests,
} from "./traffic-flow-ingest.js" } from "./traffic-flow-ingest.js"
import { buildFlowAnalytics } from "./traffic-flow-analytics.js" import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js"
import { sqliteDatabase } from "../db/index.js"
import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js" import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js"
import { import {
disableRipeEnqueueForTests, disableRipeEnqueueForTests,
@@ -211,4 +212,55 @@ try {
resetFlowCatalogForTests() resetFlowCatalogForTests()
} }
{
ingestParsedFlowsForServerForTests(7, [
{
src: "10.1.1.8",
dst: "8.8.8.8",
proto: 6,
srcPort: 51234,
dstPort: 443,
bytes: 12_000,
packets: 10,
inIface: "2",
outIface: "",
},
])
const degraded = buildFlowAnalytics({ minutes: 5, serverId: 7, skipHeavy: true })
assert.equal(degraded.degraded, true)
assert.equal(degraded.conversationsList.length, 0)
assert.ok((degraded.bytes ?? 0) >= 12_000)
const liveErr = formatLiveSseFromBuilder(() => {
throw new Error("SQLITE_BUSY")
})
assert.equal(liveErr.event, "error")
assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY")
const liveOk = formatLiveSseFromBuilder(() => ({ ok: true }))
assert.equal(liveOk.event, "sample")
const exporters = listFlowExporters(5)
const clients = listFlowClients(5)
assert.ok(Array.isArray(exporters.exporters))
assert.ok(Array.isArray(clients.clients))
resetFlowRingsForTests()
}
{
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
sqliteDatabase.exec(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES
(7, '2026-09-01', 'country', 'US', 1000, 10),
(7, '2026-09-02', 'country', 'US', 500, 5),
(7, '2026-09-01', 'service', 'steam', 800, 8),
(7, '2026-09-01', 'asn', '15169', 900, 9),
(7, '2026-09-01', 'asn', 'other', 100, 1)
`)
const monthly = getFlowMonthly("2026-09", 7)
assert.equal(monthly.bytes, 1500)
assert.equal(monthly.countries[0]?.id, "US")
assert.equal(monthly.countries[0]?.bytes, 1500)
assert.ok(monthly.asns.some((row) => row.id === "other"))
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day LIKE '2026-09-%'`).run()
}
console.log("traffic-flow-analytics.test.ts: ok") console.log("traffic-flow-analytics.test.ts: ok")
+218 -86
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { db } from "../db/index.js" import { db, sqliteDatabase } from "../db/index.js"
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js" import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
import type { import type {
FlowAnalyticsDto, FlowAnalyticsDto,
@@ -8,15 +8,19 @@ import type {
FlowEntityCard, FlowEntityCard,
FlowExportersDto, FlowExportersDto,
FlowMapEdge, FlowMapEdge,
FlowMonthlyDto,
FlowTalkerDto, FlowTalkerDto,
} from "@mmapp/contracts/traffic-flow" } from "@mmapp/contracts/traffic-flow"
import { protoName } from "./traffic-flow-parse.js" import { protoName } from "./traffic-flow-parse.js"
import { import {
getFlowListenerState, getFlowListenerState,
getFlowRuntimeCounters,
getFlowWorkerHealth,
getRingMbps, getRingMbps,
listFlowRowsForWindow, listFlowRowsForWindow,
type PendingFlowRow, type PendingFlowRow,
} from "./traffic-flow-ingest.js" } from "./traffic-flow-ingest.js"
import { MAX_PENDING } from "./traffic-flow-engine.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js" import { resolveIfaceName } from "./traffic-flow-ifaces.js"
import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js" import { getTrafficFlowSettingsRow, listHostPeers } from "./traffic-flow-settings.js"
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js" import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
@@ -25,6 +29,9 @@ import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js" import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { isIsoCountry } from "./traffic-flow-brands.js" import { isIsoCountry } from "./traffic-flow-brands.js"
export const LIVE_ANALYTICS_MINUTES = 5
const LIVE_DEGRADED_PENDING = Math.floor(MAX_PENDING * 0.8)
export interface FlowAnalyticsQuery { export interface FlowAnalyticsQuery {
minutes: number minutes: number
serverId?: number serverId?: number
@@ -32,6 +39,7 @@ export interface FlowAnalyticsQuery {
iface?: string iface?: string
/** Default true: один 5-tuple = max байт по ifaces. */ /** Default true: один 5-tuple = max байт по ifaces. */
dedup?: boolean dedup?: boolean
skipHeavy?: boolean
} }
function bpsToMbps(bps: number): number { function bpsToMbps(bps: number): number {
@@ -147,6 +155,7 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
const srcs = new Set<string>() const srcs = new Set<string>()
const dsts = new Set<string>() const dsts = new Set<string>()
const matched: PendingFlowRow[] = [] const matched: PendingFlowRow[] = []
const skipHeavy = Boolean(q.skipHeavy)
for (const r of raw) { for (const r of raw) {
const resolved = resolveIfaceName(r.serverId, r.inIface) const resolved = resolveIfaceName(r.serverId, r.inIface)
@@ -190,60 +199,62 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
bump(countries, dstCountry, r.bytes, r.packets) bump(countries, dstCountry, r.bytes, r.packets)
} }
const ckey = wantDedup if (!skipHeavy) {
? flowTupleKey(r) const ckey = wantDedup
: `${flowTupleKey(r)}|${r.inIface}` ? flowTupleKey(r)
const prev = conv.get(ckey) : `${flowTupleKey(r)}|${r.inIface}`
if (prev) { const prev = conv.get(ckey)
prev.rawBytes += r.bytes if (prev) {
prev.bytes += r.bytes prev.rawBytes += r.bytes
prev.packets += r.packets prev.bytes += r.bytes
} else { prev.packets += r.packets
conv.set(ckey, { } else {
serverId: String(r.serverId), conv.set(ckey, {
serverName: nameById.get(r.serverId) ?? String(r.serverId), serverId: String(r.serverId),
src: r.src, serverName: nameById.get(r.serverId) ?? String(r.serverId),
dst: r.dst, src: r.src,
proto: r.proto, dst: r.dst,
protoName: protoName(r.proto), proto: r.proto,
srcPort: r.srcPort, protoName: protoName(r.proto),
dstPort: r.dstPort, srcPort: r.srcPort,
bytes: r.bytes, dstPort: r.dstPort,
packets: r.packets, bytes: r.bytes,
bps: 0, packets: r.packets,
inIface: resolved.name,
inIfaceIndex: resolved.index,
application: app,
category: classified.category,
service: classified.service,
dstCountry: dstCountry || undefined,
dstAsn: ripe?.asn || undefined,
rawBytes: r.bytes,
})
}
const toCountry = dstCountry
if (toCountry) {
const fromCountry = countryById.get(r.serverId) || "UN"
const ekey = `${r.serverId}|${toCountry}`
let edge = edgeAcc.get(ekey)
if (!edge) {
edge = {
fromId: String(r.serverId),
fromLabel: nameById.get(r.serverId) ?? String(r.serverId),
fromCountry,
toCountry,
toAsn: ripe?.asn ?? 0,
category: classified.category,
bytes: 0,
bps: 0, bps: 0,
catBytes: new Map(), inIface: resolved.name,
inIfaceIndex: resolved.index,
application: app,
category: classified.category,
service: classified.service,
dstCountry: dstCountry || undefined,
dstAsn: ripe?.asn || undefined,
rawBytes: r.bytes,
})
}
const toCountry = dstCountry
if (toCountry) {
const fromCountry = countryById.get(r.serverId) || "UN"
const ekey = `${r.serverId}|${toCountry}`
let edge = edgeAcc.get(ekey)
if (!edge) {
edge = {
fromId: String(r.serverId),
fromLabel: nameById.get(r.serverId) ?? String(r.serverId),
fromCountry,
toCountry,
toAsn: ripe?.asn ?? 0,
category: classified.category,
bytes: 0,
bps: 0,
catBytes: new Map(),
}
edgeAcc.set(ekey, edge)
} }
edgeAcc.set(ekey, edge) edge.bytes += r.bytes
if (ripe?.asn) edge.toAsn = ripe.asn
edge.catBytes.set(classified.category, (edge.catBytes.get(classified.category) ?? 0) + r.bytes)
} }
edge.bytes += r.bytes
if (ripe?.asn) edge.toAsn = ripe.asn
edge.catBytes.set(classified.category, (edge.catBytes.get(classified.category) ?? 0) + r.bytes)
} }
} }
@@ -334,49 +345,56 @@ export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
ifaces: ifaceRows, ifaces: ifaceRows,
live: listener.bound, live: listener.bound,
dedupApplied: wantDedup, dedupApplied: wantDedup,
degraded: skipHeavy,
} }
} }
function cardFromServer( function summarizeByServer(rows: PendingFlowRow[]) {
s: typeof servers.$inferSelect, const bytes = new Map<number, number>()
minutes: number, const sessions = new Map<number, number>()
): FlowEntityCard { for (const r of rows) {
const analytics = buildFlowAnalytics({ minutes, serverId: s.id }) bytes.set(r.serverId, (bytes.get(r.serverId) ?? 0) + r.bytes)
const ring = getRingMbps(s.id, "__all__") sessions.set(r.serverId, (sessions.get(r.serverId) ?? 0) + 1)
return {
id: String(s.id),
name: s.name || s.host,
subtitle: s.host,
site: s.site || "—",
country: s.country || "UN",
status: snapshotStatus(s.id),
rxNow: ring.rxNow || bpsToMbps(analytics.bpsNow),
txNow: ring.txNow,
sessions: analytics.conversations,
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : analytics.rxSeries,
txSeries: ring.tx,
bytes: analytics.bytes,
} }
return { bytes, sessions }
} }
export function listFlowExporters(minutes: number): FlowExportersDto { export function listFlowExporters(minutes: number): FlowExportersDto {
const settings = getTrafficFlowSettingsRow() const runtime = getFlowRuntimeCounters()
const rows = listFlowRowsForWindow(minutes) const rows = listFlowRowsForWindow(minutes)
const ids = new Set<number>() const { bytes, sessions } = summarizeByServer(rows)
for (const r of rows) ids.add(r.serverId) const ids = new Set<number>([...bytes.keys()])
for (const p of listHostPeers()) ids.add(p.serverId) for (const p of listHostPeers()) ids.add(p.serverId)
const serverRows = db.select().from(servers).all() const serverRows = db.select().from(servers).all()
const emptySeries = Array(60).fill(0) as number[]
const exporters = serverRows const exporters = serverRows
.filter((s) => ids.has(s.id)) .filter((s) => ids.has(s.id))
.map((s) => cardFromServer(s, minutes)) .map((s) => {
const ring = getRingMbps(s.id, "__all__")
const total = bytes.get(s.id) ?? 0
return {
id: String(s.id),
name: s.name || s.host,
subtitle: s.host,
site: s.site || "—",
country: s.country || "UN",
status: snapshotStatus(s.id),
rxNow: ring.rxNow || (total * 8) / Math.max(60, minutes * 60) / 1_000_000,
txNow: ring.txNow,
sessions: sessions.get(s.id) ?? 0,
rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : emptySeries,
txSeries: ring.tx,
bytes: total,
} satisfies FlowEntityCard
})
.sort((a, b) => b.rxNow - a.rxNow) .sort((a, b) => b.rxNow - a.rxNow)
const listener = getFlowListenerState() const listener = getFlowListenerState()
return { return {
exporters, exporters,
lastExporterIp: settings.lastExporterIp ?? null, lastExporterIp: runtime.lastExporterIp,
lastError: settings.lastError || null, lastError: runtime.lastError,
packetsReceived: settings.packetsReceived, packetsReceived: runtime.packetsReceived,
lastDatagramAt: settings.lastDatagramAt ?? null, lastDatagramAt: runtime.lastDatagramAt,
listenerBound: listener.bound, listenerBound: listener.bound,
listenerAddress: listener.address, listenerAddress: listener.address,
} }
@@ -391,13 +409,31 @@ export function listFlowClients(minutes: number): FlowClientsDto {
list.push(b) list.push(b)
byUser.set(b.userId, list) byUser.set(b.userId, list)
} }
const rows = listFlowRowsForWindow(minutes)
const emptySeries = Array(60).fill(0) as number[]
const windowSec = Math.max(60, minutes * 60)
const clients: FlowEntityCard[] = [] const clients: FlowEntityCard[] = []
for (const u of users) { for (const u of users) {
const userBinds = byUser.get(u.id) ?? [] const userBinds = byUser.get(u.id) ?? []
if (userBinds.length === 0) continue if (userBinds.length === 0) continue
const analytics = buildFlowAnalytics({ minutes, userId: u.id }) const allow = new Map<number, Set<string>>()
for (const b of userBinds) {
const set = allow.get(b.serverId) ?? new Set<string>()
set.add(b.interfaceName)
allow.set(b.serverId, set)
}
let total = 0
let sessions = 0
for (const r of rows) {
const resolved = resolveIfaceName(r.serverId, r.inIface)
const names = allow.get(r.serverId)
if (!names) continue
if (!names.has(resolved.name) && !names.has(r.inIface)) continue
total += r.bytes
sessions += 1
}
const firstServer = userBinds[0]?.serverId const firstServer = userBinds[0]?.serverId
const ring = firstServer ? getRingMbps(firstServer, "__all__") : { rx: Array(60).fill(0) as number[], tx: Array(60).fill(0) as number[], rxNow: 0, txNow: 0 } const ring = firstServer ? getRingMbps(firstServer, "__all__") : { rx: emptySeries, tx: emptySeries, rxNow: 0, txNow: 0 }
clients.push({ clients.push({
id: u.id, id: u.id,
name: u.login, name: u.login,
@@ -405,14 +441,110 @@ export function listFlowClients(minutes: number): FlowClientsDto {
site: `${userBinds.length} ifaces`, site: `${userBinds.length} ifaces`,
country: "UN", country: "UN",
status: u.active ? "online" : "offline", status: u.active ? "online" : "offline",
rxNow: bpsToMbps(analytics.bpsNow) || ring.rxNow, rxNow: (total * 8) / windowSec / 1_000_000 || ring.rxNow,
txNow: ring.txNow, txNow: ring.txNow,
sessions: analytics.conversations, sessions,
rxSeries: analytics.rxSeries, rxSeries: ring.rx.some((v) => v > 0) ? ring.rx : emptySeries,
txSeries: analytics.txSeries, txSeries: ring.tx,
bytes: analytics.bytes, bytes: total,
}) })
} }
clients.sort((a, b) => b.rxNow - a.rxNow) clients.sort((a, b) => b.rxNow - a.rxNow)
return { clients } return { clients }
} }
export function formatLiveSseFromBuilder(build: () => unknown): { event: "sample" | "error"; data: unknown } {
try {
return { event: "sample", data: build() }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { event: "error", data: { error: message } }
}
}
export function isFlowAnalyticsDegraded(): boolean {
const health = getFlowWorkerHealth()
return health.pendingSize >= LIVE_DEGRADED_PENDING
}
export function safeBuildLiveFlowSample(q: Omit<FlowAnalyticsQuery, "minutes" | "skipHeavy">): {
event: "sample" | "error"
data: unknown
} {
return formatLiveSseFromBuilder(() => {
const skipHeavy = isFlowAnalyticsDegraded()
return buildFlowAnalytics({ ...q, minutes: LIVE_ANALYTICS_MINUTES, skipHeavy })
})
}
function monthBounds(month: string): { start: string; end: string } | null {
if (!/^\d{4}-\d{2}$/.test(month)) return null
const [yearRaw, monthRaw] = month.split("-")
const year = Number(yearRaw)
const monthIdx = Number(monthRaw)
if (!Number.isFinite(year) || monthIdx < 1 || monthIdx > 12) return null
const start = `${month}-01`
const endDate = new Date(Date.UTC(year, monthIdx, 1))
const end = endDate.toISOString().slice(0, 10)
return { start, end }
}
function toBreakdown(
rows: Array<{ key: string; bytes: number; packets: number }>,
totalBytes: number,
windowSec: number,
): FlowBreakdownRow[] {
const denom = totalBytes || 1
return rows
.sort((a, b) => b.bytes - a.bytes)
.map((r) => ({
id: r.key,
label: r.key,
bytes: r.bytes,
packets: r.packets,
bps: (r.bytes * 8) / windowSec,
percent: (r.bytes / denom) * 100,
}))
}
export function getFlowMonthly(month: string, serverId?: number): FlowMonthlyDto {
const bounds = monthBounds(month)
if (!bounds) {
return { month, bytes: 0, countries: [], services: [], asns: [] }
}
const params: Array<string | number> = [bounds.start, bounds.end]
let where = "day >= ? AND day < ? AND dim IN ('country', 'service', 'asn')"
if (serverId != null) {
where += " AND server_id = ?"
params.push(serverId)
}
const rows = sqliteDatabase.prepare(`
SELECT dim AS dim, key AS key, SUM(bytes) AS bytes, SUM(packets) AS packets
FROM flow_daily_dims
WHERE ${where}
GROUP BY dim, key
`).all(...params) as Array<{ dim: string; key: string; bytes: number; packets: number }>
const countries: Array<{ key: string; bytes: number; packets: number }> = []
const services: Array<{ key: string; bytes: number; packets: number }> = []
const asns: Array<{ key: string; bytes: number; packets: number }> = []
let bytes = 0
for (const row of rows) {
const rec = { key: row.key, bytes: Number(row.bytes) || 0, packets: Number(row.packets) || 0 }
if (row.dim === "country") {
countries.push(rec)
bytes += rec.bytes
} else if (row.dim === "service") services.push(rec)
else if (row.dim === "asn") asns.push(rec)
}
const daysInMonth = Math.max(1, Math.round((Date.parse(`${bounds.end}T00:00:00Z`) - Date.parse(`${bounds.start}T00:00:00Z`)) / 86_400_000))
const windowSec = daysInMonth * 86_400
const countryTotal = countries.reduce((a, r) => a + r.bytes, 0) || bytes || 1
return {
month,
bytes,
countries: toBreakdown(countries, countryTotal, windowSec),
services: toBreakdown(services, services.reduce((a, r) => a + r.bytes, 0) || 1, windowSec),
asns: toBreakdown(asns, asns.reduce((a, r) => a + r.bytes, 0) || 1, windowSec),
}
}
@@ -0,0 +1,41 @@
import type { OverlayPeerRef } from "./traffic-flow-map-exporter.js"
export interface ExporterMapPayload {
overlayPrefix: string
byTunnelIp: Array<[string, number]>
peers: OverlayPeerRef[]
hostIps: Array<[string, number]>
}
export interface CollectorStartPayload {
dbPath: string
listenHost: string
listenPort: number
topN: number
retentionHours: number
exporterMap: ExporterMapPayload
}
export interface CollectorHeartbeat {
bound: boolean
address: string | null
packetsReceived: number
lastExporterIp: string | null
lastError: string
lastDatagramAt: string | null
pendingSize: number
dropped: number
rowsStored: number
workerAlive: boolean
rings: Array<{ key: string; inBps: number[]; outBps: number[] }>
}
export type MainToWorker =
| { type: "start"; payload: CollectorStartPayload }
| { type: "stop" }
| { type: "updateExporterMap"; payload: ExporterMapPayload }
| { type: "updateSettings"; payload: { topN: number; retentionHours: number } }
export type WorkerToMain =
| { type: "heartbeat"; payload: CollectorHeartbeat }
| { type: "error"; payload: { message: string } }
@@ -0,0 +1,141 @@
import { createSocket, type Socket } from "node:dgram"
import { parentPort } from "node:worker_threads"
import { sqliteDatabase } from "../db/index.js"
import type {
CollectorStartPayload,
ExporterMapPayload,
MainToWorker,
WorkerToMain,
} from "./traffic-flow-collector-ipc.js"
import {
TICK_MS,
attachEngineSqlite,
configureEngine,
flushPending,
getEngineStats,
ingestDatagram,
setEngineError,
setExporterResolveCtx,
snapshotRings,
} from "./traffic-flow-engine.js"
let socket: Socket | null = null
let flushTimer: ReturnType<typeof setInterval> | null = null
let bound = false
let address: string | null = null
let attached = false
function send(msg: WorkerToMain): void {
parentPort?.postMessage(msg)
}
function heartbeat(): void {
const stats = getEngineStats()
send({
type: "heartbeat",
payload: {
bound,
address,
packetsReceived: stats.packetsReceived,
lastExporterIp: stats.lastExporterIp,
lastError: stats.lastError,
lastDatagramAt: stats.lastDatagramAt,
pendingSize: stats.pendingSize,
dropped: stats.dropped,
rowsStored: stats.rowsStored,
workerAlive: true,
rings: snapshotRings(),
},
})
}
function applyExporterMap(payload: ExporterMapPayload): void {
setExporterResolveCtx({
overlayPrefix: payload.overlayPrefix,
byTunnelIp: new Map(payload.byTunnelIp),
peers: payload.peers,
hostIps: new Map(payload.hostIps),
})
}
function ensureSqlite(): void {
if (attached) return
attachEngineSqlite(sqliteDatabase)
attached = true
}
function stopListener(): void {
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = null
}
try {
flushPending()
} catch (e) {
setEngineError(e instanceof Error ? e.message : String(e))
}
if (socket) {
try { socket.close() } catch { /* ignore */ }
socket = null
}
bound = false
address = null
}
function startListener(payload: CollectorStartPayload): void {
stopListener()
ensureSqlite()
configureEngine({ topN: payload.topN, retentionHours: payload.retentionHours })
applyExporterMap(payload.exporterMap)
const sock = createSocket("udp4")
sock.on("error", (err) => {
setEngineError(err.message)
bound = false
address = null
send({ type: "error", payload: { message: err.message } })
heartbeat()
})
sock.on("message", (msg, rinfo) => {
try {
ingestDatagram(msg, rinfo.address)
} catch (e) {
setEngineError(e instanceof Error ? e.message : String(e))
}
})
try {
sock.setRecvBufferSize(8 * 1024 * 1024)
} catch {
/* platform may ignore */
}
sock.bind(payload.listenPort, payload.listenHost, () => {
bound = true
address = `${payload.listenHost}:${payload.listenPort}`
setEngineError("")
heartbeat()
})
socket = sock
flushTimer = setInterval(() => {
try {
flushPending()
} catch (e) {
setEngineError(e instanceof Error ? e.message : String(e))
}
heartbeat()
}, TICK_MS)
}
parentPort?.on("message", (msg: MainToWorker) => {
try {
if (msg.type === "start") startListener(msg.payload)
else if (msg.type === "stop") {
stopListener()
heartbeat()
} else if (msg.type === "updateExporterMap") applyExporterMap(msg.payload)
else if (msg.type === "updateSettings") configureEngine(msg.payload)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
setEngineError(message)
send({ type: "error", payload: { message } })
}
})
+706
View File
@@ -0,0 +1,706 @@
import type Database from "better-sqlite3"
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js"
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
import { applicationName } from "./traffic-flow-apps.js"
import { classifyFlowDst } from "./traffic-flow-classify.js"
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
import { isIsoCountry } from "./traffic-flow-brands.js"
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
type SqliteHandle = InstanceType<typeof Database>
export const TICK_MS = 2_000
export const RING_LEN = 60
export const MAX_PENDING = 50_000
export const DAILY_ASN_TOP = 500
export const DAILY_RETENTION_DAYS = 396
export const MINUTE_RETENTION_HOURS = 48
let pendingCap = MAX_PENDING
export interface PendingFlowRow {
serverId: number
bucketAt: string
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
}
export interface EngineStats {
packetsReceived: number
lastExporterIp: string | null
lastError: string
lastDatagramAt: string | null
dropped: number
rowsStored: number
pendingSize: number
}
interface PendingEntry {
serverId: number
bucketAt: string
flow: ParsedFlow
bytes: number
packets: number
}
interface MinuteRollup {
bytes: number
packets: number
srcs: Set<string>
dsts: Set<string>
conversations: number
}
interface DimAcc {
bytes: number
packets: number
}
export interface ExporterResolveCtx {
overlayPrefix: string
byTunnelIp: Map<string, number>
peers: OverlayPeerRef[]
hostIps: Map<string, number>
}
let sqliteRef: SqliteHandle | null = null
let topN = 200
let retentionHours = 24
const pending = new Map<string, PendingEntry>()
const recent = new Map<string, PendingFlowRow>()
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
const minuteRollup = new Map<string, MinuteRollup>()
const minuteDims = new Map<string, DimAcc>()
let packetsReceived = 0
let lastExporterIp: string | null = null
let lastError = ""
let lastDatagramAt: string | null = null
let dropped = 0
let rowsStored = 0
let lastFlushUsedTransaction = false
let lastPruneAt = 0
let exporterCtx: ExporterResolveCtx | null = null
const PRUNE_MS = 5 * 60_000
const LIVE_WINDOW_MS = 15 * 60_000
function nowIso(): string {
return new Date().toISOString()
}
export function minuteBucketIso(at = Date.now()): string {
const d = new Date(at)
d.setSeconds(0, 0)
return d.toISOString()
}
function dayKey(bucketAt: string): string {
return bucketAt.slice(0, 10)
}
function ringKey(serverId: number, iface: string): string {
return `${serverId}\0${iface || "__all__"}`
}
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string {
return `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
}
function rowKey(row: PendingFlowRow): string {
return `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
}
function rollupKey(serverId: number, bucketAt: string): string {
return `${serverId}\0${bucketAt}`
}
function dimKey(serverId: number, bucketAt: string, dim: string, key: string): string {
return `${serverId}\0${bucketAt}\0${dim}\0${key}`
}
function bumpTick(key: string, inBytes: number, outBytes: number): void {
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
prev.inBytes += inBytes
prev.outBytes += outBytes
tickAccum.set(key, prev)
}
function addToTick(serverId: number, inIface: string, outIface: string, bytes: number): void {
bumpTick(ringKey(serverId, "__all__"), bytes, 0)
if (inIface) bumpTick(ringKey(serverId, inIface), bytes, 0)
if (outIface && outIface !== inIface) bumpTick(ringKey(serverId, outIface), 0, bytes)
}
function emptyRing(): { inBps: number[]; outBps: number[] } {
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
}
function bumpDim(serverId: number, bucketAt: string, dim: string, key: string, bytes: number, packets: number): void {
if (!key) return
const k = dimKey(serverId, bucketAt, dim, key)
const prev = minuteDims.get(k)
if (prev) {
prev.bytes += bytes
prev.packets += packets
return
}
minuteDims.set(k, { bytes, packets })
}
function bumpRollup(serverId: number, bucketAt: string, flow: ParsedFlow, bytes: number, packets: number): void {
const k = rollupKey(serverId, bucketAt)
let acc = minuteRollup.get(k)
if (!acc) {
acc = { bytes: 0, packets: 0, srcs: new Set(), dsts: new Set(), conversations: 0 }
minuteRollup.set(k, acc)
}
acc.bytes += bytes
acc.packets += packets
if (flow.src) acc.srcs.add(flow.src)
if (flow.dst) acc.dsts.add(flow.dst)
acc.conversations += 1
}
export function attachEngineSqlite(handle: SqliteHandle): void {
sqliteRef = handle
}
export function setPendingCapForTests(n: number | null): void {
pendingCap = n == null ? MAX_PENDING : Math.max(1, n)
}
export function configureEngine(opts: { topN?: number; retentionHours?: number }): void {
if (opts.topN != null) topN = Math.max(20, opts.topN)
if (opts.retentionHours != null) retentionHours = Math.max(1, opts.retentionHours)
}
export function setExporterResolveCtx(ctx: ExporterResolveCtx | null): void {
exporterCtx = ctx
}
export function resolveServerId(exporterIp: string): number | null {
if (!exporterCtx) return null
return pickServerIdForExporter({
exporterIp,
overlayPrefix: exporterCtx.overlayPrefix,
byTunnelIp: exporterCtx.byTunnelIp,
peers: exporterCtx.peers,
hostIps: exporterCtx.hostIps,
})
}
export function bumpPacketMeta(exporterIp: string): void {
packetsReceived += 1
lastExporterIp = exporterIp
lastDatagramAt = nowIso()
}
export function setEngineError(message: string): void {
lastError = message
}
export function getEngineStats(): EngineStats {
return {
packetsReceived,
lastExporterIp,
lastError,
lastDatagramAt,
dropped,
rowsStored,
pendingSize: pending.size,
}
}
export function queueParsedFlows(serverId: number, flows: ParsedFlow[]): void {
const bucketAt = minuteBucketIso()
const ripeMisses: string[] = []
for (const flow of flows) {
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
bumpRollup(serverId, bucketAt, flow, flow.bytes, flow.packets)
const ripe = lookupRipeCached(flow.dst)
if (flow.dst && !ripe) ripeMisses.push(flow.dst)
const classified = classifyFlowDst(flow.dst, flow.proto, flow.dstPort, flow.srcPort, ripe)
const app = applicationName(flow.proto, flow.dstPort, flow.srcPort)
const country = ripe?.ok && isIsoCountry(ripe.country)
? ripe.country
: (ripe?.ok ? "" : "unknown")
const asnKey = ripe?.ok && ripe.asn ? String(ripe.asn) : "unknown"
bumpDim(serverId, bucketAt, "proto", protoName(flow.proto), flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "app", app, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "iface", flow.inIface || "__unknown__", flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "category", classified.category, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "service", classified.service, flow.bytes, flow.packets)
if (country) bumpDim(serverId, bucketAt, "country", country, flow.bytes, flow.packets)
bumpDim(serverId, bucketAt, "asn", asnKey, flow.bytes, flow.packets)
const key = pendingKey(serverId, bucketAt, flow)
const prev = pending.get(key)
if (prev) {
prev.bytes += flow.bytes
prev.packets += flow.packets
continue
}
if (pending.size >= pendingCap) {
dropped += 1
continue
}
pending.set(key, {
serverId,
bucketAt,
flow: { ...flow },
bytes: flow.bytes,
packets: flow.packets,
})
}
if (ripeMisses.length) enqueueRipeMisses(ripeMisses)
}
export function ingestDatagram(msg: Buffer, exporterIp: string): boolean {
bumpPacketMeta(exporterIp)
const flows = parseFlowPacket(msg, exporterIp)
if (!flows.length) return true
const serverId = resolveServerId(exporterIp)
if (serverId == null) {
setEngineError(
`IPFIX от ${exporterIp}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
)
return false
}
setEngineError("")
maybeRefreshIfaces(serverId)
queueParsedFlows(serverId, flows)
return true
}
function toPendingRow(row: PendingEntry): PendingFlowRow {
return {
serverId: row.serverId,
bucketAt: row.bucketAt,
src: row.flow.src || "0.0.0.0",
dst: row.flow.dst || "0.0.0.0",
proto: row.flow.proto,
srcPort: row.flow.srcPort,
dstPort: row.flow.dstPort,
bytes: row.bytes,
packets: row.packets,
inIface: row.flow.inIface,
outIface: row.flow.outIface,
}
}
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
const key = rowKey(row)
const prev = map.get(key)
if (prev) {
prev.bytes += row.bytes
prev.packets += row.packets
return
}
map.set(key, { ...row })
}
function pruneRecent(sinceMs = Date.now() - LIVE_WINDOW_MS): void {
const cutoff = new Date(sinceMs).toISOString()
for (const [key, row] of recent) {
if (row.bucketAt < cutoff) recent.delete(key)
}
while (recent.size > MAX_PENDING) {
const first = recent.keys().next().value
if (first == null) break
recent.delete(first)
}
}
export function peekPendingFlows(): PendingFlowRow[] {
return [...pending.values()].map(toPendingRow)
}
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
const merged = new Map<string, PendingFlowRow>()
for (const row of recent.values()) {
if (row.bucketAt < sinceIso) continue
mergeInto(merged, row)
}
for (const row of peekPendingFlows()) {
if (row.bucketAt < sinceIso) continue
mergeInto(merged, row)
}
return [...merged.values()]
}
export function rollFlowRings(): void {
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
const sec = TICK_MS / 1000
for (const key of keys) {
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
tickAccum.delete(key)
const inBps = (acc.inBytes * 8) / sec
const outBps = (acc.outBytes * 8) / sec
let ring = rings.get(key)
if (!ring) {
ring = emptyRing()
rings.set(key, ring)
}
ring.inBps.push(inBps)
ring.inBps.shift()
ring.outBps.push(outBps)
ring.outBps.shift()
const silent = ring.inBps.every((v) => v === 0) && ring.outBps.every((v) => v === 0)
if (silent && !tickAccum.has(key)) rings.delete(key)
}
}
export function getRingMbps(serverId: number, iface = "__all__"): {
rx: number[]
tx: number[]
rxNow: number
txNow: number
} {
const ring = rings.get(ringKey(serverId, iface))
const scale = 1_000_000
if (!ring) {
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
}
return {
rx: ring.inBps.map((b) => b / scale),
tx: ring.outBps.map((b) => b / scale),
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
}
}
export function snapshotRings(): Array<{ key: string; inBps: number[]; outBps: number[] }> {
return [...rings.entries()].map(([key, ring]) => ({
key,
inBps: [...ring.inBps],
outBps: [...ring.outBps],
}))
}
export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; outBps: number[] }>): void {
rings.clear()
for (const row of rows) {
rings.set(row.key, { inBps: row.inBps, outBps: row.outBps })
}
}
function persistListenerStats(handle: SqliteHandle): void {
handle.prepare(`
UPDATE traffic_flow_settings
SET packets_received = @packetsReceived,
last_datagram_at = @lastDatagramAt,
last_exporter_ip = @lastExporterIp,
last_error = @lastError,
updated_at = @updatedAt
WHERE id = 1
`).run({
packetsReceived,
lastDatagramAt,
lastExporterIp,
lastError,
updatedAt: nowIso(),
})
}
function upsertMinuteAndDaily(handle: SqliteHandle): void {
const upsertMinute = handle.prepare(`
INSERT INTO flow_minute_stats (
server_id, bucket_at, bytes, packets, unique_src, unique_dst, conversations
) VALUES (
@serverId, @bucketAt, @bytes, @packets, @uniqueSrc, @uniqueDst, @conversations
)
ON CONFLICT(server_id, bucket_at) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets,
unique_src = MAX(unique_src, excluded.unique_src),
unique_dst = MAX(unique_dst, excluded.unique_dst),
conversations = conversations + excluded.conversations
`)
const upsertDim = handle.prepare(`
INSERT INTO flow_minute_dims (server_id, bucket_at, dim, key, bytes, packets)
VALUES (@serverId, @bucketAt, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, bucket_at, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
const upsertDaily = handle.prepare(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (@serverId, @day, @dim, @key, @bytes, @packets)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
const tx = handle.transaction(() => {
for (const [k, acc] of minuteRollup) {
const [serverIdRaw, bucketAt] = k.split("\0")
upsertMinute.run({
serverId: Number(serverIdRaw),
bucketAt,
bytes: acc.bytes,
packets: acc.packets,
uniqueSrc: acc.srcs.size,
uniqueDst: acc.dsts.size,
conversations: acc.conversations,
})
}
for (const [k, acc] of minuteDims) {
const [serverIdRaw, bucketAt, dim, key] = k.split("\0")
upsertDim.run({
serverId: Number(serverIdRaw),
bucketAt,
dim,
key,
bytes: acc.bytes,
packets: acc.packets,
})
if (dim === "country" || dim === "service" || dim === "asn") {
upsertDaily.run({
serverId: Number(serverIdRaw),
day: dayKey(bucketAt ?? ""),
dim,
key,
bytes: acc.bytes,
packets: acc.packets,
})
}
}
})
tx()
minuteRollup.clear()
minuteDims.clear()
}
function capDailyAsn(handle: SqliteHandle): void {
const today = nowIso().slice(0, 10)
const rows = handle.prepare(`
SELECT server_id AS serverId, key, bytes, packets
FROM flow_daily_dims
WHERE day = ? AND dim = 'asn'
ORDER BY server_id, bytes DESC
`).all(today) as Array<{ serverId: number; key: string; bytes: number; packets: number }>
const byServer = new Map<number, typeof rows>()
for (const row of rows) {
const list = byServer.get(row.serverId) ?? []
list.push(row)
byServer.set(row.serverId, list)
}
const del = handle.prepare(`
DELETE FROM flow_daily_dims WHERE server_id = ? AND day = ? AND dim = 'asn' AND key = ?
`)
const upsertOther = handle.prepare(`
INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets)
VALUES (?, ?, 'asn', 'other', ?, ?)
ON CONFLICT(server_id, day, dim, key) DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
for (const [serverId, list] of byServer) {
if (list.length <= DAILY_ASN_TOP) continue
let otherBytes = 0
let otherPackets = 0
for (const row of list.slice(DAILY_ASN_TOP)) {
if (row.key === "other") continue
otherBytes += row.bytes
otherPackets += row.packets
del.run(serverId, today, row.key)
}
if (otherBytes > 0) upsertOther.run(serverId, today, otherBytes, otherPackets)
}
}
function pruneStored(handle: SqliteHandle): void {
const now = Date.now()
if (now - lastPruneAt < PRUNE_MS) return
lastPruneAt = now
const flowCutoff = new Date(now - retentionHours * 3600_000).toISOString()
const minuteCutoff = new Date(now - MINUTE_RETENTION_HOURS * 3600_000).toISOString()
const dailyCutoff = new Date(now - DAILY_RETENTION_DAYS * 86400_000).toISOString().slice(0, 10)
handle.prepare(`DELETE FROM flow_buckets WHERE bucket_at < ?`).run(flowCutoff)
handle.prepare(`DELETE FROM flow_minute_stats WHERE bucket_at < ?`).run(minuteCutoff)
handle.prepare(`DELETE FROM flow_minute_dims WHERE bucket_at < ?`).run(minuteCutoff)
handle.prepare(`DELETE FROM flow_daily_dims WHERE day < ?`).run(dailyCutoff)
const keep = Math.max(20, topN)
try {
handle.prepare(`
DELETE FROM flow_buckets WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY server_id, bucket_at ORDER BY bytes DESC
) AS rn
FROM flow_buckets
) ranked WHERE rn > ?
)
`).run(keep)
} catch {
const buckets = handle.prepare(`
SELECT DISTINCT server_id AS serverId, bucket_at AS bucketAt FROM flow_buckets
`).all() as Array<{ serverId: number; bucketAt: string }>
for (const b of buckets) {
const rows = handle.prepare(`
SELECT id, bytes FROM flow_buckets
WHERE server_id = ? AND bucket_at = ?
ORDER BY bytes DESC
`).all(b.serverId, b.bucketAt) as Array<{ id: number; bytes: number }>
for (const extra of rows.slice(keep)) {
handle.prepare(`DELETE FROM flow_buckets WHERE id = ?`).run(extra.id)
}
}
}
}
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
const keep = Math.max(20, topN)
const groups = new Map<string, PendingFlowRow[]>()
for (const row of rows) {
const k = `${row.serverId}\0${row.bucketAt}`
const list = groups.get(k) ?? []
list.push(row)
groups.set(k, list)
}
const out: PendingFlowRow[] = []
for (const list of groups.values()) {
list.sort((a, b) => b.bytes - a.bytes)
out.push(...list.slice(0, keep))
}
return out
}
export function flushPending(): void {
pruneRecent()
rollFlowRings()
const handle = sqliteRef
if (!handle) {
lastFlushUsedTransaction = false
return
}
persistListenerStats(handle)
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
pruneStored(handle)
lastFlushUsedTransaction = false
return
}
const rows = topNPending([...pending.values()].map(toPendingRow))
pending.clear()
for (const row of rows) mergeInto(recent, row)
const upsertFlow = handle.prepare(`
INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
) VALUES (
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface
)
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
lastFlushUsedTransaction = false
try {
const tx = handle.transaction((batch: PendingFlowRow[]) => {
for (const r of batch) {
upsertFlow.run({
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
})
}
})
tx(rows)
lastFlushUsedTransaction = true
rowsStored += rows.length
} catch {
for (const r of rows) {
try {
upsertFlow.run({
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
})
rowsStored += 1
} catch {
/* ignore single-row failures */
}
}
}
try {
upsertMinuteAndDaily(handle)
capDailyAsn(handle)
} catch {
/* rollup best-effort */
}
pruneStored(handle)
try {
handle.pragma("wal_checkpoint(TRUNCATE)")
} catch {
/* ignore */
}
}
export function lastFlushUsedTransactionForTests(): boolean {
return lastFlushUsedTransaction
}
export function flushPendingForTests(): void {
flushPending()
}
export function onEngineTick(): void {
flushPending()
}
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]): void {
queueParsedFlows(serverId, flows)
rollFlowRings()
}
export function resetEngineForTests(): void {
pending.clear()
recent.clear()
tickAccum.clear()
rings.clear()
minuteRollup.clear()
minuteDims.clear()
packetsReceived = 0
lastExporterIp = null
lastError = ""
lastDatagramAt = null
dropped = 0
rowsStored = 0
lastFlushUsedTransaction = false
lastPruneAt = 0
pendingCap = MAX_PENDING
}
export function pendingSizeForTests(): number {
return pending.size
}
export function droppedForTests(): number {
return dropped
}
@@ -0,0 +1,23 @@
import assert from "node:assert/strict"
import { SQLITE_BUSY_TIMEOUT_MS, sqliteDatabase } from "../db/index.js"
import {
MAX_FLOW_LIVE_SUBSCRIBERS,
resetFlowLiveSlotsForTests,
tryAcquireFlowLiveSlot,
releaseFlowLiveSlot,
} from "../routes/traffic-flow.js"
const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: number }>
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
resetFlowLiveSlotsForTests()
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
assert.equal(tryAcquireFlowLiveSlot(), true)
}
assert.equal(tryAcquireFlowLiveSlot(), false)
releaseFlowLiveSlot()
assert.equal(tryAcquireFlowLiveSlot(), true)
resetFlowLiveSlotsForTests()
console.log("traffic-flow-hardening.test.ts: ok")
+16 -1
View File
@@ -21,8 +21,9 @@ export {
} from "./traffic-flow-ifindex.js" } from "./traffic-flow-ifindex.js"
const inflight = new Set<number>() const inflight = new Set<number>()
let refreshIfacesImpl: (serverId: number, force?: boolean) => Promise<void> = refreshServerIfacesInner
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> { async function refreshServerIfacesInner(serverId: number, force = false): Promise<void> {
if (inflight.has(serverId)) return if (inflight.has(serverId)) return
if (!force && !shouldRefreshIfaces(serverId)) return if (!force && !shouldRefreshIfaces(serverId)) return
inflight.add(serverId) inflight.add(serverId)
@@ -39,3 +40,17 @@ export async function refreshServerIfaces(serverId: number, force = false): Prom
inflight.delete(serverId) inflight.delete(serverId)
} }
} }
export async function refreshServerIfaces(serverId: number, force = false): Promise<void> {
return refreshIfacesImpl(serverId, force)
}
export function maybeRefreshIfaces(serverId: number): boolean {
if (!shouldRefreshIfaces(serverId)) return false
void refreshIfacesImpl(serverId)
return true
}
export function setRefreshIfacesForTests(fn: typeof refreshServerIfacesInner | null): void {
refreshIfacesImpl = fn ?? refreshServerIfacesInner
}
@@ -6,11 +6,23 @@ import {
shouldRefreshIfaces, shouldRefreshIfaces,
} from "./traffic-flow-ifindex.js" } from "./traffic-flow-ifindex.js"
import { import {
applyHeartbeatForTests,
flushPendingForTests,
getFlowListenerState,
getFlowRuntimeCounters,
getFlowWorkerHealth,
ingestParsedFlowsForServerForTests,
lastFlushUsedTransactionForTests, lastFlushUsedTransactionForTests,
maybeRefreshIfaces, maybeRefreshIfaces,
peekPendingFlows,
resetFlowRingsForTests, resetFlowRingsForTests,
setPendingCapForTests,
setRefreshIfacesForTests, setRefreshIfacesForTests,
setWantListenForTests,
simulateWorkerExitForTests,
} from "./traffic-flow-ingest.js" } from "./traffic-flow-ingest.js"
import { configureEngine, droppedForTests, pendingSizeForTests } from "./traffic-flow-engine.js"
import { sqliteDatabase } from "../db/index.js"
resetIfaceCacheForTests() resetIfaceCacheForTests()
resetFlowRingsForTests() resetFlowRingsForTests()
@@ -37,6 +49,70 @@ assert.equal(refreshCalls, 1)
assert.equal(lastFlushUsedTransactionForTests(), false) assert.equal(lastFlushUsedTransactionForTests(), false)
resetFlowRingsForTests()
setPendingCapForTests(3)
const many = Array.from({ length: 6 }, (_, i) => ({
src: `10.1.1.${i + 1}`,
dst: "8.8.8.8",
proto: 6,
srcPort: 50000 + i,
dstPort: 443,
bytes: 1000,
packets: 1,
inIface: "2",
outIface: "",
}))
ingestParsedFlowsForServerForTests(9, many)
assert.equal(pendingSizeForTests(), 3)
assert.equal(droppedForTests(), 3)
assert.equal(peekPendingFlows().length, 3)
setPendingCapForTests(null)
resetFlowRingsForTests()
configureEngine({ topN: 20 })
const talkers = Array.from({ length: 25 }, (_, i) => ({
src: `10.2.1.${i + 1}`,
dst: "1.1.1.1",
proto: 6,
srcPort: 40000 + i,
dstPort: 443,
bytes: 1000 + i,
packets: 1,
inIface: "2",
outIface: "",
}))
ingestParsedFlowsForServerForTests(9, talkers)
flushPendingForTests()
const stored = sqliteDatabase.prepare(`
SELECT COUNT(*) AS n FROM flow_buckets WHERE server_id = 9
`).get() as { n: number }
assert.ok(stored.n <= 20, `expected topN cap, got ${stored.n}`)
sqliteDatabase.prepare(`DELETE FROM flow_buckets WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_stats WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_minute_dims WHERE server_id = 9`).run()
sqliteDatabase.prepare(`DELETE FROM flow_daily_dims WHERE server_id = 9`).run()
applyHeartbeatForTests({
bound: true,
address: "127.0.0.1:4739",
packetsReceived: 42,
lastExporterIp: "10.255.254.3",
lastError: "",
lastDatagramAt: new Date().toISOString(),
pendingSize: 1,
dropped: 0,
rowsStored: 1,
workerAlive: true,
rings: [],
})
assert.equal(getFlowListenerState().bound, true)
assert.equal(getFlowRuntimeCounters().packetsReceived, 42)
assert.equal(getFlowWorkerHealth().alive, false)
setWantListenForTests(true)
assert.equal(simulateWorkerExitForTests(), 1)
assert.equal(getFlowListenerState().bound, false)
setWantListenForTests(false)
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
setRefreshIfacesForTests(null) setRefreshIfacesForTests(null)
+261 -401
View File
@@ -1,205 +1,268 @@
import { createSocket, type Socket } from "node:dgram" import { Worker } from "node:worker_threads"
import { desc, eq, gte, sql } from "drizzle-orm" import { gte, sql } from "drizzle-orm"
import { db, sqliteDatabase } from "../db/index.js" import { db, sqliteDatabase } from "../db/index.js"
import { env } from "../config.js"
import { flowBuckets, servers } from "../db/schema.js" import { flowBuckets, servers } from "../db/schema.js"
import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow" import type { FlowStatsDto, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
import { parseFlowPacket, protoName, type ParsedFlow } from "./traffic-flow-parse.js" import { protoName, type ParsedFlow } from "./traffic-flow-parse.js"
import { pickServerIdForExporter } from "./traffic-flow-map-exporter.js" import type { CollectorHeartbeat, ExporterMapPayload, MainToWorker, WorkerToMain } from "./traffic-flow-collector-ipc.js"
import {
attachEngineSqlite,
applyRingSnapshot,
configureEngine,
flushPending,
getEngineStats,
getRingMbps as engineGetRingMbps,
ingestParsedFlowsForServerForTests as engineIngestForServer,
lastFlushUsedTransactionForTests as engineLastFlushTx,
listLiveFlowRows as engineListLive,
peekPendingFlows,
queueParsedFlows,
resetEngineForTests,
resolveServerId,
rollFlowRings,
setExporterResolveCtx,
type PendingFlowRow,
} from "./traffic-flow-engine.js"
import { import {
getTrafficFlowSettingsRow, getTrafficFlowSettingsRow,
listHostPeers, listHostPeers,
recordFlowListenerError,
recordFlowPacket,
} from "./traffic-flow-settings.js" } from "./traffic-flow-settings.js"
import { refreshServerIfaces, resolveIfaceName, shouldRefreshIfaces } from "./traffic-flow-ifaces.js"
import { applicationName } from "./traffic-flow-apps.js" import { applicationName } from "./traffic-flow-apps.js"
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
export type { PendingFlowRow }
export interface FlowListenerState { export interface FlowListenerState {
bound: boolean bound: boolean
address: string | null address: string | null
} }
export interface PendingFlowRow { export interface FlowWorkerHealth {
serverId: number alive: boolean
bucketAt: string bound: boolean
src: string pendingSize: number
dst: string dropped: number
proto: number packetsReceived: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
outIface: string
} }
const TICK_MS = 2_000 let worker: Worker | null = null
const RING_LEN = 60 let restartTimer: ReturnType<typeof setTimeout> | null = null
const LIVE_WINDOW_MS = 15 * 60_000 let restartAttempts = 0
const PRUNE_MS = 5 * 60_000 let lastHeartbeat: CollectorHeartbeat | null = null
let socket: Socket | null = null
let state: FlowListenerState = { bound: false, address: null } let state: FlowListenerState = { bound: false, address: null }
const pending = new Map<string, { let wantListen = false
serverId: number
bucketAt: string
flow: ParsedFlow
bytes: number
packets: number
}>()
const recent = new Map<string, PendingFlowRow>()
let flushTimer: ReturnType<typeof setInterval> | null = null
let lastPruneAt = 0
let refreshIfacesImpl: (serverId: number, force?: boolean) => Promise<void> = refreshServerIfaces
let lastFlushUsedTransaction = false
const upsertFlowStmt = sqliteDatabase.prepare(` attachEngineSqlite(sqliteDatabase)
INSERT INTO flow_buckets (
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface function workerFileUrl(): URL {
) VALUES ( const ts = import.meta.url.includes(".ts")
@serverId, @bucketAt, @src, @dst, @proto, @srcPort, @dstPort, @bytes, @packets, @inIface return new URL(
ts ? "./traffic-flow-collector-worker.ts" : "./traffic-flow-collector-worker.js",
import.meta.url,
) )
ON CONFLICT(server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
DO UPDATE SET
bytes = bytes + excluded.bytes,
packets = packets + excluded.packets
`)
const upsertFlowTx = sqliteDatabase.transaction((rows: Array<{
serverId: number
bucketAt: string
src: string
dst: string
proto: number
srcPort: number
dstPort: number
bytes: number
packets: number
inIface: string
}>) => {
for (const row of rows) upsertFlowStmt.run(row)
})
const tickAccum = new Map<string, { inBytes: number; outBytes: number }>()
const rings = new Map<string, { inBps: number[]; outBps: number[] }>()
export function getFlowListenerState(): FlowListenerState {
return state
} }
function minuteBucketIso(at = Date.now()): string { export function buildExporterMapPayload(): ExporterMapPayload {
const d = new Date(at)
d.setSeconds(0, 0)
return d.toISOString()
}
function ringKey(serverId: number, iface: string): string {
return `${serverId}\0${iface || "__all__"}`
}
function bumpTick(key: string, inBytes: number, outBytes: number): void {
const prev = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
prev.inBytes += inBytes
prev.outBytes += outBytes
tickAccum.set(key, prev)
}
function addToTick(serverId: number, inIface: string, outIface: string, bytes: number): void {
bumpTick(ringKey(serverId, "__all__"), bytes, 0)
if (inIface) bumpTick(ringKey(serverId, inIface), bytes, 0)
if (outIface && outIface !== inIface) bumpTick(ringKey(serverId, outIface), 0, bytes)
}
function emptyRing(): { inBps: number[]; outBps: number[] } {
return { inBps: Array(RING_LEN).fill(0), outBps: Array(RING_LEN).fill(0) }
}
export function rollFlowRings(): void {
const keys = new Set([...tickAccum.keys(), ...rings.keys()])
const sec = TICK_MS / 1000
for (const key of keys) {
const acc = tickAccum.get(key) ?? { inBytes: 0, outBytes: 0 }
tickAccum.delete(key)
const inBps = (acc.inBytes * 8) / sec
const outBps = (acc.outBytes * 8) / sec
let ring = rings.get(key)
if (!ring) {
ring = emptyRing()
rings.set(key, ring)
}
ring.inBps.push(inBps)
ring.inBps.shift()
ring.outBps.push(outBps)
ring.outBps.shift()
}
}
export function getRingMbps(serverId: number, iface = "__all__"): {
rx: number[]
tx: number[]
rxNow: number
txNow: number
} {
const ring = rings.get(ringKey(serverId, iface))
const scale = 1_000_000
if (!ring) {
return { rx: Array(RING_LEN).fill(0), tx: Array(RING_LEN).fill(0), rxNow: 0, txNow: 0 }
}
return {
rx: ring.inBps.map((b) => b / scale),
tx: ring.outBps.map((b) => b / scale),
rxNow: (ring.inBps[RING_LEN - 1] ?? 0) / scale,
txNow: (ring.outBps[RING_LEN - 1] ?? 0) / scale,
}
}
function resolveServerId(exporterIp: string): number | null {
const settings = getTrafficFlowSettingsRow() const settings = getTrafficFlowSettingsRow()
const rows = db.select({ const rows = db.select({
id: servers.id, id: servers.id,
host: servers.host, host: servers.host,
mgmtTunnelIp: servers.mgmtTunnelIp, mgmtTunnelIp: servers.mgmtTunnelIp,
}).from(servers).all() }).from(servers).all()
const byTunnelIp = new Map<string, number>() const byTunnelIp: Array<[string, number]> = []
const hostIps = new Map<string, number>() const hostIps: Array<[string, number]> = []
for (const row of rows) { for (const row of rows) {
if (row.mgmtTunnelIp) byTunnelIp.set(row.mgmtTunnelIp, row.id) if (row.mgmtTunnelIp) byTunnelIp.push([row.mgmtTunnelIp, row.id])
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.set(row.host, row.id) if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(row.host)) hostIps.push([row.host, row.id])
} }
return pickServerIdForExporter({ return {
exporterIp,
overlayPrefix: settings.prefix, overlayPrefix: settings.prefix,
byTunnelIp, byTunnelIp,
peers: listHostPeers(), peers: listHostPeers(),
hostIps, hostIps,
}
}
function applyExporterCtxFromDb(): void {
const payload = buildExporterMapPayload()
setExporterResolveCtx({
overlayPrefix: payload.overlayPrefix,
byTunnelIp: new Map(payload.byTunnelIp),
peers: payload.peers,
hostIps: new Map(payload.hostIps),
}) })
} }
export function setRefreshIfacesForTests(fn: typeof refreshServerIfaces | null): void { function postToWorker(msg: MainToWorker): void {
refreshIfacesImpl = fn ?? refreshServerIfaces worker?.postMessage(msg)
} }
/** REST /interface только при протухшем TTL, не из-за #N в пакете. */ function handleWorkerMessage(msg: WorkerToMain): void {
export function maybeRefreshIfaces(serverId: number): boolean { if (msg.type === "heartbeat") {
if (!shouldRefreshIfaces(serverId)) return false lastHeartbeat = msg.payload
void refreshIfacesImpl(serverId) state = { bound: msg.payload.bound, address: msg.payload.address }
return true applyRingSnapshot(msg.payload.rings)
restartAttempts = 0
return
}
if (msg.type === "error") {
lastHeartbeat = lastHeartbeat
? { ...lastHeartbeat, lastError: msg.payload.message, workerAlive: true }
: null
}
} }
export function lastFlushUsedTransactionForTests(): boolean { function spawnWorker(): void {
return lastFlushUsedTransaction stopWorkerProcess()
const settings = getTrafficFlowSettingsRow()
configureEngine({ topN: settings.topN, retentionHours: settings.retentionHours })
applyExporterCtxFromDb()
const w = new Worker(workerFileUrl(), { execArgv: process.execArgv })
w.on("message", (msg: WorkerToMain) => handleWorkerMessage(msg))
w.on("error", (err) => {
state = { bound: false, address: null }
lastHeartbeat = lastHeartbeat
? { ...lastHeartbeat, workerAlive: false, lastError: err.message, bound: false }
: {
bound: false,
address: null,
packetsReceived: 0,
lastExporterIp: null,
lastError: err.message,
lastDatagramAt: null,
pendingSize: 0,
dropped: 0,
rowsStored: 0,
workerAlive: false,
rings: [],
}
})
w.on("exit", (code) => {
worker = null
state = { bound: false, address: null }
if (!wantListen) return
const delay = Math.min(30_000, 1000 * 2 ** restartAttempts)
restartAttempts += 1
restartTimer = setTimeout(() => {
if (wantListen) spawnWorker()
}, delay)
void code
})
worker = w
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
postToWorker({
type: "start",
payload: {
dbPath: env.DATABASE_PATH,
listenHost: host,
listenPort: settings.flowListenPort,
topN: settings.topN,
retentionHours: settings.retentionHours,
exporterMap: buildExporterMapPayload(),
},
})
} }
function pendingKey(serverId: number, bucketAt: string, flow: ParsedFlow): string { function stopWorkerProcess(): void {
return `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}` if (restartTimer) {
clearTimeout(restartTimer)
restartTimer = null
}
if (worker) {
try {
postToWorker({ type: "stop" })
void worker.terminate()
} catch {
/* ignore */
}
worker = null
}
} }
function rowKey(row: PendingFlowRow): string { export function reattachFlowSqlite(): void {
return `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}` attachEngineSqlite(sqliteDatabase)
}
export function applyHeartbeatForTests(payload: CollectorHeartbeat): void {
handleWorkerMessage({ type: "heartbeat", payload })
}
export function simulateWorkerExitForTests(): number {
worker = null
state = { bound: false, address: null }
lastHeartbeat = lastHeartbeat ? { ...lastHeartbeat, workerAlive: false, bound: false } : null
if (!wantListen) return restartAttempts
restartAttempts += 1
return restartAttempts
}
export function setWantListenForTests(value: boolean): void {
wantListen = value
}
export function getFlowListenerState(): FlowListenerState {
return state
}
export function getFlowWorkerHealth(): FlowWorkerHealth {
const hb = lastHeartbeat
const mem = getEngineStats()
return {
alive: Boolean(worker) && (hb?.workerAlive ?? false),
bound: state.bound,
pendingSize: hb?.pendingSize ?? mem.pendingSize,
dropped: hb?.dropped ?? mem.dropped,
packetsReceived: hb?.packetsReceived ?? mem.packetsReceived,
}
}
export function getFlowRuntimeCounters() {
const settings = getTrafficFlowSettingsRow()
const hb = lastHeartbeat
return {
packetsReceived: hb?.packetsReceived ?? settings.packetsReceived,
lastExporterIp: hb?.lastExporterIp ?? settings.lastExporterIp ?? null,
lastError: (hb?.lastError ?? settings.lastError) || null,
lastDatagramAt: hb?.lastDatagramAt ?? settings.lastDatagramAt ?? null,
dropped: hb?.dropped ?? 0,
}
}
export function startTrafficFlowListener() {
stopTrafficFlowListener()
const settings = getTrafficFlowSettingsRow()
if (!settings.enabled) {
wantListen = false
state = { bound: false, address: null }
return
}
wantListen = true
spawnWorker()
}
export function stopTrafficFlowListener() {
wantListen = false
stopWorkerProcess()
try {
flushPending()
} catch {
/* ignore */
}
state = { bound: false, address: null }
}
export function refreshFlowExporterMap(): void {
applyExporterCtxFromDb()
postToWorker({ type: "updateExporterMap", payload: buildExporterMapPayload() })
}
export function getRingMbps(serverId: number, iface = "__all__") {
return engineGetRingMbps(serverId, iface)
} }
function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void { function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void {
const key = rowKey(row) const key = `${row.serverId}|${row.bucketAt}|${row.src}|${row.dst}|${row.proto}|${row.srcPort}|${row.dstPort}|${row.inIface}`
const prev = map.get(key) const prev = map.get(key)
if (prev) { if (prev) {
prev.bytes += row.bytes prev.bytes += row.bytes
@@ -209,220 +272,21 @@ function mergeInto(map: Map<string, PendingFlowRow>, row: PendingFlowRow): void
map.set(key, { ...row }) map.set(key, { ...row })
} }
function rememberRecent(rows: PendingFlowRow[]): void {
for (const row of rows) mergeInto(recent, row)
}
function pruneRecent(sinceMs = Date.now() - LIVE_WINDOW_MS): void {
const cutoff = new Date(sinceMs).toISOString()
for (const [key, row] of recent) {
if (row.bucketAt < cutoff) recent.delete(key)
}
}
function queueFlows(exporterIp: string, flows: ParsedFlow[]): boolean {
const serverId = resolveServerId(exporterIp)
if (serverId == null) return false
maybeRefreshIfaces(serverId)
const bucketAt = minuteBucketIso()
for (const flow of flows) {
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
const key = `${serverId}\0${bucketAt}\0${flow.src}\0${flow.dst}\0${flow.proto}\0${flow.srcPort}\0${flow.dstPort}\0${flow.inIface}`
const prev = pending.get(key)
if (prev) {
prev.bytes += flow.bytes
prev.packets += flow.packets
} else {
pending.set(key, {
serverId,
bucketAt,
flow: { ...flow },
bytes: flow.bytes,
packets: flow.packets,
})
}
}
return true
}
export function peekPendingFlows(): PendingFlowRow[] {
return [...pending.values()].map(toPendingRow)
}
function toPendingRow(row: {
serverId: number
bucketAt: string
flow: ParsedFlow
bytes: number
packets: number
}): PendingFlowRow {
return {
serverId: row.serverId,
bucketAt: row.bucketAt,
src: row.flow.src || "0.0.0.0",
dst: row.flow.dst || "0.0.0.0",
proto: row.flow.proto,
srcPort: row.flow.srcPort,
dstPort: row.flow.dstPort,
bytes: row.bytes,
packets: row.packets,
inIface: row.flow.inIface,
outIface: row.flow.outIface,
}
}
function pruneStoredBuckets(): void {
const now = Date.now()
if (now - lastPruneAt < PRUNE_MS) return
lastPruneAt = now
const settings = getTrafficFlowSettingsRow()
const topN = Math.max(20, settings.topN)
const cutoff = new Date(now - settings.retentionHours * 3600_000).toISOString()
db.delete(flowBuckets).where(sql`${flowBuckets.bucketAt} < ${cutoff}`).run()
const latest = db.select({ bucketAt: flowBuckets.bucketAt }).from(flowBuckets)
.orderBy(desc(flowBuckets.bucketAt)).limit(1).all()[0]?.bucketAt
if (!latest) return
const latestRows = db.select().from(flowBuckets).where(eq(flowBuckets.bucketAt, latest)).all()
const byServer = new Map<number, typeof latestRows>()
for (const r of latestRows) {
const list = byServer.get(r.serverId) ?? []
list.push(r)
byServer.set(r.serverId, list)
}
for (const list of byServer.values()) {
if (list.length <= topN) continue
list.sort((a, b) => b.bytes - a.bytes)
for (const d of list.slice(topN)) {
db.delete(flowBuckets).where(eq(flowBuckets.id, d.id)).run()
}
}
}
function flushPending() {
pruneRecent()
if (pending.size === 0) {
pruneStoredBuckets()
lastFlushUsedTransaction = false
return
}
const rows = [...pending.values()].map(toPendingRow)
pending.clear()
rememberRecent(rows)
lastFlushUsedTransaction = false
try {
upsertFlowTx(rows.map((r) => ({
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
})))
lastFlushUsedTransaction = true
} catch {
for (const r of rows) {
try {
upsertFlowStmt.run({
serverId: r.serverId,
bucketAt: r.bucketAt,
src: r.src,
dst: r.dst,
proto: r.proto,
srcPort: r.srcPort,
dstPort: r.dstPort,
bytes: r.bytes,
packets: r.packets,
inIface: r.inIface,
})
} catch {
/* ignore single-row failures */
}
}
}
pruneStoredBuckets()
}
export function flushPendingForTests(): void {
flushPending()
}
function onTick() {
rollFlowRings()
flushPending()
}
function onMessage(msg: Buffer, rinfo: { address: string }) {
try {
const flows = parseFlowPacket(msg, rinfo.address)
recordFlowPacket(rinfo.address)
if (!flows.length) return
if (!queueFlows(rinfo.address, flows)) {
recordFlowListenerError(
`IPFIX от ${rinfo.address}: нет jump-host с адресом wg-flow. Docker SNAT (172.x) при нескольких JH не различим.`,
)
return
}
recordFlowListenerError("")
} catch (e) {
recordFlowListenerError(e instanceof Error ? e.message : String(e))
}
}
export function stopTrafficFlowListener() {
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = null
}
flushPending()
if (socket) {
try { socket.close() } catch { /* ignore */ }
socket = null
}
state = { bound: false, address: null }
}
export function startTrafficFlowListener() {
stopTrafficFlowListener()
const settings = getTrafficFlowSettingsRow()
if (!settings.enabled) {
state = { bound: false, address: null }
return
}
const host = process.env.FLOW_LISTEN_HOST?.trim() || settings.collectorIp || "127.0.0.1"
const port = settings.flowListenPort
const sock = createSocket("udp4")
sock.on("error", (err) => {
recordFlowListenerError(err.message)
state = { bound: false, address: null }
})
sock.on("message", onMessage)
sock.bind(port, host, () => {
state = { bound: true, address: `${host}:${port}` }
recordFlowListenerError("")
})
socket = sock
flushTimer = setInterval(onTick, TICK_MS)
}
export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] { export function listLiveFlowRows(sinceIso: string): PendingFlowRow[] {
const merged = new Map<string, PendingFlowRow>() if (worker && lastHeartbeat?.workerAlive) {
for (const row of recent.values()) { return listStoredFlowRows(sinceIso)
if (row.bucketAt < sinceIso) continue
mergeInto(merged, row)
} }
for (const row of peekPendingFlows()) { return engineListLive(sinceIso)
if (row.bucketAt < sinceIso) continue
mergeInto(merged, row)
}
return [...merged.values()]
} }
export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] { export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
const stored = db.select().from(flowBuckets).where(gte(flowBuckets.bucketAt, sinceIso)).all() const settings = getTrafficFlowSettingsRow()
const cap = Math.max(20, settings.topN) * 60
const stored = db.select().from(flowBuckets)
.where(gte(flowBuckets.bucketAt, sinceIso))
.orderBy(sql`${flowBuckets.bytes} DESC`)
.limit(cap)
.all()
const merged = new Map<string, PendingFlowRow>() const merged = new Map<string, PendingFlowRow>()
for (const r of stored) { for (const r of stored) {
mergeInto(merged, { mergeInto(merged, {
@@ -439,22 +303,24 @@ export function listStoredFlowRows(sinceIso: string): PendingFlowRow[] {
outIface: "", outIface: "",
}) })
} }
for (const p of peekPendingFlows()) { if (!worker) {
if (p.bucketAt < sinceIso) continue for (const p of peekPendingFlows()) {
mergeInto(merged, p) if (p.bucketAt < sinceIso) continue
mergeInto(merged, p)
}
} }
return [...merged.values()] return [...merged.values()]
} }
/** SSE / короткое окно — память; длинные окна — SQLite. */
export function listFlowRowsForWindow(minutes: number): PendingFlowRow[] { export function listFlowRowsForWindow(minutes: number): PendingFlowRow[] {
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString() const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
if (minutes <= 15) return listLiveFlowRows(sinceIso) if (minutes <= 15 && !worker) return listLiveFlowRows(sinceIso)
return listStoredFlowRows(sinceIso) return listStoredFlowRows(sinceIso)
} }
export function listFlowTalkers(minutes = 5): FlowStatsDto { export function listFlowTalkers(minutes = 5): FlowStatsDto {
const settings = getTrafficFlowSettingsRow() const settings = getTrafficFlowSettingsRow()
const runtime = getFlowRuntimeCounters()
const rows = listFlowRowsForWindow(minutes) const rows = listFlowRowsForWindow(minutes)
const serverRows = db.select().from(servers).all() const serverRows = db.select().from(servers).all()
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host])) const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
@@ -519,50 +385,44 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
uniqueDst: dsts.size, uniqueDst: dsts.size,
topProto, topProto,
talkers, talkers,
lastExporterIp: settings.lastExporterIp ?? null, lastExporterIp: runtime.lastExporterIp,
lastError: settings.lastError || null, lastError: runtime.lastError,
packetsReceived: settings.packetsReceived, packetsReceived: runtime.packetsReceived,
lastDatagramAt: settings.lastDatagramAt ?? null, lastDatagramAt: runtime.lastDatagramAt,
listenerBound: state.bound, listenerBound: state.bound,
listenerAddress: state.address, listenerAddress: state.address,
} }
} }
export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) { export function ingestParsedFlowsForTests(exporterIp: string, flows: ParsedFlow[]) {
queueFlows(exporterIp, flows) applyExporterCtxFromDb()
const serverId = resolveServerId(exporterIp)
if (serverId == null) return
queueParsedFlows(serverId, flows)
rollFlowRings() rollFlowRings()
flushPending() flushPending()
} }
/** Кладёт потоки в pending без flush в SQLite — для юнит-тестов аналитики. */
export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]) { export function ingestParsedFlowsForServerForTests(serverId: number, flows: ParsedFlow[]) {
const bucketAt = minuteBucketIso() engineIngestForServer(serverId, flows)
for (const flow of flows) {
addToTick(serverId, flow.inIface, flow.outIface, flow.bytes)
const key = pendingKey(serverId, bucketAt, flow)
const prev = pending.get(key)
if (prev) {
prev.bytes += flow.bytes
prev.packets += flow.packets
} else {
pending.set(key, {
serverId,
bucketAt,
flow: { ...flow },
bytes: flow.bytes,
packets: flow.packets,
})
}
}
rollFlowRings()
} }
export function resetFlowRingsForTests() { export function resetFlowRingsForTests() {
tickAccum.clear() resetEngineForTests()
rings.clear() attachEngineSqlite(sqliteDatabase)
pending.clear() lastHeartbeat = null
recent.clear() wantListen = false
lastPruneAt = 0 restartAttempts = 0
lastFlushUsedTransaction = false
refreshIfacesImpl = refreshServerIfaces
} }
export function lastFlushUsedTransactionForTests(): boolean {
return engineLastFlushTx()
}
export function flushPendingForTests(): void {
flushPending()
}
export { peekPendingFlows }
export { setPendingCapForTests } from "./traffic-flow-engine.js"
export { maybeRefreshIfaces, setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
+2 -1
View File
@@ -19,7 +19,7 @@ import {
getTrafficFlowSettingsRow, getTrafficFlowSettingsRow,
upsertHostPeer, upsertHostPeer,
} from "./traffic-flow-settings.js" } from "./traffic-flow-settings.js"
import { startTrafficFlowListener } from "./traffic-flow-ingest.js" import { refreshFlowExporterMap, startTrafficFlowListener } from "./traffic-flow-ingest.js"
import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js" import { listTrafficFlowHostFiles } from "./traffic-flow-host-files.js"
const IFACE_NAME = "wg-flow" const IFACE_NAME = "wg-flow"
@@ -268,6 +268,7 @@ export async function applyFlowOverlay(
enableTrafficFlowIngest() enableTrafficFlowIngest()
startTrafficFlowListener() startTrafficFlowListener()
refreshFlowExporterMap()
steps.push("Коллектор IPFIX на MM включён") steps.push("Коллектор IPFIX на MM включён")
return { return {
@@ -1,5 +1,5 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { parseFlowPacket, protoName, resetFlowTemplatesForTests } from "./traffic-flow-parse.js" import { parseFlowPacket, protoName, resetFlowTemplatesForTests, templateExporterCountForTests } from "./traffic-flow-parse.js"
import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js" import { allocateOverlayAddress, FLOW_TARGET_SRC_AUTO, usablePublicHost } from "./traffic-flow-overlay.js"
function netflowV5One(): Buffer { function netflowV5One(): Buffer {
@@ -100,4 +100,23 @@ resetFlowTemplatesForTests()
assert.equal(named[0]?.src, "10.1.1.8") assert.equal(named[0]?.src, "10.1.1.8")
} }
resetFlowTemplatesForTests()
{
const tpl = Buffer.alloc(16 + 16 + 20)
tpl.writeUInt16BE(10, 0)
tpl.writeUInt16BE(tpl.length, 2)
tpl.writeUInt16BE(2, 16)
tpl.writeUInt16BE(16, 18)
tpl.writeUInt16BE(256, 20)
tpl.writeUInt16BE(2, 22)
tpl.writeUInt16BE(8, 24)
tpl.writeUInt16BE(4, 26)
tpl.writeUInt16BE(12, 28)
tpl.writeUInt16BE(4, 30)
for (let i = 0; i < 260; i++) {
parseFlowPacket(tpl, `203.0.${Math.floor(i / 250)}.${i % 250}`)
}
assert.ok(templateExporterCountForTests() <= 256)
}
console.log("traffic-flow-parse.test.ts: ok") console.log("traffic-flow-parse.test.ts: ok")
+24 -2
View File
@@ -19,8 +19,26 @@ interface Template {
fields: FieldSpec[] fields: FieldSpec[]
} }
const MAX_TEMPLATE_EXPORTERS = 256
const templatesByExporter = new Map<string, Map<number, Template>>() const templatesByExporter = new Map<string, Map<number, Template>>()
function templatesForExporter(exporter: string): Map<number, Template> {
const existing = templatesByExporter.get(exporter)
if (existing) {
templatesByExporter.delete(exporter)
templatesByExporter.set(exporter, existing)
return existing
}
const created = new Map<number, Template>()
templatesByExporter.set(exporter, created)
while (templatesByExporter.size > MAX_TEMPLATE_EXPORTERS) {
const oldest = templatesByExporter.keys().next().value
if (oldest == null || oldest === exporter) break
templatesByExporter.delete(oldest)
}
return created
}
function ipv4(buf: Buffer, offset: number): string { function ipv4(buf: Buffer, offset: number): string {
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}` return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`
} }
@@ -105,7 +123,7 @@ function parseNetflowV5(buf: Buffer): ParsedFlow[] {
function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) { function parseIpfixTemplates(exporter: string, buf: Buffer, setStart: number, setEnd: number, setId: number) {
let off = setStart + 4 let off = setStart + 4
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>() const map = templatesForExporter(exporter)
while (off + 4 <= setEnd) { while (off + 4 <= setEnd) {
const templateId = buf.readUInt16BE(off) const templateId = buf.readUInt16BE(off)
const fieldCount = buf.readUInt16BE(off + 2) const fieldCount = buf.readUInt16BE(off + 2)
@@ -255,7 +273,7 @@ function parseNetflowV9(buf: Buffer, exporter: string): ParsedFlow[] {
const count = buf.readUInt16BE(2) const count = buf.readUInt16BE(2)
let off = 20 let off = 20
const out: ParsedFlow[] = [] const out: ParsedFlow[] = []
const map = templatesByExporter.get(exporter) ?? new Map<number, Template>() const map = templatesForExporter(exporter)
for (let s = 0; s < count && off + 4 <= buf.length; s++) { for (let s = 0; s < count && off + 4 <= buf.length; s++) {
const setId = buf.readUInt16BE(off) const setId = buf.readUInt16BE(off)
const setLen = buf.readUInt16BE(off + 2) const setLen = buf.readUInt16BE(off + 2)
@@ -308,3 +326,7 @@ export function protoName(proto: number): string {
export function resetFlowTemplatesForTests() { export function resetFlowTemplatesForTests() {
templatesByExporter.clear() templatesByExporter.clear()
} }
export function templateExporterCountForTests(): number {
return templatesByExporter.size
}
+2 -1
View File
@@ -30,13 +30,14 @@ function formatBytes(n: number): string {
return `${n} Б` return `${n} Б`
} }
const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h", "30d"] as const
const RANGE_LABELS: Record<string, string> = { const RANGE_LABELS: Record<string, string> = {
"5m": "5м", "5m": "5м",
"15m": "15м", "15m": "15м",
"1h": "1ч", "1h": "1ч",
"4h": "4ч", "4h": "4ч",
"24h": "24ч", "24h": "24ч",
"30d": "месяц",
} }
function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) { function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
+3 -2
View File
@@ -71,8 +71,9 @@ export function useFlowLive(opts: {
if (!raw.trim() || raw.trim().startsWith(":")) continue if (!raw.trim() || raw.trim().startsWith(":")) continue
const ev = parseSseBlock(raw) const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) { if (ev.event === "sample" && ev.data) {
setSample(JSON.parse(ev.data) as FlowAnalyticsDto) const parsed = JSON.parse(ev.data) as FlowAnalyticsDto
setError(null) setSample(parsed)
setError(parsed.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
} else if (ev.event === "error" && ev.data) { } else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string } const parsed = JSON.parse(ev.data) as { error?: string }
setError(parsed.error ?? "live error") setError(parsed.error ?? "live error")
+10
View File
@@ -174,6 +174,7 @@ export const flowAnalyticsDtoSchema = z.object({
ifaces: z.array(flowIfaceChipSchema), ifaces: z.array(flowIfaceChipSchema),
live: z.boolean(), live: z.boolean(),
dedupApplied: z.boolean().optional(), dedupApplied: z.boolean().optional(),
degraded: z.boolean().optional(),
}) })
export const flowExportersDtoSchema = z.object({ export const flowExportersDtoSchema = z.object({
@@ -190,6 +191,14 @@ export const flowClientsDtoSchema = z.object({
clients: z.array(flowEntityCardSchema), clients: z.array(flowEntityCardSchema),
}) })
export const flowMonthlyDtoSchema = z.object({
month: z.string(),
bytes: z.number().nonnegative(),
countries: z.array(flowBreakdownRowSchema),
services: z.array(flowBreakdownRowSchema),
asns: z.array(flowBreakdownRowSchema),
})
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema> export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema> export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema> export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
@@ -199,3 +208,4 @@ export type FlowMapEdge = z.infer<typeof flowMapEdgeSchema>
export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema> export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema>
export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema> export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema> export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
+11
View File
@@ -2,6 +2,7 @@ import type {
FlowAnalyticsDto, FlowAnalyticsDto,
FlowClientsDto, FlowClientsDto,
FlowExportersDto, FlowExportersDto,
FlowMonthlyDto,
FlowStatsDto, FlowStatsDto,
TrafficFlowHostFile, TrafficFlowHostFile,
TrafficFlowOverlayResult, TrafficFlowOverlayResult,
@@ -87,4 +88,14 @@ export async function getFlowAnalytics(
return requestJson<FlowAnalyticsDto>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`) return requestJson<FlowAnalyticsDto>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`)
} }
export async function getFlowMonthly(
baseUrl: string,
params: { month: string; serverId?: string },
): Promise<FlowMonthlyDto> {
const q = new URLSearchParams()
q.set("month", params.month)
if (params.serverId) q.set("serverId", params.serverId)
return requestJson<FlowMonthlyDto>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`)
}
export { flowQuery } export { flowQuery }