fix(backend): уменьшить лишние записи SQLite
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m8s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-image (push) Successful in 2m8s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Successful in 12s
Убрать WAL TRUNCATE с hot path NetFlow, не писать неизменённые UPDATE и служебные INSERT, кэшировать карты topology/analytics. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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-planes.test.ts && tsx src/services/traffic-flow-ip.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-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.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-planes.test.ts && tsx src/services/traffic-flow-ip.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-map-hops.test.ts && tsx src/services/traffic-flow-hardening.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/sqlite-write-opt.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": {
|
||||||
|
|||||||
+68
-1
@@ -8,18 +8,85 @@ import { env } from "../config.js"
|
|||||||
import * as schema from "./schema.js"
|
import * as schema from "./schema.js"
|
||||||
|
|
||||||
export const SQLITE_BUSY_TIMEOUT_MS = 5000
|
export const SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||||
|
/** ~16 MiB page cache (negative = KiB). */
|
||||||
|
export const SQLITE_CACHE_SIZE_KIB = 16_000
|
||||||
|
export const SQLITE_WAL_AUTOCHECKPOINT_PAGES = 1000
|
||||||
|
|
||||||
|
export interface SqliteWriteStats {
|
||||||
|
insert: number
|
||||||
|
update: number
|
||||||
|
delete: number
|
||||||
|
walCheckpoint: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeTrace: SqliteWriteStats | null = null
|
||||||
|
|
||||||
|
function classifyWriteSql(sql: string): keyof Omit<SqliteWriteStats, "walCheckpoint"> | null {
|
||||||
|
const head = sql.trimStart().slice(0, 12).toUpperCase()
|
||||||
|
if (head.startsWith("INSERT")) return "insert"
|
||||||
|
if (head.startsWith("UPDATE")) return "update"
|
||||||
|
if (head.startsWith("DELETE")) return "delete"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function installSqliteWriteTrace(handle: SqliteHandle): SqliteHandle {
|
||||||
|
const origPrepare = handle.prepare.bind(handle)
|
||||||
|
handle.prepare = ((sql: string) => {
|
||||||
|
const stmt = origPrepare(sql)
|
||||||
|
const kind = classifyWriteSql(sql)
|
||||||
|
if (!kind) return stmt
|
||||||
|
const origRun = stmt.run.bind(stmt)
|
||||||
|
stmt.run = ((...args: unknown[]) => {
|
||||||
|
if (writeTrace) writeTrace[kind] += 1
|
||||||
|
return origRun(...args)
|
||||||
|
}) as typeof stmt.run
|
||||||
|
return stmt
|
||||||
|
}) as typeof handle.prepare
|
||||||
|
|
||||||
|
const origExec = handle.exec.bind(handle)
|
||||||
|
handle.exec = ((sql: string) => {
|
||||||
|
if (writeTrace) {
|
||||||
|
for (const part of sql.split(";")) {
|
||||||
|
const kind = classifyWriteSql(part)
|
||||||
|
if (kind) writeTrace[kind] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return origExec(sql)
|
||||||
|
}) as typeof handle.exec
|
||||||
|
|
||||||
|
const origPragma = handle.pragma.bind(handle)
|
||||||
|
handle.pragma = ((source: string, options?: { simple?: boolean }) => {
|
||||||
|
if (writeTrace && /wal_checkpoint/i.test(source)) writeTrace.walCheckpoint += 1
|
||||||
|
return origPragma(source, options as never)
|
||||||
|
}) as typeof handle.pragma
|
||||||
|
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countSqliteWrites<T>(fn: () => T): { result: T; stats: SqliteWriteStats } {
|
||||||
|
const stats: SqliteWriteStats = { insert: 0, update: 0, delete: 0, walCheckpoint: 0 }
|
||||||
|
writeTrace = stats
|
||||||
|
try {
|
||||||
|
return { result: fn(), stats }
|
||||||
|
} finally {
|
||||||
|
writeTrace = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function applySqlitePragmas(handle: SqliteHandle): void {
|
export function applySqlitePragmas(handle: SqliteHandle): void {
|
||||||
handle.pragma("journal_mode = WAL")
|
handle.pragma("journal_mode = WAL")
|
||||||
handle.pragma("foreign_keys = ON")
|
handle.pragma("foreign_keys = ON")
|
||||||
handle.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
|
handle.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`)
|
||||||
handle.pragma("synchronous = NORMAL")
|
handle.pragma("synchronous = NORMAL")
|
||||||
|
handle.pragma(`wal_autocheckpoint = ${SQLITE_WAL_AUTOCHECKPOINT_PAGES}`)
|
||||||
|
handle.pragma("temp_store = MEMORY")
|
||||||
|
handle.pragma(`cache_size = -${SQLITE_CACHE_SIZE_KIB}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSqlite(): SqliteHandle {
|
function openSqlite(): SqliteHandle {
|
||||||
const handle = new Database(env.DATABASE_PATH)
|
const handle = new Database(env.DATABASE_PATH)
|
||||||
applySqlitePragmas(handle)
|
applySqlitePragmas(handle)
|
||||||
return handle
|
return installSqliteWriteTrace(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sqlite = openSqlite()
|
let sqlite = openSqlite()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { desc, eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import { db } from "../../../db/index.js"
|
import { db } from "../../../db/index.js"
|
||||||
import { serverSnapshots, servers } from "../../../db/schema.js"
|
import { serverSnapshots, servers } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
export type ServerRow = typeof servers.$inferSelect
|
export type ServerRow = typeof servers.$inferSelect
|
||||||
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
@@ -17,6 +18,7 @@ export function createServerRow(
|
|||||||
values: Omit<typeof servers.$inferInsert, "id">,
|
values: Omit<typeof servers.$inferInsert, "id">,
|
||||||
): ServerRow {
|
): ServerRow {
|
||||||
const [inserted] = db.insert(servers).values(values).returning().all()
|
const [inserted] = db.insert(servers).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,13 @@ export function updateServerRowById(
|
|||||||
values: Partial<ServerRow>,
|
values: Partial<ServerRow>,
|
||||||
): ServerRow {
|
): ServerRow {
|
||||||
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteServerRowById(id: number): void {
|
export function deleteServerRowById(id: number): void {
|
||||||
db.delete(servers).where(eq(servers.id, id)).run()
|
db.delete(servers).where(eq(servers.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq } from "drizzle-orm"
|
import { and, count, eq } from "drizzle-orm"
|
||||||
import { db } from "../../../db/index.js"
|
import { db } from "../../../db/index.js"
|
||||||
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
export type AppUserRow = typeof appUsers.$inferSelect
|
export type AppUserRow = typeof appUsers.$inferSelect
|
||||||
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||||
@@ -19,6 +20,7 @@ export function getUserRowByLogin(login: string): AppUserRow | undefined {
|
|||||||
|
|
||||||
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
export function createUserRow(values: typeof appUsers.$inferInsert): AppUserRow {
|
||||||
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
const [inserted] = db.insert(appUsers).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +29,13 @@ export function updateUserRowById(
|
|||||||
values: Partial<AppUserRow>,
|
values: Partial<AppUserRow>,
|
||||||
): AppUserRow {
|
): AppUserRow {
|
||||||
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
const [updated] = db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUserRowById(id: string): void {
|
export function deleteUserRowById(id: string): void {
|
||||||
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
db.delete(appUsers).where(eq(appUsers.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBindingRows(): BindingRow[] {
|
export function listBindingRows(): BindingRow[] {
|
||||||
@@ -65,13 +69,15 @@ export function getBindingByServerIfacePeer(
|
|||||||
|
|
||||||
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
export function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): BindingRow {
|
||||||
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
const [inserted] = db.insert(userInterfaceBindings).values(values).returning().all()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteBindingRowById(id: string): void {
|
export function deleteBindingRowById(id: string): void {
|
||||||
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countUserRows(): number {
|
export function countUserRows(): number {
|
||||||
return db.select().from(appUsers).all().length
|
return db.select({ n: count() }).from(appUsers).all()[0]?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { count } from "drizzle-orm"
|
||||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
@@ -11,27 +12,22 @@ import {
|
|||||||
} from "../db/schema.js"
|
} from "../db/schema.js"
|
||||||
import { listUsers } from "../modules/users/service/users-service.js"
|
import { listUsers } from "../modules/users/service/users-service.js"
|
||||||
|
|
||||||
|
function tableCount(table: typeof servers | typeof filterRules | typeof uptimeProbes | typeof uptimeSpeedProbes | typeof recursiveRoutes): number {
|
||||||
|
return db.select({ n: count() }).from(table).all()[0]?.n ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/sidebar-counts", async (_req, reply) => {
|
app.get("/sidebar-counts", async (_req, reply) => {
|
||||||
const [
|
const serversTotal = tableCount(servers)
|
||||||
serversTotal,
|
const filterRulesTotal = tableCount(filterRules)
|
||||||
filterRulesTotal,
|
const uptimeProbesTotal = tableCount(uptimeProbes)
|
||||||
uptimeProbesTotal,
|
const uptimeSpeedProbesTotal = tableCount(uptimeSpeedProbes)
|
||||||
uptimeSpeedProbesTotal,
|
const recursiveRoutesTotal = tableCount(recursiveRoutes)
|
||||||
recursiveRoutesTotal,
|
const [certificatesTotal, wireguardTotal] = await Promise.all([
|
||||||
certificatesTotal,
|
|
||||||
wireguardTotal,
|
|
||||||
usersTotal,
|
|
||||||
] = await Promise.all([
|
|
||||||
Promise.resolve(db.select().from(servers).all().length),
|
|
||||||
Promise.resolve(db.select().from(filterRules).all().length),
|
|
||||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
|
||||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
|
||||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
|
||||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||||
countWireGuardInterfaces().catch(() => 0),
|
countWireGuardInterfaces().catch(() => 0),
|
||||||
Promise.resolve(listUsers().length),
|
|
||||||
])
|
])
|
||||||
|
const usersTotal = listUsers().length
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
servers: serversTotal,
|
servers: serversTotal,
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
.all()[0]
|
.all()[0]
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (existing.payloadJson === payloadJson) return
|
||||||
db.update(alertEnginePrevLive)
|
db.update(alertEnginePrevLive)
|
||||||
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
.set({ payloadJson, updatedAt: new Date().toISOString() })
|
||||||
.where(eq(alertEnginePrevLive.kind, kind))
|
.where(eq(alertEnginePrevLive.kind, kind))
|
||||||
.run()
|
.run()
|
||||||
} else {
|
return
|
||||||
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
|
||||||
}
|
}
|
||||||
|
db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { db, sqliteDatabase } from "../db/index.js"
|
|
||||||
import { alertBgpPeerSamples, alertGreTunnelSamples } from "../db/schema.js"
|
|
||||||
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
|
import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js"
|
||||||
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
|
import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js"
|
||||||
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||||
@@ -12,8 +10,8 @@ export function getGreBgpSnapshotCollectorState(): { running: boolean } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Один опрос GRE + BGP по включённым серверам и запись строк в SQLite для `buildSignalSnapshot`.
|
* Один опрос GRE + BGP по включённым серверам.
|
||||||
* Движок оповещений больше не дублирует эти REST-запросы.
|
* Снимок для алертов живёт в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`).
|
||||||
*/
|
*/
|
||||||
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
|
export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnapshot> {
|
||||||
const sampledAt = new Date().toISOString()
|
const sampledAt = new Date().toISOString()
|
||||||
@@ -62,32 +60,8 @@ export async function collectGreBgpSnapshotOnce(): Promise<GreBgpSnapshotRunSnap
|
|||||||
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
|
const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : []
|
||||||
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
|
snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status }))
|
||||||
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
|
snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state }))
|
||||||
|
snapshot.greWritten = greRows.length
|
||||||
db.transaction((tx) => {
|
snapshot.bgpWritten = bgpRows.length
|
||||||
for (const r of greRows) {
|
|
||||||
tx.insert(alertGreTunnelSamples).values({
|
|
||||||
sampledAt,
|
|
||||||
targetLabel: r.targetLabel,
|
|
||||||
status: r.status,
|
|
||||||
}).run()
|
|
||||||
snapshot.greWritten += 1
|
|
||||||
}
|
|
||||||
for (const s of bgpRows) {
|
|
||||||
tx.insert(alertBgpPeerSamples).values({
|
|
||||||
sampledAt,
|
|
||||||
peerKey: bgpPeerAlertKey(s),
|
|
||||||
state: s.state,
|
|
||||||
}).run()
|
|
||||||
snapshot.bgpWritten += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
sqliteDatabase
|
|
||||||
.prepare(`DELETE FROM alert_gre_tunnel_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
|
||||||
.run()
|
|
||||||
sqliteDatabase
|
|
||||||
.prepare(`DELETE FROM alert_bgp_peer_samples WHERE sampled_at < datetime('now', '-30 days')`)
|
|
||||||
.run()
|
|
||||||
|
|
||||||
if (errors.length) snapshot.errors = errors
|
if (errors.length) snapshot.errors = errors
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { SnapshotInsert } from "../db/schema.js"
|
|||||||
import type { SnapshotRead } from "../types/server.js"
|
import type { SnapshotRead } from "../types/server.js"
|
||||||
import { MikrotikClient } from "./mikrotik.js"
|
import { MikrotikClient } from "./mikrotik.js"
|
||||||
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
// ── pollServer ─────────────────────────────────────────────────────────────────
|
// ── pollServer ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,11 +65,13 @@ export async function pollServer(serverId: number): Promise<SnapshotRead> {
|
|||||||
rawIpAddresses: JSON.stringify(addresses),
|
rawIpAddresses: JSON.stringify(addresses),
|
||||||
} satisfies Partial<SnapshotInsert>)
|
} satisfies Partial<SnapshotInsert>)
|
||||||
|
|
||||||
// Keep server.name in sync with RouterOS identity
|
if ((identity.name || "") !== (server.name || "")) {
|
||||||
db.update(servers)
|
db.update(servers)
|
||||||
.set({ name: identity.name, updatedAt: now })
|
.set({ name: identity.name, updatedAt: now })
|
||||||
.where(eq(servers.id, serverId))
|
.where(eq(servers.id, serverId))
|
||||||
.run()
|
.run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log but don't throw — we still persist the offline snapshot
|
// Log but don't throw — we still persist the offline snapshot
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
|
* 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе.
|
||||||
*
|
*
|
||||||
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
|
* **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч.
|
||||||
* `gre_bgp` → `alert_gre_tunnel_samples` / `alert_bgp_peer_samples`. После успешного завершения джоб
|
* `gre_bgp` → snapshot в `scheduler_runs.result_json` (`greTunnels` / `bgpPeers`). После успешного завершения джоб
|
||||||
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
|
* `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка
|
||||||
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
|
* (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов
|
||||||
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
|
* для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { desc, eq, lt } from "drizzle-orm"
|
import { desc, eq, lt } from "drizzle-orm"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { schedulerRuns } from "../db/schema.js"
|
import { events, schedulerRuns } from "../db/schema.js"
|
||||||
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||||
import {
|
import {
|
||||||
@@ -71,6 +71,20 @@ const timers = new Map<string, ReturnType<typeof setInterval>>()
|
|||||||
|
|
||||||
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
|
||||||
|
"traffic",
|
||||||
|
"servers_rest_ping",
|
||||||
|
"uptime_resources",
|
||||||
|
"uptime_ping",
|
||||||
|
"uptime_speed",
|
||||||
|
"gre_bgp",
|
||||||
|
"alert_engine",
|
||||||
|
])
|
||||||
|
|
||||||
|
export function shouldAppendSchedulerOkEvent(jobKey: SchedulerJobKey): boolean {
|
||||||
|
return !QUIET_SCHEDULER_OK_JOBS.has(jobKey)
|
||||||
|
}
|
||||||
|
|
||||||
function newRunId(): string {
|
function newRunId(): string {
|
||||||
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
}
|
}
|
||||||
@@ -84,18 +98,21 @@ function appendSchedulerRun(row: {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
result?: SchedulerRunSnapshot | null
|
result?: SchedulerRunSnapshot | null
|
||||||
}) {
|
}) {
|
||||||
db.insert(schedulerRuns).values({
|
|
||||||
id: newRunId(),
|
|
||||||
jobKey: row.jobKey,
|
|
||||||
startedAt: row.startedAt,
|
|
||||||
finishedAt: row.finishedAt,
|
|
||||||
status: row.status,
|
|
||||||
error: row.error,
|
|
||||||
durationMs: row.durationMs,
|
|
||||||
resultJson: row.result ? JSON.stringify(row.result) : null,
|
|
||||||
}).run()
|
|
||||||
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
|
const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString()
|
||||||
db.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schedulerRuns).values({
|
||||||
|
id: newRunId(),
|
||||||
|
jobKey: row.jobKey,
|
||||||
|
startedAt: row.startedAt,
|
||||||
|
finishedAt: row.finishedAt,
|
||||||
|
status: row.status,
|
||||||
|
error: row.error,
|
||||||
|
durationMs: row.durationMs,
|
||||||
|
resultJson: row.result ? JSON.stringify(row.result) : null,
|
||||||
|
}).run()
|
||||||
|
tx.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run()
|
||||||
|
tx.delete(events).where(lt(events.createdAt, cutoff)).run()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||||
@@ -159,19 +176,21 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
|||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result: snapshot ?? null,
|
result: snapshot ?? null,
|
||||||
})
|
})
|
||||||
appendEvent({
|
if (shouldAppendSchedulerOkEvent(jobKey)) {
|
||||||
level: "info",
|
appendEvent({
|
||||||
eventType: "scheduler.job.ok",
|
level: "info",
|
||||||
sourceModule: "scheduler",
|
eventType: "scheduler.job.ok",
|
||||||
title: "Задача планировщика завершена",
|
sourceModule: "scheduler",
|
||||||
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
title: "Задача планировщика завершена",
|
||||||
entityType: "job",
|
message: `${jobKey}: выполнено за ${Date.now() - startedAt} мс`,
|
||||||
entityId: jobKey,
|
entityType: "job",
|
||||||
payload: {
|
entityId: jobKey,
|
||||||
startedAt: startedIso,
|
payload: {
|
||||||
finishedAt: finishedIso,
|
startedAt: startedIso,
|
||||||
},
|
finishedAt: finishedIso,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
jobKey === "traffic" ||
|
jobKey === "traffic" ||
|
||||||
jobKey === "servers_rest_ping" ||
|
jobKey === "servers_rest_ping" ||
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { countSqliteWrites, sqliteDatabase } from "../db/index.js"
|
||||||
|
import { events } from "../db/schema.js"
|
||||||
|
import { db } from "../db/index.js"
|
||||||
|
import {
|
||||||
|
attachEngineSqlite,
|
||||||
|
bumpPacketMeta,
|
||||||
|
configureEngine,
|
||||||
|
flushPending,
|
||||||
|
ingestParsedFlowsForServerForTests,
|
||||||
|
resetEngineForTests,
|
||||||
|
setEngineError,
|
||||||
|
} from "./traffic-flow-engine.js"
|
||||||
|
import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js"
|
||||||
|
import { shouldAppendSchedulerOkEvent, executeSchedulerJob } from "./scheduler.js"
|
||||||
|
import { savePrevLiveMap, loadPrevLiveMap } from "./alert-engine/prev-live-store.js"
|
||||||
|
import {
|
||||||
|
invalidateFlowCatalogCache,
|
||||||
|
loadFlowTopology,
|
||||||
|
seedFlowTopologyForTests,
|
||||||
|
} from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
attachEngineSqlite(sqliteDatabase)
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
|
||||||
|
const idle1 = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(idle1.stats.walCheckpoint, 0)
|
||||||
|
assert.ok(idle1.stats.update >= 1, "first idle flush persists listener stats")
|
||||||
|
|
||||||
|
const idle2 = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(idle2.stats.update, 0, "unchanged listener stats skip UPDATE")
|
||||||
|
assert.equal(idle2.stats.walCheckpoint, 0)
|
||||||
|
|
||||||
|
bumpPacketMeta("203.0.113.9")
|
||||||
|
const changed = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(changed.stats.update, 1, "changed packets persist once")
|
||||||
|
assert.equal(changed.stats.walCheckpoint, 0)
|
||||||
|
|
||||||
|
setEngineError("boom")
|
||||||
|
const errWrite = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(errWrite.stats.update, 1)
|
||||||
|
setEngineError("")
|
||||||
|
flushPending()
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
configureEngine({ topN: 20 })
|
||||||
|
ingestParsedFlowsForServerForTests(9, [{
|
||||||
|
src: "10.1.1.1",
|
||||||
|
dst: "8.8.8.8",
|
||||||
|
proto: 6,
|
||||||
|
srcPort: 40000,
|
||||||
|
dstPort: 443,
|
||||||
|
bytes: 100,
|
||||||
|
packets: 1,
|
||||||
|
inIface: "2",
|
||||||
|
outIface: "",
|
||||||
|
}])
|
||||||
|
const withData = countSqliteWrites(() => {
|
||||||
|
flushPending()
|
||||||
|
})
|
||||||
|
assert.equal(withData.stats.walCheckpoint, 0, "flush with data must not TRUNCATE WAL")
|
||||||
|
assert.ok(withData.stats.insert >= 1, "flow upsert writes")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
const plan = sqliteDatabase.prepare(`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT id FROM flow_buckets WHERE bucket_at >= ? ORDER BY bytes DESC LIMIT 100
|
||||||
|
`).all("2000-01-01T00:00:00.000Z") as Array<{ detail?: string }>
|
||||||
|
const planText = plan.map((p) => String(p.detail ?? "")).join(" | ")
|
||||||
|
assert.ok(planText.length > 0, "EXPLAIN QUERY PLAN returned rows")
|
||||||
|
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
const topoA = loadFlowTopology()
|
||||||
|
const topoB = loadFlowTopology()
|
||||||
|
assert.equal(topoA, topoB, "topology cache returns same object")
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
const topoC = loadFlowTopology()
|
||||||
|
assert.notEqual(topoA, topoC, "invalidate rebuilds topology")
|
||||||
|
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("traffic"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("alert_engine"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("gre_bgp"), false)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("backups"), true)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("certificates_renew"), true)
|
||||||
|
assert.equal(shouldAppendSchedulerOkEvent("internet_path"), true)
|
||||||
|
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
const prevSame = countSqliteWrites(() => {
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
})
|
||||||
|
assert.equal(prevSame.stats.update, 0)
|
||||||
|
assert.equal(prevSame.stats.insert, 0)
|
||||||
|
savePrevLiveMap("gre", { a: "down" })
|
||||||
|
assert.equal(loadPrevLiveMap("gre").a, "down")
|
||||||
|
savePrevLiveMap("gre", { a: "up" })
|
||||||
|
|
||||||
|
const greBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||||
|
const bgpBefore = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||||
|
await collectGreBgpSnapshotOnce()
|
||||||
|
const greAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_gre_tunnel_samples`).get() as { n: number }
|
||||||
|
const bgpAfter = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM alert_bgp_peer_samples`).get() as { n: number }
|
||||||
|
assert.equal(greAfter.n, greBefore.n)
|
||||||
|
assert.equal(bgpAfter.n, bgpBefore.n)
|
||||||
|
|
||||||
|
const oldId = `evt-old-${randomUUID()}`
|
||||||
|
db.insert(events).values({
|
||||||
|
id: oldId,
|
||||||
|
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
eventType: "test.retention",
|
||||||
|
sourceModule: "system",
|
||||||
|
title: "old",
|
||||||
|
message: "old",
|
||||||
|
}).run()
|
||||||
|
const eventsBefore = sqliteDatabase.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||||
|
).get() as { n: number }
|
||||||
|
await executeSchedulerJob("alert_engine")
|
||||||
|
const oldGone = sqliteDatabase.prepare(`SELECT COUNT(*) AS n FROM events WHERE id = ?`).get(oldId) as { n: number }
|
||||||
|
assert.equal(oldGone.n, 0, "events older than 30 days are purged")
|
||||||
|
const eventsAfter = sqliteDatabase.prepare(
|
||||||
|
`SELECT COUNT(*) AS n FROM events WHERE event_type = 'scheduler.job.ok' AND entity_id = 'alert_engine'`,
|
||||||
|
).get() as { n: number }
|
||||||
|
assert.equal(eventsAfter.n, eventsBefore.n, "quiet jobs do not append scheduler.job.ok")
|
||||||
|
|
||||||
|
resetEngineForTests()
|
||||||
|
console.log("sqlite-write-opt.test.ts: ok")
|
||||||
|
console.log("EXPLAIN listStoredFlowRows:", planText)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { db, sqliteDatabase } from "../db/index.js"
|
import { db, sqliteDatabase } from "../db/index.js"
|
||||||
import { appUsers, servers, userInterfaceBindings } from "../db/schema.js"
|
import { appUsers, userInterfaceBindings } from "../db/schema.js"
|
||||||
import type {
|
import type {
|
||||||
FlowAnalyticsDto,
|
FlowAnalyticsDto,
|
||||||
FlowBreakdownRow,
|
FlowBreakdownRow,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
listFlowRowsForWindow,
|
listFlowRowsForWindow,
|
||||||
type PendingFlowRow,
|
type PendingFlowRow,
|
||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import { MAX_PENDING, RING_OVERLAY } from "./traffic-flow-engine.js"
|
import { flowDataEpoch, MAX_PENDING, RING_OVERLAY } 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"
|
||||||
@@ -33,6 +33,7 @@ import { classifyFlowPlane, flowBps, shouldKeepPlane } from "./traffic-flow-plan
|
|||||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
import {
|
import {
|
||||||
enGreIfaceNames,
|
enGreIfaceNames,
|
||||||
|
getServerCatalog,
|
||||||
latestWireBps,
|
latestWireBps,
|
||||||
loadFlowTopology,
|
loadFlowTopology,
|
||||||
resolveClient,
|
resolveClient,
|
||||||
@@ -142,14 +143,46 @@ function topLabel(map: Map<string, { bytes: number; packets: number; label?: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
export function buildFlowAnalytics(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||||
|
const key = analyticsQueryKey(q)
|
||||||
|
const now = Date.now()
|
||||||
|
if (analyticsCache && analyticsCache.key === key && now - analyticsCache.at < ANALYTICS_CACHE_TTL_MS) {
|
||||||
|
return analyticsCache.dto
|
||||||
|
}
|
||||||
|
const dto = buildFlowAnalyticsUncached(q)
|
||||||
|
analyticsCache = { key, at: now, dto }
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetFlowAnalyticsCacheForTests(): void {
|
||||||
|
analyticsCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyticsQueryKey(q: FlowAnalyticsQuery): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
epoch: flowDataEpoch(),
|
||||||
|
minutes: q.minutes,
|
||||||
|
serverId: q.serverId ?? null,
|
||||||
|
userId: q.userId ?? null,
|
||||||
|
iface: q.iface ?? null,
|
||||||
|
dedup: q.dedup !== false,
|
||||||
|
excludeMesh: q.excludeMesh !== false,
|
||||||
|
excludeOverlay: q.excludeOverlay !== false,
|
||||||
|
skipHeavy: Boolean(q.skipHeavy),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANALYTICS_CACHE_TTL_MS = 2000
|
||||||
|
let analyticsCache: { key: string; at: number; dto: FlowAnalyticsDto } | null = null
|
||||||
|
|
||||||
|
function buildFlowAnalyticsUncached(q: FlowAnalyticsQuery): FlowAnalyticsDto {
|
||||||
const settings = getTrafficFlowSettingsRow()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
const top = Math.min(50, Math.max(10, settings.topN))
|
const top = Math.min(50, Math.max(10, settings.topN))
|
||||||
const windowSec = Math.max(60, q.minutes * 60)
|
const windowSec = Math.max(60, q.minutes * 60)
|
||||||
const raw = listFlowRowsForWindow(q.minutes)
|
const raw = listFlowRowsForWindow(q.minutes)
|
||||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||||
const serverRows = db.select().from(servers).all()
|
const catalog = getServerCatalog()
|
||||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
const countryById = new Map(serverRows.map((s) => [s.id, (s.country || "").toUpperCase() || "UN"]))
|
const countryById = new Map([...catalog.byId].map(([id, s]) => [id, s.country]))
|
||||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
const excludeMesh = q.excludeMesh !== false
|
const excludeMesh = q.excludeMesh !== false
|
||||||
@@ -478,19 +511,19 @@ export function listFlowExporters(minutes: number): FlowExportersDto {
|
|||||||
const { bytes, sessions } = summarizeByServer(rows)
|
const { bytes, sessions } = summarizeByServer(rows)
|
||||||
const ids = new Set<number>([...bytes.keys()])
|
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 catalog = getServerCatalog()
|
||||||
const emptySeries = Array(60).fill(0) as number[]
|
const emptySeries = Array(60).fill(0) as number[]
|
||||||
const exporters = serverRows
|
const exporters = catalog.list
|
||||||
.filter((s) => ids.has(s.id))
|
.filter((s) => ids.has(s.id))
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const ring = getRingMbps(s.id, "__all__")
|
const ring = getRingMbps(s.id, "__all__")
|
||||||
const total = bytes.get(s.id) ?? 0
|
const total = bytes.get(s.id) ?? 0
|
||||||
return {
|
return {
|
||||||
id: String(s.id),
|
id: String(s.id),
|
||||||
name: s.name || s.host,
|
name: s.name,
|
||||||
subtitle: s.host,
|
subtitle: s.host,
|
||||||
site: s.site || "—",
|
site: s.site,
|
||||||
country: s.country || "UN",
|
country: s.country,
|
||||||
status: snapshotStatus(s.id),
|
status: snapshotStatus(s.id),
|
||||||
rxNow: ring.rxNow || (total * 8) / Math.max(60, minutes * 60) / 1_000_000,
|
rxNow: ring.rxNow || (total * 8) / Math.max(60, minutes * 60) / 1_000_000,
|
||||||
txNow: ring.txNow,
|
txNow: ring.txNow,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { classifyFlowPlaneLite } from "./traffic-flow-planes.js"
|
|||||||
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
import { pickServerIdForExporter, type OverlayPeerRef } from "./traffic-flow-map-exporter.js"
|
||||||
import { applicationName } from "./traffic-flow-apps.js"
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
import { classifyFlowDst } from "./traffic-flow-classify.js"
|
||||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
import { enqueueRipeMisses, lookupRipeCached, pruneRipeSqlite } from "./traffic-flow-ripe.js"
|
||||||
|
import { invalidateTrafficFlowSettingsCache } from "./traffic-flow-settings.js"
|
||||||
import { isIsoCountry } from "./traffic-flow-brands.js"
|
import { isIsoCountry } from "./traffic-flow-brands.js"
|
||||||
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
import { maybeRefreshIfaces } from "./traffic-flow-ifaces.js"
|
||||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
@@ -94,10 +95,27 @@ let dropped = 0
|
|||||||
let rowsStored = 0
|
let rowsStored = 0
|
||||||
let lastFlushUsedTransaction = false
|
let lastFlushUsedTransaction = false
|
||||||
let lastPruneAt = 0
|
let lastPruneAt = 0
|
||||||
|
let lastPassiveCheckpointAt = Date.now()
|
||||||
|
let dataEpoch = 0
|
||||||
|
let lastPersistedStats: {
|
||||||
|
packetsReceived: number
|
||||||
|
lastDatagramAt: string | null
|
||||||
|
lastExporterIp: string | null
|
||||||
|
lastError: string
|
||||||
|
} | null = null
|
||||||
let exporterCtx: ExporterResolveCtx | null = null
|
let exporterCtx: ExporterResolveCtx | null = null
|
||||||
|
|
||||||
const PRUNE_MS = 5 * 60_000
|
const PRUNE_MS = 5 * 60_000
|
||||||
const LIVE_WINDOW_MS = 15 * 60_000
|
const LIVE_WINDOW_MS = 15 * 60_000
|
||||||
|
const PASSIVE_CHECKPOINT_MS = 60_000
|
||||||
|
|
||||||
|
function bumpDataEpoch(): void {
|
||||||
|
dataEpoch += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowDataEpoch(): number {
|
||||||
|
return dataEpoch
|
||||||
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString()
|
||||||
@@ -234,6 +252,7 @@ export function getEngineStats(): EngineStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
export function queueParsedFlows(serverId: number, flows: ParsedFlowInput[]): void {
|
||||||
|
if (flows.length) bumpDataEpoch()
|
||||||
const bucketAt = minuteBucketIso()
|
const bucketAt = minuteBucketIso()
|
||||||
const ripeMisses: string[] = []
|
const ripeMisses: string[] = []
|
||||||
for (const raw of flows) {
|
for (const raw of flows) {
|
||||||
@@ -422,7 +441,16 @@ export function applyRingSnapshot(rows: Array<{ key: string; inBps: number[]; ou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistListenerStats(handle: SqliteHandle): void {
|
function persistListenerStats(handle: SqliteHandle): boolean {
|
||||||
|
if (
|
||||||
|
lastPersistedStats
|
||||||
|
&& lastPersistedStats.packetsReceived === packetsReceived
|
||||||
|
&& lastPersistedStats.lastDatagramAt === lastDatagramAt
|
||||||
|
&& lastPersistedStats.lastExporterIp === lastExporterIp
|
||||||
|
&& lastPersistedStats.lastError === lastError
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
handle.prepare(`
|
handle.prepare(`
|
||||||
UPDATE traffic_flow_settings
|
UPDATE traffic_flow_settings
|
||||||
SET packets_received = @packetsReceived,
|
SET packets_received = @packetsReceived,
|
||||||
@@ -438,6 +466,25 @@ function persistListenerStats(handle: SqliteHandle): void {
|
|||||||
lastError,
|
lastError,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
})
|
})
|
||||||
|
lastPersistedStats = {
|
||||||
|
packetsReceived,
|
||||||
|
lastDatagramAt,
|
||||||
|
lastExporterIp,
|
||||||
|
lastError,
|
||||||
|
}
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybePassiveCheckpoint(handle: SqliteHandle): void {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastPassiveCheckpointAt < PASSIVE_CHECKPOINT_MS) return
|
||||||
|
lastPassiveCheckpointAt = now
|
||||||
|
try {
|
||||||
|
handle.pragma("wal_checkpoint(PASSIVE)")
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertMinuteAndDaily(handle: SqliteHandle): void {
|
function upsertMinuteAndDaily(handle: SqliteHandle): void {
|
||||||
@@ -586,6 +633,7 @@ function pruneStored(handle: SqliteHandle): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pruneRipeSqlite(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
function topNPending(rows: PendingFlowRow[]): PendingFlowRow[] {
|
||||||
@@ -616,6 +664,7 @@ export function flushPending(): void {
|
|||||||
persistListenerStats(handle)
|
persistListenerStats(handle)
|
||||||
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
if (pending.size === 0 && minuteRollup.size === 0 && minuteDims.size === 0) {
|
||||||
pruneStored(handle)
|
pruneStored(handle)
|
||||||
|
maybePassiveCheckpoint(handle)
|
||||||
lastFlushUsedTransaction = false
|
lastFlushUsedTransaction = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -665,6 +714,7 @@ export function flushPending(): void {
|
|||||||
tx(rows)
|
tx(rows)
|
||||||
lastFlushUsedTransaction = true
|
lastFlushUsedTransaction = true
|
||||||
rowsStored += rows.length
|
rowsStored += rows.length
|
||||||
|
bumpDataEpoch()
|
||||||
} catch {
|
} catch {
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
try {
|
try {
|
||||||
@@ -697,11 +747,7 @@ export function flushPending(): void {
|
|||||||
/* rollup best-effort */
|
/* rollup best-effort */
|
||||||
}
|
}
|
||||||
pruneStored(handle)
|
pruneStored(handle)
|
||||||
try {
|
maybePassiveCheckpoint(handle)
|
||||||
handle.pragma("wal_checkpoint(TRUNCATE)")
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lastFlushUsedTransactionForTests(): boolean {
|
export function lastFlushUsedTransactionForTests(): boolean {
|
||||||
@@ -736,6 +782,9 @@ export function resetEngineForTests(): void {
|
|||||||
rowsStored = 0
|
rowsStored = 0
|
||||||
lastFlushUsedTransaction = false
|
lastFlushUsedTransaction = false
|
||||||
lastPruneAt = 0
|
lastPruneAt = 0
|
||||||
|
lastPassiveCheckpointAt = Date.now()
|
||||||
|
lastPersistedStats = null
|
||||||
|
bumpDataEpoch()
|
||||||
pendingCap = MAX_PENDING
|
pendingCap = MAX_PENDING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import { SQLITE_BUSY_TIMEOUT_MS, sqliteDatabase } from "../db/index.js"
|
import {
|
||||||
|
SQLITE_BUSY_TIMEOUT_MS,
|
||||||
|
SQLITE_CACHE_SIZE_KIB,
|
||||||
|
SQLITE_WAL_AUTOCHECKPOINT_PAGES,
|
||||||
|
sqliteDatabase,
|
||||||
|
} from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
MAX_FLOW_LIVE_SUBSCRIBERS,
|
MAX_FLOW_LIVE_SUBSCRIBERS,
|
||||||
resetFlowLiveSlotsForTests,
|
resetFlowLiveSlotsForTests,
|
||||||
@@ -11,6 +16,16 @@ const busy = sqliteDatabase.pragma("busy_timeout") as Array<{ busy_timeout: numb
|
|||||||
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
|
const busyValue = Array.isArray(busy) ? Number(Object.values(busy[0] ?? {})[0]) : Number(busy)
|
||||||
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
|
assert.equal(busyValue, SQLITE_BUSY_TIMEOUT_MS)
|
||||||
|
|
||||||
|
function pragmaNum(name: string): number {
|
||||||
|
const rows = sqliteDatabase.pragma(name) as Array<Record<string, number>>
|
||||||
|
const row = Array.isArray(rows) ? rows[0] : rows
|
||||||
|
return Number(Object.values(row ?? {})[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(pragmaNum("wal_autocheckpoint"), SQLITE_WAL_AUTOCHECKPOINT_PAGES)
|
||||||
|
assert.equal(pragmaNum("cache_size"), -SQLITE_CACHE_SIZE_KIB)
|
||||||
|
assert.equal(pragmaNum("temp_store"), 2)
|
||||||
|
|
||||||
resetFlowLiveSlotsForTests()
|
resetFlowLiveSlotsForTests()
|
||||||
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
|
for (let i = 0; i < MAX_FLOW_LIVE_SUBSCRIBERS; i++) {
|
||||||
assert.equal(tryAcquireFlowLiveSlot(), true)
|
assert.equal(tryAcquireFlowLiveSlot(), true)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
import { applicationName } from "./traffic-flow-apps.js"
|
import { applicationName } from "./traffic-flow-apps.js"
|
||||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||||
|
import { getServerCatalog } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
export type { PendingFlowRow }
|
export type { PendingFlowRow }
|
||||||
|
|
||||||
@@ -332,8 +333,8 @@ export function listFlowTalkers(minutes = 5): FlowStatsDto {
|
|||||||
const settings = getTrafficFlowSettingsRow()
|
const settings = getTrafficFlowSettingsRow()
|
||||||
const runtime = getFlowRuntimeCounters()
|
const runtime = getFlowRuntimeCounters()
|
||||||
const rows = listFlowRowsForWindow(minutes)
|
const rows = listFlowRowsForWindow(minutes)
|
||||||
const serverRows = db.select().from(servers).all()
|
const catalog = getServerCatalog()
|
||||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
const agg = new Map<string, FlowTalkerDto & { rawBytes: number }>()
|
||||||
const protoBytes = new Map<number, number>()
|
const protoBytes = new Map<number, number>()
|
||||||
const srcs = new Set<string>()
|
const srcs = new Set<string>()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers, userInterfaceBindings } from "../db/schema.js"
|
import { userInterfaceBindings } from "../db/schema.js"
|
||||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
import {
|
import {
|
||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
@@ -15,7 +15,8 @@ import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
|||||||
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
import { pickInternetPeer } from "./traffic-flow-ip.js"
|
||||||
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
import { lookupRipeCached, type FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||||
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
import { getTrafficFlowSettingsRow } from "./traffic-flow-settings.js"
|
||||||
import { loadFlowTopology, resolveClient, resolveEn } from "./traffic-flow-topology.js"
|
import { loadFlowTopology, resolveClient, resolveEn, getServerCatalog } from "./traffic-flow-topology.js"
|
||||||
|
import { flowDataEpoch } from "./traffic-flow-engine.js"
|
||||||
|
|
||||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||||
export const MAP_SERVICE_NODE_CAP = 20
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
@@ -100,6 +101,7 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
|||||||
|
|
||||||
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
|
epoch: flowDataEpoch(),
|
||||||
minutes: q.minutes,
|
minutes: q.minutes,
|
||||||
serverId: q.serverId ?? null,
|
serverId: q.serverId ?? null,
|
||||||
userId: q.userId ?? null,
|
userId: q.userId ?? null,
|
||||||
@@ -194,8 +196,8 @@ function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number): Flo
|
|||||||
const windowSec = Math.max(60, q.minutes * 60)
|
const windowSec = Math.max(60, q.minutes * 60)
|
||||||
const raw = listFlowRowsForWindow(q.minutes)
|
const raw = listFlowRowsForWindow(q.minutes)
|
||||||
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
const allow = q.userId ? userIfaceAllow(q.userId) : null
|
||||||
const serverRows = db.select().from(servers).all()
|
const catalog = getServerCatalog()
|
||||||
const nameById = new Map(serverRows.map((s) => [s.id, s.name || s.host]))
|
const nameById = new Map([...catalog.byId].map(([id, s]) => [id, s.name]))
|
||||||
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
const ifaceFilter = q.iface && q.iface !== "__all__" ? q.iface : ""
|
||||||
const wantDedup = q.dedup !== false && !ifaceFilter
|
const wantDedup = q.dedup !== false && !ifaceFilter
|
||||||
const excludeMesh = q.excludeMesh !== false
|
const excludeMesh = q.excludeMesh !== false
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "./traffic-flow-settings.js"
|
} from "./traffic-flow-settings.js"
|
||||||
import { refreshFlowExporterMap, 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"
|
||||||
|
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
|
||||||
|
|
||||||
const IFACE_NAME = "wg-flow"
|
const IFACE_NAME = "wg-flow"
|
||||||
const JH_LISTEN_PORT = 13232
|
const JH_LISTEN_PORT = 13232
|
||||||
@@ -288,6 +289,7 @@ export async function applyFlowOverlay(
|
|||||||
mgmtTunnelIp: address,
|
mgmtTunnelIp: address,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}).where(eq(servers.id, server.id)).run()
|
}).where(eq(servers.id, server.id)).run()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
|
||||||
upsertHostPeer({
|
upsertHostPeer({
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ try {
|
|||||||
`).run(serverId)
|
`).run(serverId)
|
||||||
sqliteDatabase.prepare(`
|
sqliteDatabase.prepare(`
|
||||||
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
|
INSERT INTO flow_ip_meta (prefix, asn, country, holder, ok, fetched_at)
|
||||||
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, '2026-01-01T00:00:00.000Z')
|
VALUES ('8.8.8.0/24', 15169, 'US', 'Google', 1, ?)
|
||||||
`).run()
|
`).run(new Date().toISOString())
|
||||||
sqliteDatabase.prepare(`
|
sqliteDatabase.prepare(`
|
||||||
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
UPDATE traffic_flow_settings SET packets_received = 42, last_exporter_ip = '10.255.254.3' WHERE id = 1
|
||||||
`).run()
|
`).run()
|
||||||
|
|||||||
@@ -239,6 +239,20 @@ function persistAsn(asn: number, holder: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Удаляет просроченный RIPE-кэш с диска (hit 24h / negative 6h). */
|
||||||
|
export function pruneRipeSqlite(nowMs = Date.now()): void {
|
||||||
|
if (!persistEnabled) return
|
||||||
|
try {
|
||||||
|
const hitCutoff = new Date(nowMs - HIT_TTL_MS).toISOString()
|
||||||
|
const negCutoff = new Date(nowMs - NEG_TTL_MS).toISOString()
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok != 0 AND fetched_at < ?`).run(hitCutoff)
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_ip_meta WHERE ok = 0 AND fetched_at < ?`).run(negCutoff)
|
||||||
|
sqliteDatabase.prepare(`DELETE FROM flow_asn_meta WHERE fetched_at < ?`).run(hitCutoff)
|
||||||
|
} catch {
|
||||||
|
/* table may not exist in isolated tests */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function negative(prefix: string): FlowIpMeta {
|
function negative(prefix: string): FlowIpMeta {
|
||||||
return {
|
return {
|
||||||
prefix,
|
prefix,
|
||||||
|
|||||||
@@ -4,10 +4,42 @@ import { trafficFlowSettings } from "../db/schema.js"
|
|||||||
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
import type { FlowHostPeer, TrafficFlowSettingsDto, TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
|
||||||
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
import { generateWireGuardKeyPair } from "./wg-keys.js"
|
||||||
|
|
||||||
|
let settingsRowCache: ReturnType<typeof readSettingsRow> | null = null
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString()
|
return new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function invalidateTrafficFlowSettingsCache(): void {
|
||||||
|
settingsRowCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettingsRow() {
|
||||||
|
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrafficFlowSettingsRow() {
|
||||||
|
if (settingsRowCache) return settingsRowCache
|
||||||
|
const row = readSettingsRow()
|
||||||
|
if (row) {
|
||||||
|
settingsRowCache = row
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
const now = nowIso()
|
||||||
|
db.insert(trafficFlowSettings).values({
|
||||||
|
id: 1,
|
||||||
|
enabled: false,
|
||||||
|
collectorIp: "10.255.254.1",
|
||||||
|
flowListenPort: 4739,
|
||||||
|
wgListenPort: 51821,
|
||||||
|
prefix: "10.255.254.0/24",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}).run()
|
||||||
|
settingsRowCache = readSettingsRow()
|
||||||
|
return settingsRowCache!
|
||||||
|
}
|
||||||
|
|
||||||
function parsePeers(raw: string): FlowHostPeer[] {
|
function parsePeers(raw: string): FlowHostPeer[] {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as unknown
|
const parsed = JSON.parse(raw) as unknown
|
||||||
@@ -20,23 +52,6 @@ function parsePeers(raw: string): FlowHostPeer[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTrafficFlowSettingsRow() {
|
|
||||||
const row = db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
|
||||||
if (row) return row
|
|
||||||
const now = nowIso()
|
|
||||||
db.insert(trafficFlowSettings).values({
|
|
||||||
id: 1,
|
|
||||||
enabled: false,
|
|
||||||
collectorIp: "10.255.254.1",
|
|
||||||
flowListenPort: 4739,
|
|
||||||
wgListenPort: 51821,
|
|
||||||
prefix: "10.255.254.0/24",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}).run()
|
|
||||||
return db.select().from(trafficFlowSettings).where(eq(trafficFlowSettings.id, 1)).limit(1).all()[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toTrafficFlowSettingsDto(
|
export function toTrafficFlowSettingsDto(
|
||||||
listener: { bound: boolean; address: string | null },
|
listener: { bound: boolean; address: string | null },
|
||||||
): TrafficFlowSettingsDto {
|
): TrafficFlowSettingsDto {
|
||||||
@@ -81,6 +96,7 @@ export function updateTrafficFlowSettings(patch: TrafficFlowSettingsPatch) {
|
|||||||
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
|
: Math.min(100, Math.max(0, patch.mapServiceMinSharePct)),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
return getTrafficFlowSettingsRow()
|
return getTrafficFlowSettingsRow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +111,7 @@ export function ensureHostKeys(): { publicKey: string; created: boolean } {
|
|||||||
hostPrivateKey: keys.privateKey,
|
hostPrivateKey: keys.privateKey,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
return { publicKey: keys.publicKey, created: true }
|
return { publicKey: keys.publicKey, created: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +123,7 @@ export function upsertHostPeer(peer: FlowHostPeer) {
|
|||||||
peersJson: JSON.stringify(peers),
|
peersJson: JSON.stringify(peers),
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordFlowPacket(exporterIp: string) {
|
export function recordFlowPacket(exporterIp: string) {
|
||||||
@@ -116,6 +134,7 @@ export function recordFlowPacket(exporterIp: string) {
|
|||||||
packetsReceived: row.packetsReceived + 1,
|
packetsReceived: row.packetsReceived + 1,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordFlowListenerError(message: string) {
|
export function recordFlowListenerError(message: string) {
|
||||||
@@ -123,6 +142,7 @@ export function recordFlowListenerError(message: string) {
|
|||||||
lastError: message,
|
lastError: message,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enableTrafficFlowIngest() {
|
export function enableTrafficFlowIngest() {
|
||||||
@@ -130,6 +150,7 @@ export function enableTrafficFlowIngest() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listHostPeers(): FlowHostPeer[] {
|
export function listHostPeers(): FlowHostPeer[] {
|
||||||
@@ -144,4 +165,5 @@ export function resetFlowIngestCounters(): void {
|
|||||||
lastError: "",
|
lastError: "",
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
}).where(eq(trafficFlowSettings.id, 1)).run()
|
}).where(eq(trafficFlowSettings.id, 1)).run()
|
||||||
|
invalidateTrafficFlowSettingsCache()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,44 @@ export interface FlowTopology {
|
|||||||
plane: PlaneTopology
|
plane: PlaneTopology
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerCatalogEntry {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
country: string
|
||||||
|
host: string
|
||||||
|
type: string
|
||||||
|
site: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG_TTL_MS = 5_000
|
||||||
|
|
||||||
let seeded: FlowTopology | null = null
|
let seeded: FlowTopology | null = null
|
||||||
|
let topologyCache: { at: number; topo: FlowTopology } | null = null
|
||||||
|
let serverCatalogCache: { at: number; list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } | null = null
|
||||||
|
|
||||||
|
export function invalidateFlowCatalogCache(): void {
|
||||||
|
topologyCache = null
|
||||||
|
serverCatalogCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerCatalog(): { list: ServerCatalogEntry[]; byId: Map<number, ServerCatalogEntry> } {
|
||||||
|
const now = Date.now()
|
||||||
|
if (serverCatalogCache && now - serverCatalogCache.at < CATALOG_TTL_MS) {
|
||||||
|
return serverCatalogCache
|
||||||
|
}
|
||||||
|
const rows = db.select().from(servers).all()
|
||||||
|
const list: ServerCatalogEntry[] = rows.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || s.host,
|
||||||
|
country: (s.country || "").toUpperCase() || "UN",
|
||||||
|
host: s.host,
|
||||||
|
type: s.type,
|
||||||
|
site: s.site || "—",
|
||||||
|
}))
|
||||||
|
const byId = new Map(list.map((s) => [s.id, s]))
|
||||||
|
serverCatalogCache = { at: now, list, byId }
|
||||||
|
return serverCatalogCache
|
||||||
|
}
|
||||||
|
|
||||||
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
|
function parseWanUplinks(raw: string): Array<{ iface?: string; ip?: string }> {
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +81,8 @@ function ifaceKey(serverId: number, name: string): string {
|
|||||||
|
|
||||||
export function loadFlowTopology(): FlowTopology {
|
export function loadFlowTopology(): FlowTopology {
|
||||||
if (seeded) return seeded
|
if (seeded) return seeded
|
||||||
|
const now = Date.now()
|
||||||
|
if (topologyCache && now - topologyCache.at < CATALOG_TTL_MS) return topologyCache.topo
|
||||||
const serverRows = db.select().from(servers).all()
|
const serverRows = db.select().from(servers).all()
|
||||||
const users = db.select().from(appUsers).all()
|
const users = db.select().from(appUsers).all()
|
||||||
const binds = db.select().from(userInterfaceBindings).all()
|
const binds = db.select().from(userInterfaceBindings).all()
|
||||||
@@ -82,7 +121,7 @@ export function loadFlowTopology(): FlowTopology {
|
|||||||
for (const h of hosts) jhHosts.add(h)
|
for (const h of hosts) jhHosts.add(h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
const topo: FlowTopology = {
|
||||||
clientIfaces,
|
clientIfaces,
|
||||||
clientByIface,
|
clientByIface,
|
||||||
enNodes,
|
enNodes,
|
||||||
@@ -95,10 +134,13 @@ export function loadFlowTopology(): FlowTopology {
|
|||||||
jhHosts,
|
jhHosts,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
topologyCache = { at: Date.now(), topo }
|
||||||
|
return topo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
export function seedFlowTopologyForTests(topo: FlowTopology | null): void {
|
||||||
seeded = topo
|
seeded = topo
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveClient(
|
export function resolveClient(
|
||||||
|
|||||||
Reference in New Issue
Block a user