Compare commits

..
Author SHA1 Message Date
Denozordec 5efd0339fb 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
2026-10-16 22:13:48 +07:00
37 changed files with 1399 additions and 7 deletions

No files matched your search

+4 -1
View File
@@ -58,6 +58,7 @@ import {
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -84,6 +85,7 @@ function fmtMs(ms: number): string {
}
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
@@ -759,7 +761,7 @@ function DataCollectionContent() {
return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
const enabled = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
@@ -959,6 +961,7 @@ function DataCollectionContent() {
</Alert>
)}
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
{isLive && (
<>
<KpiStatGrid
+2
View File
@@ -0,0 +1,2 @@
import { LogsWorkspace } from "@/components/logs/logs-workspace"
export default function LogsPage() { return <LogsWorkspace /> }
+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
+53
View File
@@ -0,0 +1,53 @@
"use client"
// Adapted from ReUI PRO sheet-14: event header, facts, tabs and stepping context.
// https://reui.io/preview/base/sheet-14
import { useEffect, useState } from "react"
import Link from "next/link"
import { z } from "zod"
import type { LogEntry } from "@mmapp/contracts"
import { toast } from "sonner"
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetHeader } from "@/components/ui/sheet"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { getLogContext } from "@/shared/api/logs"
import { LevelBadge, formatLogTime } from "./log-presentation"
export function LogDetailSheet({ entry, base, onClose, onSelect }: { entry: LogEntry | null; base: string; onClose: () => void; onSelect: (entry: LogEntry) => void }) {
return <Sheet open={!!entry} onOpenChange={open => { if (!open) onClose() }}><SheetContent className="data-[side=right]:w-full data-[side=right]:sm:max-w-2xl overflow-y-auto">
{entry && <LogDetail key={entry.id} entry={entry} base={base} onSelect={onSelect} />}
</SheetContent></Sheet>
}
function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) {
const [context, setContext] = useState<Awaited<ReturnType<typeof getLogContext>> | null>(null), [error, setError] = useState<string | null>(null), [retry, setRetry] = useState(0)
useEffect(() => {
const ac = new AbortController()
void getLogContext(base, entry.id, ac.signal).then(data => { if (!ac.signal.aborted) { setContext(data); setError(null) } }).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный ответ контекста" : e.message) })
return () => ac.abort()
}, [base, entry.id, retry])
const copy = () => { void navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => toast.success("Событие скопировано"), () => toast.error("Не удалось скопировать событие")) }
return <>
<SheetHeader className="gap-3 border-b">
<div className="flex flex-wrap items-center gap-2"><LevelBadge level={entry.level} /><span className="text-xs text-muted-foreground font-mono">{entry.routerId}</span></div>
<SheetTitle>Событие RouterOS</SheetTitle><SheetDescription>{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)}</SheetDescription>
<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" onClick={copy}><CopyIcon />Копировать</Button><Button size="sm" variant="outline" nativeButton={false} render={<Link href={`/servers?server=${entry.serverId}`} />}><ExternalLinkIcon />Устройство</Button></div>
</SheetHeader>
<Tabs defaultValue="details" className="min-w-0 px-4 pb-4">
<TabsList variant="line"><TabsTrigger value="details">Событие</TabsTrigger><TabsTrigger value="raw">Исходные поля</TabsTrigger><TabsTrigger value="context">Контекст</TabsTrigger></TabsList>
<TabsContent value="details" className="flex flex-col gap-4">
<p className="whitespace-pre-wrap break-words font-mono text-sm">{entry.message}</p>
<dl className="grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
{[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => <FragmentPair key={label} label={label} value={value} />)}
</dl>
</TabsContent>
<TabsContent value="raw"><pre className="overflow-auto whitespace-pre-wrap break-all text-xs font-mono">{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}</pre></TabsContent>
<TabsContent value="context" className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.</p>
{error ? <Alert variant="destructive"><AlertTitle>Контекст не получен</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !context ? <Skeleton className="h-32 w-full" /> : [context.before, [context.entry], context.after].flat().map(row => <Button key={row.id} variant="ghost" className="h-auto justify-start whitespace-normal text-left" onClick={() => onSelect(row)} aria-current={row.id === entry.id ? "true" : undefined}><span className="flex min-w-0 flex-col gap-1"><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.effectiveAt)} · {row.topics.join(", ")}</span><span className="break-all font-mono text-xs">{row.message}</span></span></Button>)}
</TabsContent>
</Tabs>
</>
}
function FragmentPair({ label, value }: { label: string; value: string }) { return <><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words">{value}</dd></> }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import { Fragment } from "react"
import { AlertTriangleIcon, CircleAlertIcon, CircleHelpIcon, InfoIcon, BugIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import type { LogLevel } from "@mmapp/contracts"
export const LEVEL_LABELS: Record<LogLevel, string> = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
export const LEVEL_COLORS: Record<LogLevel, string> = { critical: "var(--destructive)", error: "var(--destructive)", warning: "var(--warning)", info: "var(--info)", debug: "var(--muted-foreground)", unknown: "var(--border)" }
export function LevelBadge({ level }: { level: LogLevel }) {
const Icon = level === "critical" || level === "error" ? CircleAlertIcon : level === "warning" ? AlertTriangleIcon : level === "debug" ? BugIcon : level === "unknown" ? CircleHelpIcon : InfoIcon
return <Badge variant={level === "critical" || level === "error" ? "destructive-light" : level === "warning" ? "warning-light" : level === "info" ? "info-light" : "secondary"} size="sm"><Icon aria-hidden="true" className="size-3" />{LEVEL_LABELS[level]}</Badge>
}
export function HighlightMessage({ text, search }: { text: string; search: string }) {
if (!search) return <>{text}</>
const lower = text.toLocaleLowerCase(), needle = search.toLocaleLowerCase(), parts = []
let start = 0, found = lower.indexOf(needle)
while (found >= 0) {
parts.push(<Fragment key={start}>{text.slice(start, found)}<mark className="bg-primary/15 text-foreground">{text.slice(found, found + search.length)}</mark></Fragment>)
start = found + search.length; found = lower.indexOf(needle, start)
}
return <>{parts}{text.slice(start)}</>
}
export function formatLogTime(at: string | null) { return at ? new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—" }
@@ -0,0 +1,54 @@
"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { type LogSettings, type LogSummary } from "@mmapp/contracts"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Skeleton } from "@/components/ui/skeleton"
import { getLogSettings, getLogSummary, putLogSettings } from "@/shared/api/logs"
import { can, ensureAuthConfig } from "@/lib/auth"
import { formatLogTime } from "./log-presentation"
export function LogsCollectorSettings({ base, onSaved }: { base: string; onSaved?: (settings: LogSettings) => void }) {
const [settings, setSettings] = useState<LogSettings | null>(null), [summary, setSummary] = useState<LogSummary | null>(null)
const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
const [error, setError] = useState<string | null>(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
const lifetime = useRef<AbortController | null>(null), busy = useRef(false)
useEffect(() => {
const ac = new AbortController(); lifetime.current = ac
void ensureAuthConfig().then(async () => {
if (!can("mm:settings:admin")) throw new Error("Настройки логов доступны с mm:settings:admin")
const data = await getLogSettings(base, ac.signal)
if (ac.signal.aborted) return
setSettings(data); setEnabled(data.enabled); setIntervalDraft(String(data.intervalSec)); setRetention(String(data.retentionDays)); setError(null)
const stats = await getLogSummary(base, new URLSearchParams({ from: new Date(Date.now() - 900000).toISOString() }), ac.signal)
if (!ac.signal.aborted) setSummary(stats)
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof Error ? e.message : String(e)) })
return () => ac.abort()
}, [base, revision])
const save = async () => {
if (busy.current || !settings || !can("mm:settings:admin")) return
if (!/^\d+$/.test(interval) || !/^\d+$/.test(retention)) { setError("Укажите целые значения интервала и срока хранения"); return }
if (Number(interval) < 5 || Number(interval) > 3600 || Number(retention) < 1 || Number(retention) > 365) { setError("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней"); return }
busy.current = true; setSaving(true); setNotice("")
const signal = lifetime.current?.signal
try {
const confirmed = await putLogSettings(base, { enabled, intervalSec: Number(interval), retentionDays: Number(retention) }, signal)
if (signal?.aborted) return
setSettings(confirmed); setError(null); setNotice("Настройки сборщика логов сохранены"); onSaved?.(confirmed)
} catch (e) { if (!signal?.aborted) setError(e instanceof Error ? e.message : String(e)) }
finally { busy.current = false; if (!signal?.aborted) setSaving(false) }
}
return <Frame id="router-logs" dense><FrameHeader><FrameTitle>Логи MikroTik</FrameTitle><FrameDescription>REST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.</FrameDescription></FrameHeader><FramePanel className="flex flex-col gap-4">
{error && <Alert variant="destructive"><AlertTitle>Данные сборщика не подтверждены</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" disabled={saving} onClick={() => setRevision(n => n + 1)}>Повторить загрузку</Button></AlertDescription></Alert>}
{!settings && !error && <Skeleton className="h-24 w-full" />}
<div className="flex flex-wrap items-end gap-4"><label className="flex items-center gap-2 text-sm"><Switch checked={enabled} onCheckedChange={setEnabled} disabled={!settings || saving} aria-label="Включить сбор логов" />Собирать логи</label><label className="flex flex-col gap-1 text-xs">Фоновый интервал, секунд<Input aria-label="Интервал сбора логов" className="w-40" inputMode="numeric" value={interval} onChange={e => setIntervalDraft(e.target.value)} disabled={!settings || saving} /></label><label className="flex flex-col gap-1 text-xs">Хранить, дней<Input aria-label="Срок хранения логов" className="w-40" inputMode="numeric" value={retention} onChange={e => setRetention(e.target.value)} disabled={!settings || saving} /></label><Button size="sm" disabled={!settings || saving} onClick={() => void save()}>{saving ? "Сохранение…" : "Сохранить настройки логов"}</Button></div>
{notice && <p role="status" className="text-sm">{notice}</p>}
<p className="text-xs text-muted-foreground">Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.</p>
{summary?.states.length ? <div className="flex flex-col gap-2">{summary.states.map(state => <div key={state.serverId} className="flex flex-wrap items-center gap-2 border-t py-2 text-xs"><Link href={`/logs?server=${state.serverId}`} className="font-medium underline">{state.serverName}</Link><Badge size="sm" variant={state.lastError ? "warning-light" : state.lastCollectedAt ? "success-light" : "secondary"}>{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}</Badge><span className="text-muted-foreground">{formatLogTime(state.lastCollectedAt)}</span>{state.gapAt && <span>Возможный разрыв: {formatLogTime(state.gapAt)}</span>}{state.lastError && <span role="status" className="break-all">{state.lastError}</span>}</div>)}</div> : <p className="text-xs text-muted-foreground">Результатов сбора по устройствам пока нет.</p>}
</FramePanel></Frame>
}
+52
View File
@@ -0,0 +1,52 @@
"use client"
// ReUI PRO solution-dev-ops-2: Frame-led grid, toolbar and display settings.
// https://reui.io/preview/base/solution-dev-ops-2
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useTable, type ColumnDef } from "@tanstack/react-table"
import { elementScroll, observeElementRect, observeElementOffset } from "@tanstack/react-virtual"
import type { LogEntry } from "@mmapp/contracts"
import { CopyIcon, Columns3Icon, WrapTextIcon } from "lucide-react"
import { toast } from "sonner"
import { DataGrid, DataGridContainer, DataGridTableVirtual, DataGridColumnVisibility, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
import { HighlightMessage, LevelBadge, formatLogTime } from "./log-presentation"
export function LogsGrid({ entries, loading, total, search, toolbar, onSelect, onPause, hasMore, fetchingMore, loadMore, reset }: {
entries: LogEntry[]; loading: boolean; total: number | null; search: string; toolbar: ReactNode;
onSelect: (entry: LogEntry) => void; onPause: () => void; hasMore: boolean; fetchingMore: boolean; loadMore: () => void; reset: () => void
}) {
const [wrap, setWrap] = useState(false)
const [narrow, setNarrow] = useState(false)
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)")
const update = () => setNarrow(mq.matches)
queueMicrotask(update); mq.addEventListener("change", update)
return () => mq.removeEventListener("change", update)
}, [])
const columns = useMemo<ColumnDef<DataGridFeatures, LogEntry>[]>(() => [
{ accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => <span className="font-mono text-xs tabular-nums" title={row.original.timeKind === "receipt" ? `Время получения; RouterOS: ${row.original.rawTime}` : row.original.rawTime}>{formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && <span aria-label="Время получения"> *</span>}</span> },
{ accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => <span className="truncate" title={row.original.host}>{row.original.serverName}</span> },
{ accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => <LevelBadge level={row.original.level} /> },
{ accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => <span className="truncate text-xs text-muted-foreground font-mono" title={row.original.topics.join(", ")}>{row.original.topics.join(", ")}</span> },
{ accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => <Button variant="ghost" className="h-auto min-w-0 w-full justify-start whitespace-normal text-left" onClick={event => { event.stopPropagation(); onSelect(row.original) }} aria-label={`Открыть событие ${row.original.id}`}><span className="flex min-w-0 flex-col gap-1">{narrow && <span className="flex flex-wrap items-center gap-1"><LevelBadge level={row.original.level} /><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.original.effectiveAt)}</span></span>}<span className={`min-w-0 font-mono text-xs ${wrap || narrow ? "whitespace-pre-wrap break-words" : "truncate"}`}><HighlightMessage text={row.original.message} search={search} /></span></span></Button> },
{ id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => <Button size="icon-sm" variant="ghost" aria-label="Копировать сообщение" onClick={event => { event.stopPropagation(); void navigator.clipboard.writeText(row.original.message).then(() => toast.success("Сообщение скопировано"), () => toast.error("Не удалось скопировать сообщение")) }}><CopyIcon /></Button> },
], [wrap, narrow, search, onSelect])
const table = useTable({ features: dataGridFeatures, data: entries, columns, getRowId: row => row.id, manualPagination: true, rowCount: entries.length })
useEffect(() => { table.setColumnVisibility({ effectiveAt: !narrow, serverName: !narrow, level: !narrow, topics: !narrow, copy: !narrow }) }, [narrow, table])
return <DataGrid table={table} recordCount={entries.length} isLoading={loading} loadingMode="skeleton" i18n={{ labels: DATA_GRID_LABELS_RU }} onRowClick={onSelect}
tableLayout={{ dense: true, headerSticky: true, columnsVisibility: true, width: "fixed" }}
emptyMessage={<div className="flex flex-col items-center gap-2 p-6"><p>Событий за выбранный период нет</p><Button size="sm" variant="outline" onClick={reset}>Сбросить фильтры</Button></div>}
tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}>
<Frame dense className="min-w-0 w-full">
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><FrameTitle>Журнал событий</FrameTitle><FrameDescription className="text-xs tabular-nums">Загружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}</FrameDescription></div><div className="flex items-center gap-2"><Button size="sm" variant="outline" aria-pressed={wrap} onClick={() => setWrap(v => !v)}><WrapTextIcon /><span className="max-sm:sr-only">Перенос</span></Button><DataGridColumnVisibility table={table} trigger={<Button variant="outline" size="sm"><Columns3Icon />Колонки</Button>} /></div></FrameHeader>
<FramePanel className="p-0"><div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">{toolbar}</div>
<div onScrollCapture={event => { if ((event.target as HTMLElement).scrollTop > 16) onPause() }}>
<DataGridContainer border={false}><DataGridTableVirtual height={420} estimateSize={48} overscan={8} hasMore={hasMore} isFetchingMore={fetchingMore} onFetchMore={loadMore} virtualizerOptions={{ scrollToFn: elementScroll, observeElementRect, observeElementOffset, measureElement: element => element.getBoundingClientRect().height }} /></DataGridContainer>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-xs text-muted-foreground"><span>* Использовано время получения. Часовой пояс отображения: браузер.</span>{hasMore && <Button size="sm" variant="outline" disabled={fetchingMore} onClick={loadMore}>{fetchingMore ? "Загрузка…" : "Загрузить ещё"}</Button>}</div>
</FramePanel>
</Frame>
</DataGrid>
}
+149
View File
@@ -0,0 +1,149 @@
"use client"
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useSearchParams } from "next/navigation"
import Link from "next/link"
import { z } from "zod"
import { logFiltersSchema, logLevels, type LogEntry } from "@mmapp/contracts"
import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { DownloadIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, BookmarkPlusIcon, ActivityIcon } from "lucide-react"
import { toast } from "sonner"
import { PageHeader } from "@/components/page-header"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
import { Badge } from "@/components/reui/badge"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { Filters } from "@/components/reui/filters/filters"
import { createFilterQuery, createFilterRule, flattenFilterConditions } from "@/components/reui/filters/filters-query"
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import { useDataSource } from "@/lib/data-source"
import { can, ensureAuthConfig, getClaims } from "@/lib/auth"
import { firewallServersSchema } from "@/lib/firewall-response"
import { FILTER_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
import { requestJson } from "@/shared/api/http-client"
import { exportLogs } from "@/shared/api/logs"
import { useRouterLogs } from "@/hooks/use-router-logs"
import { LogsGrid } from "./logs-grid"
import { LogDetailSheet } from "./log-detail-sheet"
import { LEVEL_COLORS, LEVEL_LABELS, formatLogTime } from "./log-presentation"
const RANGES = [{ value: "15m", label: "15 минут" }, { value: "1h", label: "1 час" }, { value: "24h", label: "24 часа" }, { value: "7d", label: "7 дней" }, { value: "14d", label: "14 дней" }, { value: "custom", label: "Свой период" }]
const RANGE_MS: Record<string, number> = { "15m": 900000, "1h": 3600000, "24h": 86400000, "7d": 604800000, "14d": 1209600000 }
const PRESETS = [{ value: "", label: "Все события" }, { value: "errors", label: "Ошибки и critical" }, { value: "warnings", label: "Предупреждения" }, { value: "login-failure", label: "Ошибки входа · шаблон" }, { value: "interfaces", label: "Интерфейсы" }, { value: "routing", label: "Маршрутизация" }, { value: "firewall", label: "Firewall" }]
const viewSchema = z.array(z.object({ name: z.string().max(60), query: z.string().max(4000) })).max(20)
function navigate(values: Record<string, string | null>, replace = false) {
const params = new URLSearchParams(window.location.search)
for (const [key, value] of Object.entries(values)) { if (value) params.set(key, value); else params.delete(key) }
window.history[replace ? "replaceState" : "pushState"](null, "", "/logs?" + params)
}
export function LogsWorkspace() { return <Suspense fallback={<Skeleton className="h-64 w-full" />}><LogsRoute /></Suspense> }
function LogsRoute() {
const { backendUrl, mode, prefsHydrated } = useDataSource()
return prefsHydrated ? <LogsCatalog key={JSON.stringify([backendUrl, mode])} base={backendUrl} active={mode === "live"} /> : <p role="status" className="p-4">Загружаем источник…</p>
}
function LogsCatalog({ base, active }: { base: string; active: boolean }) {
const params = useSearchParams(), selectedId = params.get("server") ?? ""
const [servers, setServers] = useState<z.infer<typeof firewallServersSchema>>([]), [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false), [loading, setLoading] = useState(active), [retry, setRetry] = useState(0)
useEffect(() => {
const ac = new AbortController()
void ensureAuthConfig().then(async () => {
if (!active || ac.signal.aborted) return
if (!can("mm:servers:read")) throw new Error("Для чтения логов требуется mm:servers:read")
const rows = firewallServersSchema.parse(await requestJson(base, "/api/servers", { signal: ac.signal }))
if (ac.signal.aborted) return
setServers(rows); setReady(true); setError(null)
if (!new URLSearchParams(window.location.search).has("server")) navigate({ server: String(rows.find(s => s.enabled)?.id ?? ALL_SERVERS_ID) }, true)
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный каталог устройств" : e.message) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
return () => ac.abort()
}, [base, active, retry])
const items: ServerTileItem[] = servers.map(s => ({ id: String(s.id), name: s.name, host: s.host, country: s.country, type: s.type, status: s.status ?? "unknown", enabled: s.enabled, selectable: true }))
// Keep an explicit historical ID in the rail after deletion; history remains addressable.
if (selectedId && selectedId !== ALL_SERVERS_ID && !items.some(s => s.id === selectedId) && /^\d+$/.test(selectedId)) items.push({ id: selectedId, name: `История устройства #${selectedId}`, host: "Удалено или отсутствует в каталоге", enabled: false, selectable: true })
const selected = servers.find(s => String(s.id) === selectedId)
return <ServerRailLayout items={items} selectedId={selectedId} preserveSelection showAll allCount={servers.length} showCount={false} loading={loading} onSelect={id => navigate({ server: id, live: id === ALL_SERVERS_ID ? "0" : null })} contentClassName="min-w-0 min-h-0 overflow-y-auto p-2 md:p-3"
header={<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Логи" }]} actions={<><ServerRailMobileButton /><Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)} disabled={!active || loading}><RefreshCwIcon />Обновить устройства</Button></>} />}>
{!active ? <Alert variant="info"><AlertTitle>Демонстрационный источник</AlertTitle><AlertDescription>Для просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных.</AlertDescription></Alert> : error ? <Alert variant="destructive"><AlertTitle>Каталог устройств не подтверждён</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !ready || !selectedId ? <Skeleton className="h-80 w-full" /> : <LogExplorer key={JSON.stringify([base, params.toString()])} base={base} raw={params.toString()} selectedName={selected?.name ?? "История"} liveAvailable={!!selected?.enabled} />}
</ServerRailLayout>
}
function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string; raw: string; selectedName: string; liveAvailable: boolean }) {
const params = useMemo(() => new URLSearchParams(raw), [raw])
const [anchor] = useState(() => Date.now())
const range = params.get("range") ?? "15m", server = params.get("server") ?? ALL_SERVERS_ID
const live = server !== ALL_SERVERS_ID && liveAvailable && params.get("live") !== "0" && range !== "custom"
const parsed = useMemo(() => {
const from = range === "custom" ? params.get("from") : new Date(anchor - (RANGE_MS[range] ?? 900000)).toISOString()
return logFiltersSchema.safeParse({ serverId: server === ALL_SERVERS_ID ? undefined : server, from: from || undefined, to: range === "custom" ? params.get("to") || undefined : live ? undefined : new Date(anchor).toISOString(), levels: params.get("levels") ?? "", topics: params.get("topics") ?? "", q: params.get("q") ?? "", preset: params.get("preset") ?? "" })
}, [params, server, range, anchor, live])
const query = useMemo(() => { const query = new URLSearchParams(); if (parsed.success) for (const [key, value] of Object.entries(parsed.data)) if (value !== undefined && value !== "") query.set(key, String(value)); return query.toString() }, [parsed])
const [paused, setPaused] = useState(false), [selected, setSelected] = useState<LogEntry | null>(null)
const valid = parsed.success && RANGES.some(r => r.value === range) && (range !== "custom" || !!params.get("from") && !!params.get("to"))
const result = useRouterLogs(base, query, valid, live, paused || !!selected, live ? RANGE_MS[range] ?? 900000 : 0)
const [search, setSearch] = useState(params.get("q") ?? "")
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createFilterQuery(["levels", "topics"].flatMap(field => params.get(field) ? [createFilterRule({ id: field, path: [field], operator: "is_any_of", value: params.get(field)!.split(",") })] : [])))
const [viewName, setViewName] = useState(""), [views, setViews] = useState<z.infer<typeof viewSchema>>([]), [exporting, setExporting] = useState(false)
const exportController = useRef<AbortController | null>(null)
const storageKey = `mm:logs:views:${base}:${getClaims()?.sub ?? "local"}`
useEffect(() => { queueMicrotask(() => { try { setViews(viewSchema.parse(JSON.parse(localStorage.getItem(storageKey) ?? "[]"))) } catch { setViews([]) } }); return () => exportController.current?.abort() }, [storageKey])
const fields: FilterField[] = useMemo(() => [
{ id: "levels", label: "Уровень", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: logLevels.map(value => ({ value, label: LEVEL_LABELS[value] })) },
{ id: "topics", label: "Topics", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: [...new Set(["system", "account", "interface", "firewall", "route", "routing", "bgp", "ospf", "bfd", "dhcp", "dns", "wireguard", "ipsec", "script", ...result.entries.flatMap(row => row.topics), ...(params.get("topics")?.split(",") ?? [])])].sort().map(value => ({ value, label: value })) },
], [result.entries, params])
const applyFilters = (next: FilterQuery) => {
setFilterQuery(next)
const conditions = flattenFilterConditions(next), values: Record<string, string | null> = { levels: null, topics: null }
for (const condition of conditions) if (condition.values.length) values[condition.field] = condition.values.join(",")
navigate(values)
}
const reset = () => navigate({ q: null, levels: null, topics: null, preset: null, range: "15m", from: null, to: null })
const openEntry = useCallback((entry: LogEntry) => setSelected(entry), [])
const fetchMore = result.loadMore
const loadMore = useCallback(() => { setPaused(true); void fetchMore() }, [fetchMore])
const pause = useCallback(() => setPaused(true), [])
const state = result.stream?.state ?? result.summary?.states.find(s => String(s.serverId) === server)
const collectorEnabled = result.stream?.enabled ?? result.summary?.settings.enabled
const saveView = () => {
if (!viewName.trim()) return
const savedQuery = new URLSearchParams(raw); savedQuery.delete("server")
const next = [...views.filter(v => v.name !== viewName.trim()), { name: viewName.trim().slice(0, 60), query: savedQuery.toString() }].slice(-20)
try { localStorage.setItem(storageKey, JSON.stringify(next)); setViews(next); setViewName(""); toast.success("Представление сохранено") } catch { toast.error("Не удалось сохранить представление") }
}
const doExport = async (format: "csv" | "json") => {
const ac = new AbortController(); exportController.current = ac; setExporting(true)
const exportQuery = new URLSearchParams(query)
if (live) exportQuery.set("from", new Date(Date.now() - (RANGE_MS[range] ?? 900000)).toISOString())
try { await exportLogs(base, exportQuery, format, ac.signal) } catch (e) { if (!ac.signal.aborted) toast.error(e instanceof Error ? e.message : "Экспорт не завершён") } finally { if (!ac.signal.aborted) setExporting(false) }
}
if (!parsed.success || !RANGES.some(r => r.value === range) || range === "custom" && (!params.get("from") || !params.get("to"))) return <Alert variant="destructive"><AlertTitle>Некорректные фильтры в ссылке</AlertTitle><AlertDescription>Проверьте устройство, уровни и период.<Button size="sm" variant="outline" onClick={() => navigate({ server: ALL_SERVERS_ID, q: null, levels: null, topics: null, preset: null, range: null, from: null, to: null })}>Сбросить ссылку</Button></AlertDescription></Alert>
return <div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><h1 className="text-lg font-semibold tracking-tight">Логи MikroTik</h1><p className="text-sm text-muted-foreground">{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}</p></div><div className="flex flex-wrap items-center gap-2">
<Select value={range} items={RANGES} onValueChange={value => { if (!value) return; navigate(value === "custom" ? { range: value, from: new Date(anchor - 900000).toISOString(), to: new Date(anchor).toISOString(), live: "0" } : { range: value, from: null, to: null }) }}><SelectTrigger size="sm" aria-label="Период логов"><SelectValue /></SelectTrigger><SelectContent>{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}</SelectContent></Select>
<Button size="sm" variant={live ? "default" : "outline"} disabled={!liveAvailable || range === "custom"} onClick={() => navigate({ live: live ? "0" : null })}><ActivityIcon />{live ? "Live включён" : "История"}</Button><Button size="sm" variant="outline" onClick={result.reload}><RefreshCwIcon />Обновить</Button>
<Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("csv")}><DownloadIcon />CSV</Button><Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("json")}>JSON</Button>
</div></div>
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
<Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex flex-col gap-0.5"><FrameTitle>Поток событий</FrameTitle><FrameDescription>Выберите столбец, чтобы открыть его период</FrameDescription></div><div className="flex flex-wrap gap-2">{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => <span key={label} className="text-xs text-muted-foreground">{label}: <strong className="text-foreground tabular-nums">{count?.toLocaleString("ru-RU") ?? "—"}</strong></span>)}</div></FrameHeader><FramePanel>
{result.summaryError ? <Alert variant="warning"><AlertDescription>{result.summaryError}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert> : !result.summary ? <Skeleton className="h-32 w-full" /> : result.summary.buckets.length === 0 ? <div className="flex h-32 items-center justify-center text-sm text-muted-foreground">За выбранный период событий нет</div> : <ChartContainer className="h-36 w-full" config={Object.fromEntries(logLevels.map(level => [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}>
<BarChart accessibilityLayer data={result.summary.buckets} onClick={value => { const at = value.activeLabel; if (!at || !result.summary) return; const buckets = result.summary.buckets, i = buckets.findIndex(b => b.at === at); if (i < 0) return; const width = Math.max(1000, Math.ceil(((parsed.data.to ? Date.parse(parsed.data.to) : anchor) - Date.parse(parsed.data.from ?? new Date(anchor - 900000).toISOString())) / 60000) * 1000); navigate({ range: "custom", from: buckets[i].at, to: new Date(Date.parse(buckets[i].at) + width - 1).toISOString(), live: "0" }) }}><XAxis dataKey="at" tickFormatter={value => new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /><YAxis allowDecimals={false} width={32} tickLine={false} axisLine={false} /><ChartTooltip content={<ChartTooltipContent labelFormatter={value => formatLogTime(String(value))} />} />{logLevels.map(level => <Bar key={level} dataKey={level} stackId="events" fill={`var(--color-${level})`} maxBarSize={28} isAnimationActive={false} />)}</BarChart>
</ChartContainer>}
</FramePanel></Frame>
{result.error && <Alert variant="destructive"><AlertTitle>Журнал не загружен</AlertTitle><AlertDescription>{result.error}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert>}
<LogsGrid entries={result.entries} loading={result.loading} total={result.summary?.total ?? null} search={parsed.data.q} onSelect={openEntry} onPause={pause} hasMore={result.hasMore} fetchingMore={result.fetchingMore} loadMore={loadMore} reset={reset} toolbar={<>
<div className="flex flex-wrap items-center gap-2"><form className="flex min-w-0 flex-1 basis-full gap-2 sm:basis-auto sm:max-w-md" onSubmit={event => { event.preventDefault(); navigate({ q: search || null }) }}><Input maxLength={500} aria-label="Поиск по сообщениям" placeholder="Сообщение, IP, интерфейс…" value={search} onChange={event => setSearch(event.target.value)} /><Button size="sm" variant="outline" aria-label="Найти события" type="submit"><SearchIcon /></Button></form><Select value={params.get("preset") || (params.get("levels") === "critical,error" ? "errors" : params.get("levels") === "warning" ? "warnings" : "")} items={PRESETS} onValueChange={value => { navigate(value === "errors" ? { levels: "critical,error", preset: null } : value === "warnings" ? { levels: "warning", preset: null } : { preset: value || null, levels: null }) }}><SelectTrigger size="sm" aria-label="Быстрый срез"><SelectValue placeholder="Все события" /></SelectTrigger><SelectContent>{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent></Select><Button size="sm" variant="ghost" onClick={reset}>Сбросить</Button></div>
<Filters size="sm" fields={fields} query={filterQuery} labels={FILTER_LABELS_RU} onBeforeQueryChange={(next, details) => details.reason !== "negate" && next.rules.every(rule => rule.type === "rule") && new Set(next.rules.map(rule => rule.type === "rule" ? rule.path[0] : "")).size === next.rules.length} onQueryChange={applyFilters} />
<div className="flex flex-wrap items-center gap-2"><Input aria-label="Название представления" placeholder="Название представления" className="w-full sm:w-48" maxLength={60} value={viewName} onChange={event => setViewName(event.target.value)} /><Button size="sm" variant="outline" disabled={!viewName.trim()} onClick={saveView}><BookmarkPlusIcon />Сохранить</Button>{views.map(view => <Button key={view.name} size="sm" variant="ghost" onClick={() => { const saved = new URLSearchParams(view.query); saved.set("server", server); window.history.pushState(null, "", "/logs?" + saved) }}>{view.name}</Button>)}</div>
</>} />
{result.entries.length >= 5000 && <p role="status" className="text-xs text-muted-foreground">Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.</p>}
<p className="text-xs text-muted-foreground">REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.</p>
<LogDetailSheet entry={selected} base={base} onClose={() => setSelected(null)} onSelect={openEntry} />
</div>
}
+66
View File
@@ -0,0 +1,66 @@
# Логи MikroTik
Раздел `/logs` использует общую панель выбора серверов, как `/filters` и `/firewall`.
История хранится в PostgreSQL; live читает уже сохранённые события через SSE.
## Включение
При следующем запуске backend автоматически применяется миграция `0014_router_logs.sql`.
В «Сбор данных → Логи MikroTik» администратор включает сбор. По умолчанию он выключен,
фоновый интервал — 10 секунд, хранение — 14 дней. Настройки требуют `mm:settings:admin`;
журнал, контекст, статистика и экспорт — `mm:servers:read` (также write/admin этой секции).
При открытом live выбранный включённый MikroTik опрашивается раз в 2 секунды.
Вкладки разделяют опрос; одновременно опрашиваются не более четырёх устройств.
Целевая задержка при штатной нагрузке — до 5 секунд. Ошибки сети вызывают backoff до минуты.
«Все серверы», выключенные и удалённые устройства доступны в режиме истории.
Коллектор читает только buffer=memory и не меняет правила RouterOS. После очистки,
перезагрузки или переполнения буфера возможны пропуски. Потеря пересечения снимков
помечается как возможный разрыв без выдуманного числа пропущенных событий. Время без
явного часового пояса не интерпретируется как UTC: журнал использует отмеченное время
получения и сохраняет исходную строку RouterOS.
События удаляются пакетами по времени получения. Очистка продолжает работать и при
выключенном сборщике. Дедупликация хранит SHA-256 отпечатки, а не копии сообщений.
Удаление устройства не удаляет его историю; повторно открыть её можно по ссылке
`/logs?server=<id>` или через общий журнал. Новые таблицы входят в полный `pg_dump`.
Планировщик и фоновые задачи ожидаются, а SSE закрываются перед обслуживанием БД.
Перед закрытием поток получает `reset`: интерфейс предлагает заново загрузить историю
после обслуживания, чтобы не продолжать старый курсор поверх восстановленной БД.
## Интерфейс и ReUI PRO
Сохранены компоненты и стиль проекта base-nova. Исходники блоков получены из
лицензионного реестра ReUI и адаптированы под RouterOS; демонстрационные данные,
изменения инфраструктуры и workflow review из блоков не используются.
- Основа таблицы, toolbar и графика: [Infra Audit Log](https://reui.io/blocks/solutions/dev-ops/solution-dev-ops-2), [превью](https://reui.io/preview/base/solution-dev-ops-2).
- Панель записи, исходные поля и контекст: [Audit Event Detail](https://reui.io/blocks/application/sheet/sheet-14), [превью](https://reui.io/preview/base/sheet-14).
- [Data Grid API](https://reui.io/docs/components/base/data-grid), [превью](https://reui.io/components/data-grid).
- [Filters API](https://reui.io/docs/components/base/filters), [превью](https://reui.io/components/filters).
- [Frame API](https://reui.io/docs/components/base/frame), [превью](https://reui.io/components/frame).
- [Badge API](https://reui.io/docs/components/base/badge), [превью](https://reui.io/components/badge).
Фильтры между полями соединяются AND, значения уровня/topics — OR. Поиск сообщения
регистронезависимый, буквальный. «Ошибки входа» — явно обозначенный текстовый шаблон.
Представления хранятся локально с привязкой к пользователю и backend. Экспорт CSV/JSON
применяет те же фильтры; выборки свыше 50 000 записей отклоняются с предложением сузить
период. Формулы spreadsheet в CSV нейтрализуются.
При прокрутке и открытии записи обновление видимой таблицы приостанавливается, но
сбор продолжается. Буфер отображения ограничен 5 000 строками; более ранние записи
доступны через период/экспорт. Смена сервера/источника/фильтров отменяет старые запросы.
## Проверки
- `npm --prefix backend run build` — contracts и backend.
- `npm --prefix backend run test:logs` — дедупликация, нормализация, фильтры, CSV, JWT/RBAC и REST-совместимость терминала.
- `npm --prefix backend run test:logs:pg` — интеграция с PostgreSQL и локальным HTTP-стендом RouterOS; используется отдельная случайная схема, которая удаляется после проверки.
- Для нагрузочной SQL-проверки установить `LOGS_PERFORMANCE=1` перед `test:logs:pg`: миллион событий, курсорная история и агрегация.
- `npm run test:logs` — Playwright: выбор серверов, детали, URL/поиск, экспорт, live-пауза, устаревшие ответы, 5 000 строк, mobile/desktop, светлая/тёмная темы.
Браузерный профиль использует Webpack и отдельный каталог `tmp/next-logs-test`, чтобы
не конфликтовать с кэшем запущенного dev-сервера. SQL-профиль требует доступного
`DATABASE_URL` и права создать временную схему; при недоступной БД завершится ошибкой,
а не выдаст пропуск за успешную проверку.
+123
View File
@@ -0,0 +1,123 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { logStreamSchema, type LogEntry, type LogPage, type LogStream, type LogSummary } from "@mmapp/contracts"
import { getLogs, getLogSummary } from "@/shared/api/logs"
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
export function mergeLogEntries(previous: LogEntry[], incoming: LogEntry[], limit = 5000) {
return [...new Map([...previous, ...incoming].map(row => [row.id, row])).values()].sort((a, b) => b.effectiveAt.localeCompare(a.effectiveAt) || (BigInt(a.id) < BigInt(b.id) ? 1 : -1)).slice(0, limit)
}
const message = (error: unknown) => error instanceof Error ? error.message : String(error)
export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) {
const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null)
const [summary, setSummary] = useState<LogSummary | null>(null), [stream, setStream] = useState<LogStream | null>(null)
const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false)
const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null)
const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0)
const pending = useRef<LogEntry[]>([]), pausedRef = useRef(paused), initialCursor = useRef<string | null>(null)
const lifetime = useRef<AbortController | null>(null), moreBusy = useRef(false)
useEffect(() => { pausedRef.current = paused }, [paused])
const flush = useCallback(() => { const buffered = pending.current; setEntries(prev => mergeLogEntries(prev, buffered).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs)); pending.current = []; setPendingCount(0) }, [windowMs])
useEffect(() => {
if (!paused && pending.current.length) flush()
}, [paused, flush])
useEffect(() => {
if (!enabled) return
const ac = new AbortController(); lifetime.current = ac
queueMicrotask(() => { if (!ac.signal.aborted) { setLoading(true); setError(null) } })
const currentQuery = () => { const params = new URLSearchParams(query); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString()); return params }
void getLogs(base, currentQuery(), ac.signal).then(data => {
if (ac.signal.aborted) return
setEntries(data.entries); setPage(data); initialCursor.current = data.watermark
}).catch(e => { if (!ac.signal.aborted) setError(message(e)) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
const readSummary = () => { void getLogSummary(base, currentQuery(), ac.signal).then(data => { if (!ac.signal.aborted) { setSummary(data); setSummaryError(null) } }).catch(e => { if (!ac.signal.aborted) setSummaryError(message(e)) }) }
readSummary()
const timer = setInterval(readSummary, 10_000)
return () => { ac.abort(); clearInterval(timer); initialCursor.current = null }
}, [base, query, enabled, revision, windowMs])
const ready = page !== null && !loading && !error
useEffect(() => {
if (!enabled || !live || !ready || !initialCursor.current) return
const ac = new AbortController()
let cursor = initialCursor.current, retry = 1000
void (async () => {
while (!ac.signal.aborted) {
const connection = new AbortController(), abort = () => connection.abort()
ac.signal.addEventListener("abort", abort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const heartbeat = () => { clearTimeout(watchdog); watchdog = setTimeout(() => connection.abort(), 20_000) }
try {
heartbeat()
const params = new URLSearchParams(query); params.set("cursor", cursor); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString())
const res = await fetch(resolveApiUrl(base, "/api/logs/live?" + params), { headers: withAuthHeaders({ Accept: "text/event-stream" }), signal: connection.signal, credentials: "include" })
if (!res.ok || !res.body) {
const body = await res.json().catch(() => null)
throw Object.assign(new Error(body?.error ?? `Live недоступен (HTTP ${res.status})`), { status: res.status })
}
const reader = res.body.getReader(), decoder = new TextDecoder()
let buffer = ""
try {
while (!ac.signal.aborted) {
const { done, value } = await reader.read()
if (done) throw new Error("Поток завершён; переподключение…")
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""
if (buffer.length > 8 * 1024 * 1024) throw new Error("Слишком большой ответ live")
for (const block of blocks) {
const type = block.match(/^event:\s*(.+)$/m)?.[1]
const data = block.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n")
if (type === "reset") {
const reason = "Поток остановлен для обслуживания БД. После завершения обновите журнал, чтобы заново загрузить историю."
if (!ac.signal.aborted) setError(reason)
return
}
if (type === "error") throw new Error(JSON.parse(data).error)
if (type !== "batch") continue
const batch = logStreamSchema.parse(JSON.parse(data))
const expectedServer = new URLSearchParams(query).get("serverId")
if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор")
heartbeat(); retry = 1000; cursor = batch.cursor
if (ac.signal.aborted) return
setStream(batch); setStreamError(null)
if (batch.entries.length) {
if (pausedRef.current) {
pending.current = mergeLogEntries(pending.current, batch.entries)
setPendingCount(n => n + batch.entries.length)
} else setEntries(prev => mergeLogEntries(prev, batch.entries).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs))
} else if (!pausedRef.current && windowMs) {
setEntries(prev => { const firstExpired = prev.findIndex(row => Date.parse(row.effectiveAt) < Date.now() - windowMs); return firstExpired < 0 ? prev : prev.slice(0, firstExpired) })
}
}
}
} finally { await reader.cancel().catch(() => {}) }
} catch (e) {
if (ac.signal.aborted) return
setStreamError(message(e))
if (e && typeof e === "object" && "status" in e && [401, 403, 404].includes(Number(e.status))) return
} finally { clearTimeout(watchdog); connection.abort(); ac.signal.removeEventListener("abort", abort) }
await new Promise<void>(resolve => {
const done = () => { clearTimeout(timer); ac.signal.removeEventListener("abort", done); resolve() }
const timer = setTimeout(done, retry); ac.signal.addEventListener("abort", done, { once: true }); if (ac.signal.aborted) done()
})
retry = Math.min(15_000, retry * 2)
}
})()
return () => ac.abort()
}, [base, query, enabled, live, ready, revision, windowMs])
const loadMore = useCallback(async () => {
if (!page?.nextCursor || moreBusy.current || !enabled) return
moreBusy.current = true; setFetchingMore(true)
const signal = lifetime.current?.signal
try {
const params = new URLSearchParams(query); params.set("cursor", page.nextCursor); params.set("watermark", page.watermark)
const data = await getLogs(base, params, signal)
if (signal?.aborted) return
setEntries(prev => mergeLogEntries(prev, data.entries)); setPage(data)
} catch (e) { if (!signal?.aborted) setError(message(e)) }
finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) }
}, [base, query, enabled, page])
const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, [])
return { entries, summary, stream, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush }
}
+1 -1
View File
@@ -232,7 +232,7 @@ export function permissionForPath(pathname: string): string | null {
return "mm:dashboard:read"
}
if (pathname.startsWith("/probes")) return "mm:servers:read"
if (pathname.startsWith("/terminal")) return "mm:servers:read"
if (pathname.startsWith("/terminal") || pathname.startsWith("/logs")) return "mm:servers:read"
if (pathname.startsWith("/servers")) return "mm:servers:read"
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
return "mm:filters:read"
+1
View File
@@ -42,6 +42,7 @@ export const navigationItems: NavigationItem[] = [
{ id: "certificates", title: "Сертификаты", group: "Управление", url: "/certificates", icon: BadgeCheckIcon, keywords: ["ssl","tls","cert","pki","x509"] },
{ id: "backups", title: "Бэкапы", group: "Управление", url: "/backups", icon: HardDriveIcon, keywords: ["backup","резервная копия"] },
// Инструменты
{ id: "logs", title: "Логи", group: "Инструменты", url: "/logs", icon: ActivityIcon, keywords: ["logs", "журнал", "события", "syslog", "ошибки"] },
{ id: "route-optimizer", title: "Оптимизатор маршрутов", group: "Инструменты", url: "/route-optimizer", icon: RouteIcon, keywords: ["routing","wan","failover","переключение"] },
{ id: "ospf", title: "OSPF", group: "Инструменты", url: "/ospf", icon: GitForkIcon, keywords: ["ospf","igp","neighbors","lsa"] },
{ id: "bgp", title: "BGP", group: "Инструменты", url: "/bgp", icon: GitMergeIcon, keywords: ["bgp","ebgp","ibgp","сессии","peers"] },
+10 -1
View File
@@ -114,7 +114,16 @@ 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
@@ -234,7 +243,7 @@ export function parseSchedulerRunSnapshot(raw: unknown): SchedulerRunSnapshot |
if (o.skipped !== undefined && typeof o.skipped !== "boolean") return null
if (o.fatalError !== undefined && typeof o.fatalError !== "string") return null
for (const field of ["errors", "eventErrors"]) if (o[field] !== undefined && (!Array.isArray(o[field]) || !(o[field] as unknown[]).every(error => typeof error === "string"))) return null
const arrays: Record<string, string> = { traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
const arrays: Record<string, string> = { router_logs: "servers", traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
if (Object.hasOwn(arrays, o.job)) {
if (!Array.isArray(o[arrays[o.job]])) return null
} else {
+3
View File
@@ -2,6 +2,7 @@
/** Последний ключ — движок оповещений (читает БД после коллекторов; см. описание `alert_engine`). */
export const SCHEDULER_JOB_KEYS = [
"router_logs",
"traffic",
"servers_rest_ping",
"uptime_resources",
@@ -17,6 +18,7 @@ export const SCHEDULER_JOB_KEYS = [
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
export const SCHEDULER_JOB_LABELS: Record<string, string> = {
router_logs: "Логи MikroTik",
traffic: "Трафик",
servers_rest_ping: "Серверы: REST API",
uptime_resources: "Uptime: ресурсы",
@@ -32,6 +34,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
/** Кратко — для раскрытой карточки прогона и подсказок. */
export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
router_logs: "REST-сбор буфера memory в PostgreSQL. Параметры — в панели «Логи MikroTik» ниже; live ускоряет опрос выбранного устройства до 2 секунд.",
traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в PostgreSQL.",
servers_rest_ping:
"Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.",
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
import { backendInternalUrl } from "./lib/backend-internal-url";
const nextConfig: NextConfig = {
distDir: process.env.MM_NEXT_DIST_DIR || ".next",
output: "standalone",
experimental: {
proxyClientMaxBodySize: "512mb",
+1
View File
@@ -7,6 +7,7 @@
"backend"
],
"scripts": {
"test:logs": "playwright test --config playwright.logs-context.config.ts",
"dev": "next dev",
"build": "npm run build -w @mmapp/contracts && next build",
"postinstall": "npm run build -w @mmapp/contracts",
+1
View File
@@ -1,4 +1,5 @@
export * from "./servers.js"
export * from "./logs.js"
export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod"
export const logLevels = ["critical", "error", "warning", "info", "debug", "unknown"] as const
export const logLevelSchema = z.enum(logLevels)
export type LogLevel = z.infer<typeof logLevelSchema>
const id = z.string().regex(/^\d+$/)
const instant = z.string().datetime({ offset: true })
export const logEntrySchema = z.object({
id, serverId: z.number().int(), serverName: z.string(), host: z.string(),
routerId: z.string(), rawTime: z.string(), eventAt: instant.nullable(), receivedAt: instant,
effectiveAt: instant, timeKind: z.enum(["router", "receipt"]), topics: z.array(z.string()),
level: logLevelSchema, message: z.string(), generation: z.number().int(),
})
export type LogEntry = z.infer<typeof logEntrySchema>
export const logFiltersSchema = z.object({
serverId: z.coerce.number().int().positive().optional(), from: instant.default(() => new Date(Date.now() - 900000).toISOString()), to: instant.optional(),
levels: z.string().max(100).default("").refine(v => !v || v.split(",").every(x => (logLevels as readonly string[]).includes(x))),
topics: z.string().max(500).default(""), q: z.string().max(500).default(""),
preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
}).refine(v => !v.from || !v.to || Date.parse(v.from) <= Date.parse(v.to), "Некорректный период")
export type LogFilters = z.infer<typeof logFiltersSchema>
export const logPageQuerySchema = logFiltersSchema.safeExtend({
cursor: z.string().max(200).optional(), watermark: id.optional(),
limit: z.coerce.number().int().min(1).max(200).default(200),
})
export const logStateSchema = z.object({ serverId: z.number().int(), serverName: z.string(), host: z.string(),
lastCollectedAt: instant.nullable(), lastError: z.string().nullable(), gapAt: instant.nullable(),
nextPollAt: instant.nullable(), failures: z.number().int(), generation: z.number().int() })
export const logPageSchema = z.object({ entries: z.array(logEntrySchema), nextCursor: z.string().nullable(), watermark: id })
export type LogPage = z.infer<typeof logPageSchema>
export const logSettingsSchema = z.object({ enabled: z.boolean(), intervalSec: z.number().int().min(5).max(3600), retentionDays: z.number().int().min(1).max(365) })
export type LogSettings = z.infer<typeof logSettingsSchema>
export const logSettingsPatchSchema = logSettingsSchema.partial().strict().refine(v => Object.keys(v).length > 0)
export const logSummarySchema = z.object({ total: z.number(), levels: z.record(logLevelSchema, z.number()),
buckets: z.array(z.object({ at: instant, critical: z.number(), error: z.number(), warning: z.number(), info: z.number(), debug: z.number(), unknown: z.number() })),
settings: logSettingsSchema, states: z.array(logStateSchema) })
export type LogSummary = z.infer<typeof logSummarySchema>
export const logStreamSchema = z.object({ entries: z.array(logEntrySchema), cursor: id, state: logStateSchema.nullable(), enabled: z.boolean() })
export type LogStream = z.infer<typeof logStreamSchema>
export type LogStreamReset = { reason: "database-maintenance" }
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/wireguard-context.spec.ts", "**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
testIgnore: ["**/logs-context.spec.ts", "**/wireguard-context.spec.ts", "**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false,
workers: 1,
retries: 0,
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "@playwright/test"
import base from "./playwright.config"
const server = base.webServer
if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер")
export default defineConfig({
...base, testIgnore: [], testMatch: "logs-context.spec.ts",
use: { ...base.use, baseURL: "http://127.0.0.1:3101" },
webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local", MM_NEXT_DIST_DIR: "tmp/next-logs-test" } },
})
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
import { logEntrySchema, logPageSchema, logSettingsSchema, logSettingsPatchSchema, logSummarySchema } from "@mmapp/contracts"
import { requestBlob, requestJson } from "./http-client"
const timedSignal = (signal?: AbortSignal, ms = 30000) => AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(ms)])
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Ответ сервера журнала не подтверждён: некорректные данные")
return parsed.data
}
export async function getLogs(base: string, params: URLSearchParams, signal?: AbortSignal) {
const result = confirmed(logPageSchema, await requestJson(base, "/api/logs?" + params, { signal: timedSignal(signal) }))
const server = params.get("serverId")
if (server && result.entries.some(row => String(row.serverId) !== server)) throw new Error("Журнал относится к другому устройству")
return result
}
export async function getLogSummary(base: string, params: URLSearchParams, signal?: AbortSignal) {
return confirmed(logSummarySchema, await requestJson(base, "/api/logs/summary?" + params, { signal: timedSignal(signal) }))
}
export async function getLogContext(base: string, id: string, signal?: AbortSignal) {
return confirmed(z.object({ entry: logEntrySchema, before: z.array(logEntrySchema), after: z.array(logEntrySchema) }), await requestJson(base, `/api/logs/${encodeURIComponent(id)}/context`, { signal: timedSignal(signal) }))
}
export async function getLogSettings(base: string, signal?: AbortSignal) { return confirmed(logSettingsSchema, await requestJson(base, "/api/logs/settings", { signal: timedSignal(signal) })) }
export async function putLogSettings(base: string, patch: z.infer<typeof logSettingsPatchSchema>, signal?: AbortSignal) {
const input = logSettingsPatchSchema.parse(patch)
const result = confirmed(z.object({ ok: z.literal(true), settings: logSettingsSchema }), await requestJson(base, "/api/logs/settings", { method: "PUT", body: JSON.stringify(input), signal: timedSignal(signal) }))
if (Object.entries(input).some(([key, value]) => result.settings[key as keyof typeof result.settings] !== value)) throw new Error("Сервер подтвердил другие настройки; обновите данные")
return result.settings
}
export async function exportLogs(base: string, params: URLSearchParams, format: "csv" | "json", signal?: AbortSignal) {
const query = new URLSearchParams(params); query.set("format", format)
const res = await requestBlob(base, "/api/logs/export?" + query, { signal: timedSignal(signal, 60000) })
const blob = await res.blob(), url = URL.createObjectURL(blob), anchor = document.createElement("a")
anchor.href = url; anchor.download = `router-logs.${format}`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+155
View File
@@ -0,0 +1,155 @@
import { test, expect, type Page } from "@playwright/test"
const headers = { "access-control-allow-origin": "http://127.0.0.1:3101", "access-control-allow-credentials": "true", "access-control-allow-methods": "GET,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const levels = { critical: 1, error: 0, warning: 1, info: 1, debug: 0, unknown: 0 }
const settings = { enabled: true, intervalSec: 10, retentionDays: 14 }
const servers = [1, 2].map(id => ({ id, name: `MikroTik-${id}`, host: `192.0.2.${id}`, enabled: true, country: "RU", type: "home-router", status: "online" }))
const row = (id: number, serverId = 1) => ({ id: String(id), serverId, serverName: `MikroTik-${serverId}`, host: `192.0.2.${serverId}`, routerId: `*${id}`, rawTime: "12:00:00", eventAt: null, receivedAt: new Date().toISOString(), effectiveAt: new Date().toISOString(), timeKind: "receipt", topics: ["system", id === 2 ? "warning" : id === 3 ? "error" : "info"], level: id === 2 ? "warning" : id === 3 ? "critical" : "info", message: `Событие ${id} сервера ${serverId} — interface ether1`, generation: 0 })
const state = (id: number) => ({ serverId: id, serverName: `MikroTik-${id}`, host: `192.0.2.${id}`, generation: 0, lastCollectedAt: new Date().toISOString(), lastError: null, gapAt: null, nextPollAt: null, failures: 0 })
async function setup(page: Page, options: { count?: number; historyError?: boolean } = {}) {
const calls: URL[] = []
await page.route("**/health", route => route.fulfill({ headers, json: { status: "ok" } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname
calls.push(url)
if (path === "/api/auth/config") return route.fulfill({ headers, json: { required: false } })
if (path === "/api/servers") return route.fulfill({ headers, json: servers })
if (path === "/api/logs/settings") return route.fulfill({ headers, json: route.request().method() === "PUT" ? { ok: true, settings: { ...settings, ...route.request().postDataJSON() } } : settings })
if (path === "/api/logs/summary") return route.fulfill({ headers, json: { settings, total: options.count ?? 3, levels, states: [state(1), state(2)], buckets: [{ at: new Date(Date.now() - 60000).toISOString(), ...levels }] } })
if (path === "/api/logs") return route.fulfill(options.historyError ? { headers, status: 503, json: { error: "История временно недоступна" } } : { headers, json: { entries: Array.from({ length: options.count ?? 3 }, (_, i) => row((options.count ?? 3) - i, Number(url.searchParams.get("serverId") ?? 1))), nextCursor: null, watermark: String(options.count ?? 3) } })
if (path === "/api/logs/live") return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [], cursor: url.searchParams.get("cursor"), state: state(Number(url.searchParams.get("serverId"))), enabled: true })}\n\n` })
if (/\/api\/logs\/\d+\/context$/.test(path)) { const id = Number(path.split("/").at(-2)); return route.fulfill({ headers, json: { entry: row(id), before: [row(1)], after: [row(4)] } }) }
if (path === "/api/logs/export") return route.fulfill({ headers: { ...headers, "content-type": "text/csv", "content-disposition": 'attachment; filename="router-logs.csv"' }, body: "time,message\nnow,test" })
return route.fulfill({ headers, json: {} })
})
return calls
}
test("Логи: общий выбор серверов, история и детали", async ({ page }) => {
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
const calls = await setup(page)
await page.goto("/logs")
await expect(page).toHaveURL(/server=1/)
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible()
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Открыть событие 3", exact: true }).click()
await expect(page.getByRole("dialog")).toBeVisible()
await expect(page.getByRole("heading", { name: "Событие RouterOS" })).toBeVisible()
await page.getByRole("tab", { name: "Исходные поля" }).click()
await expect(page.getByRole("tabpanel")).toContainText('".id": "*3"')
await page.getByRole("tab", { name: "Контекст", exact: true }).click()
await expect(page.getByRole("tabpanel")).toContainText("Событие 4")
await page.keyboard.press("Escape")
await page.getByRole("option", { name: /MikroTik-2/ }).click()
await expect(page).toHaveURL(/server=2/)
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
await expect.poll(() => calls.some(url => url.pathname === "/api/logs/live" && url.searchParams.get("serverId") === "2")).toBe(true)
await page.getByRole("option", { name: /Все серверы/ }).click()
await expect(page).toHaveURL(/server=all/)
await expect(page.getByText("Все серверы · сохранённая история")).toBeVisible()
expect(errors).toEqual([])
})
test("Логи: поиск в URL, представления и экспорт используют фильтры", async ({ page }) => {
const calls = await setup(page)
await page.goto("/logs?server=1&live=0")
await page.getByRole("textbox", { name: "Поиск по сообщениям" }).fill("ether1")
await page.getByRole("button", { name: "Найти события" }).click()
await expect(page).toHaveURL(/q=ether1/)
await expect.poll(() => calls.some(url => url.pathname === "/api/logs" && url.searchParams.get("q") === "ether1")).toBe(true)
await expect(page.locator("mark").first()).toHaveText("ether1")
await page.getByRole("textbox", { name: "Название представления" }).fill("Мой WAN")
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "CSV", exact: true }).click()
await download
expect(calls.find(url => url.pathname === "/api/logs/export")?.searchParams.get("q")).toBe("ether1")
await page.reload()
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
})
test("Логи: пауза буферизует новые события", async ({ page }) => {
await setup(page)
let release!: () => void, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/logs/live?**", async route => {
if (started) return route.fallback()
started = true; await gate
await route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [row(999)], cursor: "999", state: state(1), enabled: true })}\n\n` }).catch(() => {})
})
await page.goto("/logs?server=1")
await expect.poll(() => started).toBe(true)
await page.getByRole("button", { name: "Пауза", exact: true }).click()
release()
await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click()
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible()
})
test("Логи: поздний ответ прежнего устройства не меняет новый журнал", async ({ page }) => {
await setup(page)
let release!: () => void, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/logs?**", async route => {
if (new URL(route.request().url()).searchParams.get("serverId") !== "1") return route.fallback()
started = true; await gate
await route.fulfill({ headers, json: { entries: [{ ...row(888), message: "Поздний ответ первого устройства" }], watermark: "888", nextCursor: null } }).catch(() => {})
})
await page.goto("/logs?server=1")
await expect.poll(() => started).toBe(true)
await page.getByRole("option", { name: /MikroTik-2/ }).click()
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
release()
await expect(page.getByText("Поздний ответ первого устройства")).toHaveCount(0)
})
for (const width of [1440, 390]) for (const theme of ["light", "dark"]) test(`Логи: ${width}px ${theme}, виртуализация 5000 строк`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme)
await setup(page, { count: 5000 })
await page.goto("/logs?server=1&live=0")
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible({ timeout: 15000 })
await expect(page.getByText("Показаны последние 5 000 загруженных событий.", { exact: false })).toBeVisible()
expect(await page.locator("tbody tr").count()).toBeLessThan(60)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
if (width === 390) {
await page.getByRole("button", { name: "Серверы", exact: true }).click()
await expect(page.getByRole("option", { name: /MikroTik-2/ })).toBeVisible()
await page.keyboard.press("Escape")
}
await page.screenshot({ path: `test-results/logs-${width}-${theme}.png`, fullPage: true })
})
test("Логи: ошибка истории отображается с повтором", async ({ page }) => {
await setup(page, { historyError: true })
await page.goto("/logs?server=1&live=0")
await expect(page.getByText("История временно недоступна")).toBeVisible()
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
})
test("Логи: настройки сборщика сохраняют подтверждённые параметры", async ({ page }) => {
await setup(page)
const writes: unknown[] = []
await page.route("**/api/logs/settings", async route => {
if (route.request().method() !== "PUT") return route.fallback()
const payload = route.request().postDataJSON(); writes.push(payload)
await route.fulfill({ headers, json: { ok: true, settings: { ...settings, ...payload } } })
})
await page.goto("/data-collection#router-logs")
await expect(page.getByRole("textbox", { name: "Интервал сбора логов", exact: true })).toBeEnabled({ timeout: 15000 })
await page.getByRole("textbox", { name: "Интервал сбора логов", exact: true }).fill("20")
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("7")
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
await expect(page.getByText("Настройки сборщика логов сохранены", { exact: true })).toBeVisible()
expect(writes).toEqual([{ enabled: true, intervalSec: 20, retentionDays: 7 }])
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("0")
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
await expect(page.getByText("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней")).toBeVisible()
expect(writes).toHaveLength(1)
})
test("Логи: обслуживание БД требует нового снимка истории", async ({ page }) => {
await setup(page)
let connections = 0
await page.route("**/api/logs/live?**", route => {
connections++
return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: 'event: reset\ndata: {"reason":"database-maintenance"}\n\n' })
})
await page.goto("/logs?server=1")
await expect(page.getByText("Поток остановлен для обслуживания БД.", { exact: false })).toBeVisible()
expect(connections).toBe(1)
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
})