feat(logs): добавить раздел логов с историей и живым потоком
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s

This commit is contained in:
Denozordec committed 2026-10-16 22:13:48 +07:00
1 parent e461dc21bd
commit 5efd0339fb
37 files changed
+1399 -7

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE IF NOT EXISTS router_log_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
interval_sec INTEGER NOT NULL DEFAULT 10 CHECK (interval_sec BETWEEN 5 AND 3600),
retention_days INTEGER NOT NULL DEFAULT 14 CHECK (retention_days BETWEEN 1 AND 365)
);
INSERT INTO router_log_settings(id) VALUES (1) ON CONFLICT DO NOTHING;
-- No foreign key: collected history survives device deletion.
CREATE TABLE IF NOT EXISTS router_log_collection_state (
server_id INTEGER PRIMARY KEY, server_name TEXT NOT NULL, host TEXT NOT NULL,
generation INTEGER NOT NULL DEFAULT 0, snapshot JSONB NOT NULL DEFAULT '[]',
last_collected_at TIMESTAMPTZ, last_error TEXT, gap_at TIMESTAMPTZ,
next_poll_at TIMESTAMPTZ, failures INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS router_log_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, server_id INTEGER NOT NULL,
server_name TEXT NOT NULL, host TEXT NOT NULL, router_id TEXT NOT NULL, raw_time TEXT NOT NULL,
event_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL,
effective_at TIMESTAMPTZ NOT NULL, topics TEXT[] NOT NULL, level TEXT NOT NULL,
message TEXT NOT NULL, generation INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS router_logs_server_time ON router_log_entries(server_id, effective_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS router_logs_time ON router_log_entries(effective_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS router_logs_level_time ON router_log_entries(level, effective_at DESC);
CREATE INDEX IF NOT EXISTS router_logs_topics ON router_log_entries USING gin(topics);
CREATE INDEX IF NOT EXISTS router_logs_received ON router_log_entries(received_at);
CREATE INDEX IF NOT EXISTS router_logs_server_cursor ON router_log_entries(server_id, id);
+3 -1
View File
@@ -4,6 +4,8 @@
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
"type": "module",
"scripts": {
"test:logs": "tsx src/services/router-logs-model.test.ts && tsx src/plugins/auth.logs.test.ts && tsx src/services/mikrotik-logs.test.ts",
"test:logs:pg": "tsx src/services/router-logs-sql.test.ts",
"test:sidebar-counts": "tsx src/services/complete-count.test.ts && tsx src/routes/sidebar-counts.test.ts",
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
@@ -39,7 +41,7 @@
"test:containers": "tsx src/services/containers-live.test.ts && tsx src/services/containers-state.test.ts && tsx src/services/container-removal.test.ts && tsx src/routes/containers-state.test.ts",
"test:certificates": "tsx src/services/certificate-installation.test.ts && tsx src/services/certificate-renew-collector.test.ts && tsx src/services/certificate-issue-execution.test.ts && tsx src/services/certificate-issue-request.test.ts && tsx src/routes/certificates-issue.test.ts && tsx src/services/certificate-issue-dto.test.ts && tsx src/services/certificate-parse.test.ts && tsx src/services/certificates-state.test.ts && tsx src/routes/certificates-state.test.ts",
"test:system-database": "tsx src/services/system-database-partition-restore.test.ts && tsx src/services/system-database-cache.test.ts && tsx src/services/database-background-work.test.ts && tsx src/services/database-request-gate.test.ts && tsx src/services/traffic-flow-worker-shutdown.test.ts && tsx src/services/system-database-restore-execution.test.ts && tsx src/services/system-database-operation.test.ts && tsx src/routes/system-database.test.ts && tsx src/plugins/auth.system-database.test.ts",
"test:all": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:all": "npm run test:logs && npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:alerts-telegram && npm run test:auth && npm run test:wireguard && npm run test:ipsec && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution && npm run test:system-database && npm run test:server-privacy",
"test:server-privacy": "tsx src/routes/server-secrets-sql.test.ts"
},
"dependencies": {
+16
View File
@@ -20,6 +20,22 @@ function ts(name: string) {
return timestamp(name, { withTimezone: true, mode: "string" as const })
}
export const routerLogSettings = pgTable("router_log_settings", {
id: integer("id").primaryKey(), enabled: boolean("enabled").notNull().default(false),
intervalSec: integer("interval_sec").notNull().default(10), retentionDays: integer("retention_days").notNull().default(14),
})
export const routerLogCollectionState = pgTable("router_log_collection_state", {
serverId: integer("server_id").primaryKey(), serverName: text("server_name").notNull(), host: text("host").notNull(),
generation: integer("generation").notNull().default(0), snapshot: jsonb("snapshot").$type<string[]>().notNull().default([]),
lastCollectedAt: ts("last_collected_at"), lastError: text("last_error"), gapAt: ts("gap_at"), nextPollAt: ts("next_poll_at"), failures: integer("failures").notNull().default(0),
})
export const routerLogEntries = pgTable("router_log_entries", {
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(), serverId: integer("server_id").notNull(),
serverName: text("server_name").notNull(), host: text("host").notNull(), routerId: text("router_id").notNull(), rawTime: text("raw_time").notNull(),
eventAt: ts("event_at"), receivedAt: ts("received_at").notNull(), effectiveAt: ts("effective_at").notNull(),
topics: text("topics").array().notNull(), level: text("level").notNull(), message: text("message").notNull(), generation: integer("generation").notNull(),
}, table => [index("router_logs_server_time").on(table.serverId, table.effectiveAt, table.id), index("router_logs_time").on(table.effectiveAt, table.id), index("router_logs_level_time").on(table.level, table.effectiveAt), index("router_logs_topics").using("gin", table.topics), index("router_logs_received").on(table.receivedAt), index("router_logs_server_cursor").on(table.serverId, table.id)])
function idIdentity() {
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
}
+2
View File
@@ -37,6 +37,7 @@ import firewallRoutes from "./routes/firewall.js"
import greRoutes from "./routes/gre.js"
import usersRoutes from "./routes/users.js"
import statisticsRoutes from "./routes/statistics.js"
import logsRoutes from "./routes/logs.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
import { agentSocket } from "./services/traffic-flow-agent-client.js"
@@ -144,6 +145,7 @@ export async function buildApp(opts?: {
await app.register(greRoutes, { prefix: "/api" })
await app.register(usersRoutes, { prefix: "/api" })
await app.register(statisticsRoutes, { prefix: "/api" })
await app.register(logsRoutes, { prefix: "/api" })
if (opts?.startScheduler !== false) {
await refreshScheduler()
+2
View File
@@ -42,6 +42,8 @@ type Rule = {
}
const RULES: Rule[] = [
{ methods: ["GET", "PUT"], match: p => p === "/api/logs/settings", permission: "mm:settings:admin" },
{ methods: ["GET"], match: p => p === "/api/logs" || p.startsWith("/api/logs/"), permission: "mm:servers:read" },
{ methods: ["GET"], match: path => path === "/api/traffic/flow/host-files", permission: "mm:traffic:write" },
{ methods: ["GET"], match: path => path === "/api/geoip", permission: "mm:traffic:read" },
{ methods: ["PUT", "POST"], match: path => path === "/api/geoip" || path === "/api/geoip/update", permission: "mm:traffic:write" },
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "logs-test-secret"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.NODE_ENV = "test"
const { default: authPlugin, requireApiAuth } = await import("./auth.js")
const { default: logsRoutes } = await import("../routes/logs.js")
const app = Fastify()
await app.register(authPlugin)
app.addHook("preHandler", requireApiAuth)
await app.register(logsRoutes, { prefix: "/api" })
const token = (permission: string) => new SignJWT({ sub: "logs-test", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("logs-test-secret"))
for (const path of ["/api/logs", "/api/logs/live", "/api/logs/summary", "/api/logs/export", "/api/logs/1/context", "/api/logs/settings"]) {
assert.equal((await app.inject({ url: path })).statusCode, 401)
assert.equal((await app.inject({ url: path, headers: { authorization: "Bearer " + await token("mm:network:write") } })).statusCode, 403)
}
for (const permission of ["mm:servers:read", "mm:servers:write", "mm:servers:admin"]) {
assert.equal((await app.inject({ url: "/api/logs?levels=invalid", headers: { authorization: "Bearer " + await token(permission) } })).statusCode, 400)
assert.equal((await app.inject({ url: "/api/logs/settings", headers: { authorization: "Bearer " + await token(permission) } })).statusCode, 403)
}
assert.equal((await app.inject({ method: "PUT", url: "/api/logs/settings", payload: { retentionDays: 0 }, headers: { authorization: "Bearer " + await token("mm:settings:admin") } })).statusCode, 400)
await app.close()
console.log("PASS logs JWT: history/live/export/context require servers:read, settings require settings:admin before DB access")
+1 -1
View File
@@ -16,7 +16,7 @@ export function installDatabaseRequestGate(app: FastifyInstance): void {
if (!path.startsWith("/api/") || path === "/api/auth/config") return
const original = options.handler
const operation = path === "/api/system/database/backup" || path === "/api/system/database/restore"
const stream = path === "/api/traffic/flow/live" || /^\/api\/traffic\/servers\/:id\/live$/.test(path)
const stream = path === "/api/logs/live" || path === "/api/traffic/flow/live" || /^\/api\/traffic\/servers\/:id\/live$/.test(path)
options.handler = async function (request, reply) {
const run = async () => await original.call(this, request, reply)
if (operation) return gate.exclusive(run)
+100
View File
@@ -0,0 +1,100 @@
import type { FastifyPluginAsync } from "fastify"
import { setTimeout as sleep } from "node:timers/promises"
import { once } from "node:events"
import { logFiltersSchema, logPageQuerySchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { env } from "../config.js"
import { registerDatabaseStreamAbort } from "../plugins/database-request-gate.js"
import { getLogSettings, subscribeLogDevice, updateLogSettings } from "../services/router-logs-collector.js"
import { readLogPage, readLogSummary, readLogEntry, readLogContext, readLogStream, logColumns } from "../services/router-logs-query.js"
import { csvCell, logWhere } from "../services/router-logs-model.js"
import { refreshScheduler } from "../services/scheduler.js"
import { isSchedulerJobsPaused } from "../services/scheduler-running.js"
const logsRoutes: FastifyPluginAsync = async app => {
const streams = new Set<AbortController>()
app.addHook("preClose", async () => { for (const stream of streams) stream.abort() })
app.get("/logs", async req => {
const parsed = logPageQuerySchema.safeParse(req.query)
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
return readLogPage(parsed.data, parsed.data)
})
app.get("/logs/summary", async req => {
const parsed = logFiltersSchema.safeParse(req.query)
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
return readLogSummary(parsed.data)
})
app.get("/logs/settings", async () => getLogSettings())
app.put("/logs/settings", async (req, reply) => {
const parsed = logSettingsPatchSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: "Некорректные настройки сборщика логов" })
const settings = await updateLogSettings(parsed.data)
await refreshScheduler()
return { ok: true, settings }
})
app.get("/logs/export", async (req, reply) => {
const parsed = logFiltersSchema.safeParse(req.query)
const format = (req.query as { format?: string }).format ?? "csv"
if (!parsed.success || !["csv", "json"].includes(format)) return reply.code(400).send({ error: "Некорректный экспорт" })
const where = logWhere(parsed.data)
const rows = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT 50001`, where.values)).rows
if (rows.length > 50000) return reply.code(422).send({ error: "Найдено более 50 000 событий. Сузьте период или фильтры для экспорта." })
reply.header("Content-Disposition", `attachment; filename="router-logs.${format}"`)
if (format === "json") return reply.type("application/json; charset=utf-8").send(JSON.stringify(rows))
const columns = ["effectiveAt", "serverName", "host", "level", "topics", "message", "rawTime", "receivedAt", "timeKind"]
const csv = [columns.map(csvCell).join(","), ...rows.map(row => columns.map(key => csvCell(Array.isArray(row[key]) ? row[key].join(",") : row[key] instanceof Date ? row[key].toISOString() : row[key])).join(","))].join("\r\n")
return reply.type("text/csv; charset=utf-8").send("\uFEFF" + csv)
})
app.get("/logs/live", async (req, reply) => {
const parsed = logFiltersSchema.safeParse(req.query)
const cursor = (req.query as { cursor?: string }).cursor
if (!parsed.success || !parsed.data.serverId || !cursor || !/^\d{1,20}$/.test(cursor)) return reply.code(400).send({ error: "Выберите устройство и передайте курсор истории" })
const filters = parsed.data
const available = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
if (!available.rowCount) return reply.code(404).send({ error: "Устройство отключено или удалено" })
if (isSchedulerJobsPaused()) return reply.code(503).send({ error: "Сбор приостановлен для обслуживания БД" })
const controller = new AbortController()
streams.add(controller)
const onClose = () => controller.abort()
registerDatabaseStreamAbort(req, () => {
if (reply.raw.headersSent && !reply.raw.destroyed) reply.raw.write('event: reset\ndata: {"reason":"database-maintenance"}\n\n')
controller.abort()
})
req.raw.on("close", onClose)
const unsubscribe = subscribeLogDevice(filters.serverId!)
reply.hijack()
req.raw.setTimeout(0); reply.raw.setTimeout(0)
const headers: Record<string, string> = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" }
const origin = req.headers.origin
if (origin && (env.CORS_ORIGIN === "*" || env.CORS_ORIGIN === origin)) { headers["Access-Control-Allow-Origin"] = origin; headers["Access-Control-Allow-Credentials"] = "true"; headers.Vary = "Origin" }
reply.raw.writeHead(200, headers)
let after = cursor
try {
while (!controller.signal.aborted && !isSchedulerJobsPaused()) {
// Long connections must not outlive the JWT they were authenticated with.
if (env.authRequired && req.user.exp && Date.now() >= req.user.exp * 1000) break
const exists = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
if (!exists.rowCount) { reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Устройство отключено или удалено" })}\n\n`); break }
const batch = await readLogStream(filters, after)
after = batch.cursor
if (!reply.raw.write(`event: batch\nid: ${after}\ndata: ${JSON.stringify(batch)}\n\n`)) await once(reply.raw, "drain", { signal: controller.signal })
if (batch.entries.length < 200) await sleep(1000, undefined, { signal: controller.signal })
}
} catch (error) {
if (!controller.signal.aborted && !reply.raw.destroyed) reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Поток прерван; выполняется переподключение" })}\n\n`)
req.log.debug({ err: error }, "Logs SSE closed")
} finally { streams.delete(controller); unsubscribe(); req.raw.off("close", onClose); reply.raw.end() }
})
app.get("/logs/:id", async (req, reply) => {
const id = String((req.params as { id: string }).id)
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
const entry = await readLogEntry(id)
return entry ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
})
app.get("/logs/:id/context", async (req, reply) => {
const id = String((req.params as { id: string }).id)
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
return await readLogContext(id) ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
})
}
export default logsRoutes
+9 -1
View File
@@ -715,13 +715,21 @@ export class MikrotikClient {
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
}
async getLogBuffer(): Promise<RosLogEntry[]> {
const entries = await this.get<unknown>("/log?buffer=memory&.proplist=.id,time,topics,message", 8_000)
if (!Array.isArray(entries) || entries.some(e => !e || [".id", "time", "topics", "message"].some(k => typeof e[k] !== "string"))) {
throw new Error("RouterOS: некорректный буфер логов")
}
return entries as RosLogEntry[]
}
async getLogs(limit = 50): Promise<RosLogEntry[]> {
if (!Number.isSafeInteger(limit) || limit < 1) {
throw new RangeError("limit должен быть положительным целым числом.")
}
// REST query parameters filter properties; limit is not a print row limit.
// Read only the default buffer and retain its latest entries in native order.
const entries = await this.get<RosLogEntry[]>("/log?buffer=memory&.proplist=.id,time,topics,message")
const entries = await this.getLogBuffer()
return entries.slice(-Math.min(limit, 200))
}
@@ -0,0 +1,128 @@
import type { LogSettings } from "@mmapp/contracts"
import { pool, dbAll } from "../db/index.js"
import type { Server } from "../db/schema.js"
import { listEnabledMonitoringServers } from "./monitoring-server-catalog.js"
import { MikrotikClient } from "./mikrotik.js"
import { logLevel, logSignature, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
import { endSchedulerJob, isSchedulerJobsPaused, tryBeginSchedulerJob } from "./scheduler-running.js"
import type { RouterLogsRunSnapshot } from "../types/scheduler-run-snapshot.js"
export async function getLogSettings(): Promise<LogSettings> {
const { rows } = await pool.query<LogSettings>(`SELECT enabled, interval_sec AS "intervalSec", retention_days AS "retentionDays" FROM router_log_settings WHERE id=1`)
if (!rows[0]) throw new Error("Настройки логов не найдены; проверьте миграцию БД")
return rows[0]
}
export async function updateLogSettings(patch: Partial<LogSettings>) {
await pool.query(`UPDATE router_log_settings SET enabled=COALESCE($1,enabled), interval_sec=COALESCE($2,interval_sec), retention_days=COALESCE($3,retention_days) WHERE id=1`, [patch.enabled, patch.intervalSec, patch.retentionDays])
return getLogSettings()
}
const liveDevices = new Map<number, number>()
export function subscribeLogDevice(serverId: number) {
liveDevices.set(serverId, (liveDevices.get(serverId) ?? 0) + 1)
let released = false
return () => { if (released) return; released = true; const n = (liveDevices.get(serverId) ?? 1) - 1; if (n) liveDevices.set(serverId, n); else liveDevices.delete(serverId) }
}
const inflight = new Map<number, Promise<{ added: number; gap: boolean; error?: string }>>()
let active = 0
const waiters: (() => void)[] = []
async function slot() { if (active >= 4) await new Promise<void>(resolve => waiters.push(resolve)); else active++ }
function release() { const next = waiters.shift(); if (next) next(); else active-- }
async function collectDevice(server: Server, settings: LogSettings, force: boolean) {
await slot()
const client = await pool.connect().catch(error => { release(); throw error })
try {
await client.query("BEGIN")
const lock = await client.query<{ locked: boolean }>("SELECT pg_try_advisory_xact_lock(872314,$1) AS locked", [server.id])
if (!lock.rows[0].locked || isSchedulerJobsPaused()) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
const admission = await client.query<{ enabled: boolean }>("SELECT enabled FROM router_log_settings WHERE id=1")
if (!admission.rows[0]?.enabled) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
await client.query(`INSERT INTO router_log_collection_state(server_id,server_name,host) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, [server.id, server.name, server.host])
const prev = (await client.query<{ snapshot: string[]; generation: number; last_collected_at: Date | null; next_poll_at: Date | null; failures: number }>("SELECT * FROM router_log_collection_state WHERE server_id=$1 FOR UPDATE", [server.id])).rows[0]
const now = Date.now()
const interval = liveDevices.has(server.id) ? 2000 : settings.intervalSec * 1000
const backoff = prev.failures ? Math.min(60_000, interval * 2 ** Math.min(prev.failures, 5)) : interval
if (!force && (prev.next_poll_at && prev.next_poll_at.getTime() > now || prev.last_collected_at && now - prev.last_collected_at.getTime() < interval)) {
await client.query("COMMIT"); return { added: 0, gap: false }
}
let rows
try { rows = await MikrotikClient.fromServer(server).getLogBuffer() }
catch (error) {
const message = (error instanceof Error ? error.message : String(error)).slice(0, 1024)
await client.query(`UPDATE router_log_collection_state SET last_error=$2, failures=failures+1, next_poll_at=$3,server_name=$4,host=$5 WHERE server_id=$1`, [server.id, message, new Date(now + backoff).toISOString(), server.name, server.host])
await client.query("COMMIT")
return { added: 0, gap: false, error: message }
}
const current = await client.query("SELECT id FROM servers WHERE id=$1 AND enabled=true AND updated_at=$2", [server.id, server.updatedAt])
if (!current.rowCount) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
const snapshot = rows.map(logSignature)
const { offset, gap } = reconcileLogSnapshot(prev.snapshot, snapshot)
const generation = prev.generation + (gap ? 1 : 0)
const receivedAt = new Date().toISOString()
const newRows = rows.slice(offset).map(row => {
const topics = row.topics.split(",").map(t => t.trim()).filter(Boolean)
const eventAt = parseLogTime(row.time)
return { server_id: server.id, server_name: server.name, host: server.host, router_id: row[".id"], raw_time: row.time, event_at: eventAt, received_at: receivedAt, effective_at: eventAt ?? receivedAt, topics, level: logLevel(topics), message: row.message, generation }
})
if (newRows.length) await client.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation)
SELECT server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation
FROM jsonb_to_recordset($1::jsonb) AS x(server_id int,server_name text,host text,router_id text,raw_time text,event_at timestamptz,received_at timestamptz,effective_at timestamptz,topics text[],level text,message text,generation int)`, [JSON.stringify(newRows)])
await client.query(`UPDATE router_log_collection_state SET snapshot=$2::jsonb,generation=$3,last_collected_at=$4,last_error=NULL,failures=0,next_poll_at=NULL,gap_at=CASE WHEN $5 THEN $4::timestamptz ELSE gap_at END,server_name=$6,host=$7 WHERE server_id=$1`, [server.id, JSON.stringify(snapshot), generation, receivedAt, gap, server.name, server.host])
await client.query("COMMIT")
return { added: newRows.length, gap }
} catch (error) { await client.query("ROLLBACK"); throw error }
finally { client.release(); release() }
}
function collectShared(server: Server, settings: LogSettings, force = false) {
const pending = inflight.get(server.id)
if (pending) return pending
const task = collectDevice(server, settings, force)
inflight.set(server.id, task)
void task.then(() => inflight.delete(server.id), () => inflight.delete(server.id))
return task
}
export async function collectRouterLogsOnce(): Promise<RouterLogsRunSnapshot> {
const settings = await getLogSettings()
const result: RouterLogsRunSnapshot = { v: 1, job: "router_logs", sampledAt: new Date().toISOString(), servers: [], errors: [] }
if (!settings.enabled || isSchedulerJobsPaused()) return { ...result, skipped: true }
const servers = await listEnabledMonitoringServers()
result.servers = await Promise.all(servers.map(async server => {
try { return { serverId: server.id, name: server.name, ...await collectShared(server, settings) } }
catch (error) { return { serverId: server.id, name: server.name, added: 0, gap: false, error: error instanceof Error ? error.message : String(error) } }
}))
result.errors = result.servers.flatMap(row => row.error ? [`${row.name}: ${row.error}`] : [])
await pruneRouterLogHistory(settings.retentionDays)
return result
}
let timer: ReturnType<typeof setInterval> | undefined
let retentionTimer: ReturnType<typeof setInterval> | undefined
export async function pruneRouterLogHistory(retentionDays: number) {
await pool.query(`DELETE FROM router_log_entries WHERE id IN (SELECT id FROM router_log_entries WHERE received_at < now() - ($1 * interval '1 day') ORDER BY received_at LIMIT 10000)`, [retentionDays])
}
export function stopLogsLiveTimer() { if (timer) clearInterval(timer); if (retentionTimer) clearInterval(retentionTimer); timer = undefined; retentionTimer = undefined }
export function startLogsLiveTimer() {
stopLogsLiveTimer()
// Retention continues even when collection is disabled, and drains before DB maintenance.
const prune = () => {
if (!tryBeginSchedulerJob("router_logs_retention")) return
void getLogSettings().then(settings => pruneRouterLogHistory(settings.retentionDays)).catch(() => {}).finally(() => endSchedulerJob("router_logs_retention"))
}
retentionTimer = setInterval(prune, 60_000); retentionTimer.unref()
timer = setInterval(() => {
if (!liveDevices.size || !tryBeginSchedulerJob("router_logs_live")) return
void (async () => {
try {
const settings = await getLogSettings()
if (!settings.enabled) return
const servers = (await listEnabledMonitoringServers()).filter(s => liveDevices.has(s.id))
await Promise.allSettled(servers.map(server => collectShared(server, settings)))
} finally { endSchedulerJob("router_logs_live") }
})().catch(() => {})
}, 1000)
timer.unref()
}
export const logStateSql = `SELECT server_id AS "serverId", server_name AS "serverName", host, generation, last_collected_at AS "lastCollectedAt", last_error AS "lastError", gap_at AS "gapAt", next_poll_at AS "nextPollAt", failures FROM router_log_collection_state`
export async function getLogStates(serverId?: number) { return dbAll(logStateSql + (serverId ? " WHERE server_id=$1" : " ORDER BY server_name"), serverId ? [serverId] : []) }
@@ -0,0 +1,46 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
import { csvCell, logLevel, logSignature, logWhere, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
import { permissionForRequest } from "../lib/permissions.js"
test("buffer overlap survives rotation; real repeated messages and reused IDs remain events", () => {
const row = (id: string, message = "same message", time = "08:00:00") => ({ ".id": id, time, message, topics: "system,info" })
const a = row("*A"), b = row("*B"), c = row("*C"), d = row("*D")
const sig = (...rows: ReturnType<typeof row>[]) => rows.map(logSignature)
assert.deepEqual(reconcileLogSnapshot([], sig(a, b)), { offset: 0, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b, c), sig(b, c, d)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b, c)), { offset: 2, gap: false })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(row("*A", "new boot"))), { offset: 0, gap: true })
assert.deepEqual(reconcileLogSnapshot(sig(a, b), []), { offset: 0, gap: true })
assert.deepEqual(reconcileLogSnapshot(sig(a), sig(row("*A", "same message", "08:00:01"))), { offset: 0, gap: true })
const previous = Array.from({ length: 100_000 }, (_, i) => String(i))
assert.equal(reconcileLogSnapshot(previous, [...previous.slice(100), "next"]).offset, 99900)
})
test("severity and time never fabricate RouterOS facts", () => {
assert.equal(logLevel(["system", "error", "critical"]), "critical")
assert.equal(logLevel(["firewall"]), "unknown")
for (const time of ["08:00:00", "oct/16 08:00:00", "2026-10-16 08:00:00", "bad"]) assert.equal(parseLogTime(time), null)
assert.equal(parseLogTime("2026-10-16T08:00:00+07:00"), "2026-10-16T01:00:00.000Z")
assert.equal(parseLogTime("2026-02-30T08:00:00Z"), null)
})
test("filters compile parameterized literal search with OR inside fields", () => {
const filters = logFiltersSchema.parse({ serverId: "1", levels: "critical,error", topics: "system,firewall", q: "%' OR true --" })
const query = logWhere(filters)
assert.ok(query.sql.includes("level = ANY")); assert.ok(query.sql.includes("topics &&"))
assert.ok(!query.sql.includes(filters.q)); assert.ok(query.values.includes(filters.q))
assert.equal(logFiltersSchema.safeParse({ levels: "panic" }).success, false)
assert.equal(logFiltersSchema.safeParse({ from: "2026-10-17T00:00:00Z", to: "2026-10-16T00:00:00Z" }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ enabled: true, retentionDays: 0 }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ intervalSec: 1 }).success, false)
assert.equal(logSettingsPatchSchema.safeParse({ unknown: true }).success, false)
})
test("log endpoints use existing read and admin grants", () => {
for (const path of ["/api/logs", "/api/logs/live", "/api/logs/summary", "/api/logs/export", "/api/logs/12/context"]) assert.equal(permissionForRequest("GET", path), "mm:servers:read")
for (const method of ["GET", "PUT"]) assert.equal(permissionForRequest(method, "/api/logs/settings"), "mm:settings:admin")
})
test("CSV escapes quotes, multiline text and spreadsheet formulas", () => {
assert.equal(csvCell('hello,"router"\nline'), '"hello,""router""\nline"')
assert.equal(csvCell("=HYPERLINK(1)"), '"\'=HYPERLINK(1)"')
})
+53
View File
@@ -0,0 +1,53 @@
import type { LogFilters, LogLevel } from "@mmapp/contracts"
import type { RosLogEntry } from "../types/server.js"
import { createHash } from "node:crypto"
export function logLevel(topics: string[]): LogLevel {
return (["critical", "error", "warning", "info", "debug"] as const).find(level => topics.includes(level)) ?? "unknown"
}
export function logSignature(entry: RosLogEntry): string {
return createHash("sha256").update(JSON.stringify([entry[".id"], entry.time, entry.topics, entry.message])).digest("hex")
}
/** Longest suffix/prefix overlap, linear in buffer size. Signatures include RouterOS identity. */
export function reconcileLogSnapshot(previous: string[], current: string[]) {
if (!previous.length) return { offset: 0, gap: false }
if (!current.length) return { offset: 0, gap: true }
const combined: (string | null)[] = [...current, null, ...previous]
const pi = new Array<number>(combined.length).fill(0)
for (let i = 1; i < combined.length; i++) {
let j = pi[i - 1]
while (j && combined[i] !== combined[j]) j = pi[j - 1]
if (combined[i] === combined[j]) j++
pi[i] = j
}
const offset = pi[pi.length - 1]
return { offset, gap: offset === 0 }
}
export function parseLogTime(raw: string): string | null {
// RouterOS timestamps without an explicit offset cannot be interpreted as UTC safely.
if (!/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(raw)) return null
const date = raw.slice(0, 10), calendar = new Date(date + "T00:00:00Z")
if (!Number.isFinite(calendar.getTime()) || calendar.toISOString().slice(0, 10) !== date || Number(raw.slice(11, 13)) > 23) return null
const ms = Date.parse(raw.replace(" ", "T"))
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
}
export function logWhere(filters: LogFilters, extra: string[] = [], initial: unknown[] = []) {
const values = [...initial], clauses = [...extra]
const bind = (value: unknown) => { values.push(value); return `$${values.length}` }
if (filters.serverId) clauses.push(`server_id = ${bind(filters.serverId)}`)
if (filters.from) clauses.push(`effective_at >= ${bind(filters.from)}::timestamptz`)
if (filters.to) clauses.push(`effective_at <= ${bind(filters.to)}::timestamptz`)
if (filters.levels) clauses.push(`level = ANY(${bind(filters.levels.split(","))}::text[])`)
if (filters.topics) clauses.push(`topics && ${bind(filters.topics.split(",").map(t => t.trim()).filter(Boolean))}::text[]`)
if (filters.q) clauses.push(`strpos(lower(message), lower(${bind(filters.q)})) > 0`)
if (filters.preset === "login-failure") clauses.push(`(lower(message) LIKE '%login failure%' OR lower(message) LIKE '%login failed%' OR lower(message) LIKE '%authentication failed%')`)
if (filters.preset === "interfaces") clauses.push(`topics && ARRAY['interface','bridge','ethernet','wireless','wifi']::text[]`)
if (filters.preset === "routing") clauses.push(`topics && ARRAY['route','routing','bgp','ospf','bfd']::text[]`)
if (filters.preset === "firewall") clauses.push(`'firewall' = ANY(topics)`)
return { sql: clauses.length ? clauses.join(" AND ") : "TRUE", values }
}
export function csvCell(value: unknown): string {
let text = String(value ?? "")
if (/^(?:\s*[=+\-@]|[\t\r])/.test(text)) text = "'" + text
return '"' + text.replaceAll('"', '""') + '"'
}
+68
View File
@@ -0,0 +1,68 @@
import type { PoolClient } from "pg"
import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { getLogSettings, getLogStates } from "./router-logs-collector.js"
import { logWhere } from "./router-logs-model.js"
export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation`
export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") }
export function decodeLogCursor(cursor: string): [string, string] {
try {
const value = JSON.parse(Buffer.from(cursor, "base64url").toString())
if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && Number.isFinite(Date.parse(value[0])) && /^\d+$/.test(value[1])) return value as [string, string]
} catch { /* invalid cursor */ }
throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 })
}
export async function logWatermark(client: PoolClient, serverId?: number) {
const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${serverId ? " WHERE server_id=$1" : ""}`, serverId ? [serverId] : [])
return res.rows[0].id
}
export async function readLogPage(filters: LogFilters, options: { cursor?: string; watermark?: string; limit: number }) {
const client = await pool.connect()
try {
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
const watermark = options.watermark ?? await logWatermark(client, filters.serverId)
const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark]
if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) }
const where = logWhere(filters, conditions, values)
const rows = (await client.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT $${where.values.length + 1}`, [...where.values, options.limit + 1])).rows
await client.query("COMMIT")
const entries = rows.slice(0, options.limit)
return { entries, nextCursor: rows.length > options.limit ? encodeLogCursor(entries[entries.length - 1]) : null, watermark }
} catch (error) { await client.query("ROLLBACK"); throw error } finally { client.release() }
}
export async function readLogSummary(filters: LogFilters) {
const where = logWhere(filters)
const from = filters.from ? Date.parse(filters.from) : Date.now() - 900_000
const to = filters.to ? Date.parse(filters.to) : Date.now()
const bucketSec = Math.max(1, Math.ceil((to - from) / 1000 / 60))
const { rows } = await pool.query<{ at: Date; level: string; count: number }>(`SELECT to_timestamp(floor(extract(epoch FROM effective_at)/$${where.values.length + 1})*$${where.values.length + 1}) AS at,level,count(*)::int AS count FROM router_log_entries WHERE ${where.sql} GROUP BY 1,2 ORDER BY 1`, [...where.values, bucketSec])
const levels = Object.fromEntries(logLevels.map(level => [level, 0])) as Record<typeof logLevels[number], number>
const buckets = new Map<string, { at: string } & typeof levels>()
for (const row of rows) {
const at = row.at.toISOString()
const level = row.level as typeof logLevels[number]
levels[level] += row.count
if (!buckets.has(at)) buckets.set(at, { at, ...Object.fromEntries(logLevels.map(l => [l, 0])) } as { at: string } & typeof levels)
buckets.get(at)![level] += row.count
}
return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId) }
}
export async function readLogEntry(id: string) {
return (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1`, [id])).rows[0] ?? null
}
export async function readLogContext(id: string) {
const entry = await readLogEntry(id)
if (!entry) return null
const before = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
return { entry, before: before.reverse(), after }
}
export async function readLogStream(filters: LogFilters, cursor: string) {
// A committed upper bound is safe per device: writers are serialized by its advisory lock.
// Advance across non-matching events too, so a quiet filtered stream never rescans its backlog.
const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor])
const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark])
const entries = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY id LIMIT 200`, where.values)).rows
return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled }
}
@@ -0,0 +1,108 @@
/** Integration/performance tests use a fresh, disposable schema, never application tables. */
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import { createServer } from "node:http"
import { randomBytes } from "node:crypto"
import pg from "pg"
import { logEntrySchema, logFiltersSchema, logPageSchema, logSummarySchema } from "@mmapp/contracts"
import { pool } from "../db/index.js"
import { env } from "../config.js"
import { collectRouterLogsOnce, getLogStates, subscribeLogDevice, updateLogSettings } from "./router-logs-collector.js"
import { readLogContext, readLogPage, readLogStream, readLogSummary } from "./router-logs-query.js"
const schema = "test_router_logs_" + randomBytes(6).toString("hex")
const admin = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 3000 })
try { await admin.query("SELECT 1") } catch { await admin.end(); console.error("PostgreSQL недоступен; SQL/live/performance проверки не выполнены"); process.exit(1) }
if (!/^[a-z_0-9]+$/.test(schema)) throw new Error("Unsafe test schema")
await admin.query(`CREATE SCHEMA ${schema}`)
pool.options.options = `-c search_path=${schema}`
let entries = Array.from({ length: 301 }, (_, i) => ({ ".id": `*${i}`, time: "12:00:00", topics: "system,info", message: "entry " + i }))
let requests = 0, denied = false
const router = createServer((req, res) => {
requests++
const url = new URL(req.url!, "http://127.0.0.1")
assert.equal(url.pathname, "/rest/log")
assert.equal(url.searchParams.get("buffer"), "memory")
res.writeHead(denied ? 401 : 200, { "content-type": "application/json" }); res.end(JSON.stringify(denied ? { error: "denied" } : entries))
})
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
const address = router.address()
assert.ok(address && typeof address !== "string")
try {
const initial = await readFile(new URL("../../drizzle/0000_postgresql.sql", import.meta.url), "utf8")
const serverSql = initial.match(/CREATE TABLE IF NOT EXISTS servers \([\s\S]*?\n\);/)![0]
await pool.query(serverSql)
await pool.query(await readFile(new URL("../../drizzle/0014_router_logs.sql", import.meta.url), "utf8"))
const { rows: [server] } = await pool.query<{ id: number }>(`INSERT INTO servers(name,host,port,use_ssl) VALUES('logs-fixture','127.0.0.1',$1,false) RETURNING id`, [address.port])
const serverId = Number(server.id)
await updateLogSettings({ enabled: true, intervalSec: 5 })
const filter = logFiltersSchema.parse({ serverId })
await collectRouterLogsOnce()
let page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(filter, { limit: 200 }))))
assert.equal(page.entries.length, 200); assert.ok(page.nextCursor)
const older = await readLogPage(filter, { limit: 200, cursor: page.nextCursor!, watermark: page.watermark })
assert.equal(older.entries.length, 101)
assert.equal(new Set([...older.entries, ...page.entries].map(e => e.id)).size, 301)
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.equal((await readLogSummary(filter)).total, 301, "Repeated snapshot is not reimported")
entries = [...entries.slice(5), { ".id": "*302", time: "12:00:01", topics: "system,error,critical", message: "login failure" }]
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
// Concurrent clients and background collection must issue one read, not duplicate writes.
const beforeRequests = requests
const a = subscribeLogDevice(serverId), b = subscribeLogDevice(serverId)
await Promise.all([collectRouterLogsOnce(), collectRouterLogsOnce(), collectRouterLogsOnce()])
a(); b()
assert.equal(requests - beforeRequests, 1)
const stream = await readLogStream(filter, page.watermark)
assert.equal(stream.entries.length, 1)
const unmatched = await readLogStream(logFiltersSchema.parse({ serverId, q: "not-present-in-buffer" }), page.watermark)
assert.equal(unmatched.entries.length, 0)
assert.equal(unmatched.cursor, stream.cursor, "Filtered stream advances across non-matching entries")
const critical = logEntrySchema.parse(JSON.parse(JSON.stringify(stream.entries[0])))
assert.equal(critical.level, "critical"); assert.equal(critical.timeKind, "receipt")
const context = await readLogContext(critical.id)
assert.equal(context?.before.length, 10)
const filtered = logFiltersSchema.parse({ serverId, levels: "critical,error", q: "LOGIN" })
assert.equal((await readLogPage(filtered, { limit: 200 })).entries.length, 1)
const summary = logSummarySchema.parse(JSON.parse(JSON.stringify(await readLogSummary(filtered))))
assert.equal(summary.total, 1); assert.equal(summary.levels.critical, 1)
entries = [{ ".id": "*1", time: "01:00:00", topics: "system,warning", message: "reboot" }]
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.equal((await getLogStates(serverId))[0].generation, 1)
assert.ok((await getLogStates(serverId))[0].gapAt)
denied = true
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
await collectRouterLogsOnce()
assert.ok((await getLogStates(serverId))[0].lastError)
const deniedRequests = requests
await collectRouterLogsOnce()
assert.equal(requests, deniedRequests, "Error backoff prevents repeated logins")
denied = false
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute',next_poll_at=NULL")
await collectRouterLogsOnce()
assert.equal((await getLogStates(serverId))[0].lastError, null)
// Receipt-based retention, including events whose device timestamp is wrong.
await pool.query(`UPDATE router_log_entries SET received_at=now()-interval '15 days' WHERE id=$1`, [critical.id])
await collectRouterLogsOnce()
assert.equal((await pool.query("SELECT id FROM router_log_entries WHERE id=$1", [critical.id])).rowCount, 0)
await pool.query("DELETE FROM servers WHERE id=$1", [serverId])
assert.ok((await readLogSummary(filter)).total > 0, "Deleting device preserves collected history")
const count = process.env.LOGS_PERFORMANCE === "1" ? 1_000_000 : 5000
const started = performance.now()
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
SELECT 999,'perf','192.0.2.1',i::text,'12:00:00',now(),now()-(i * interval '0.1 seconds'),ARRAY['system','info'],'info','performance event '||i,0 FROM generate_series(1,$1::int) AS i`, [count])
const performanceFilters = logFiltersSchema.parse({ serverId: 999, from: new Date(Date.now() - 14 * 86400000).toISOString() })
const readStarted = performance.now()
page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(performanceFilters, { limit: 200 }))))
assert.equal(page.entries.length, 200)
const performanceSummary = await readLogSummary(performanceFilters)
assert.equal(performanceSummary.total, count)
console.log(`PASS SQL: snapshot, rotation, real repeats, concurrency, restart-safe state, cursor/SSE, filters, context, retention, deletion; ${count} events inserted in ${Math.round(readStarted - started)}ms; history+summary ${Math.round(performance.now() - readStarted)}ms`)
} finally {
await new Promise<void>(resolve => router.close(() => resolve()))
await pool.end()
await admin.query(`DROP SCHEMA ${schema} CASCADE`)
await admin.end()
}
+17
View File
@@ -49,6 +49,7 @@ import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
import { getBackupScheduleSettings } from "./backup-service.js"
import { collectGeoipUpdateOnce } from "./geoip-update-collector.js"
import { getGeoipSettings } from "./geoip-settings.js"
import { collectRouterLogsOnce, getLogSettings, startLogsLiveTimer, stopLogsLiveTimer } from "./router-logs-collector.js"
import {
endSchedulerJob,
isSchedulerJobRunning,
@@ -61,6 +62,7 @@ import { appendEvent } from "../modules/events/service/events-service.js"
import { runRecordedSchedulerJob, type SchedulerRunReceipt } from "./scheduler-recorded-run.js"
export const JOB_KEYS = [
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -81,6 +83,7 @@ let schedulerGeneration = 0
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -130,6 +133,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<SchedulerRu
collect: async () => {
let snapshot: SchedulerRunSnapshot | undefined
switch (jobKey) {
case "router_logs":
snapshot = await collectRouterLogsOnce()
break
case "traffic":
snapshot = await collectTrafficOnce()
break
@@ -221,6 +227,7 @@ export async function runSchedulerJobNow(jobKey: string): Promise<SchedulerRunRe
}
function clearAllTimers() {
stopLogsLiveTimer()
clearAlertCollectorHooksTimer()
for (const t of timers.values()) clearInterval(t)
timers.clear()
@@ -233,6 +240,14 @@ export async function refreshScheduler(): Promise<void> {
const superseded = () => generation !== schedulerGeneration || isSchedulerJobsPaused()
clearAllTimers()
const logs = await getLogSettings()
if (superseded()) return
startLogsLiveTimer()
if (logs.enabled) {
void executeSchedulerJob("router_logs")
timers.set("router_logs", setInterval(() => { void executeSchedulerJob("router_logs") }, logs.intervalSec * 1000))
}
const traffic = await getTrafficSettings()
if (superseded()) return
if (traffic.enabled) {
@@ -398,6 +413,7 @@ async function lastRunForJob(jobKey: string) {
}
export async function getSchedulerStatus() {
const logs = await getLogSettings()
const traffic = await getTrafficSettings()
const uptime = await getUptimeSettings()
const apiPing = await getServersApiPingSettings()
@@ -411,6 +427,7 @@ export async function getSchedulerStatus() {
const spdOn = uptime.speedEnabled ?? uptime.enabled
const jobMeta: Record<SchedulerJobKey, { enabled: boolean; intervalSec: number }> = {
router_logs: { enabled: logs.enabled, intervalSec: logs.intervalSec },
traffic: { enabled: traffic.enabled, intervalSec: traffic.intervalSec },
servers_rest_ping: { enabled: apiPing.enabled, intervalSec: apiPing.intervalSec },
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
@@ -245,7 +245,17 @@ export interface GeoipUpdateRunSnapshot {
errors: string[]
}
export interface RouterLogsRunSnapshot {
v: 1
job: "router_logs"
sampledAt: string
skipped?: boolean
errors: string[]
servers: { serverId: number; name: string; added: number; gap: boolean; error?: string }[]
}
export type SchedulerRunSnapshot =
| RouterLogsRunSnapshot
| TrafficRunSnapshot
| ResourcesRunSnapshot
| PingRunSnapshot