diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx index 21a069a5..6dd73209 100644 --- a/app/(main)/data-collection/page.tsx +++ b/app/(main)/data-collection/page.tsx @@ -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
{snap.skipped &&

Сбор логов выключен или приостановлен.

}{snap.servers.map(server =>

{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}

)}
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() { )} + {isLive && can("mm:settings:admin") && setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />} {isLive && ( <> } diff --git a/backend/drizzle/0014_router_logs.sql b/backend/drizzle/0014_router_logs.sql new file mode 100644 index 00000000..68a30120 --- /dev/null +++ b/backend/drizzle/0014_router_logs.sql @@ -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); diff --git a/backend/package.json b/backend/package.json index 4a10047b..69c92320 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5777d81b..cfddaf3e 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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().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() } diff --git a/backend/src/index.ts b/backend/src/index.ts index 4921b63e..aa1dc478 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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() diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts index 411a4186..5d35fafb 100644 --- a/backend/src/lib/permissions.ts +++ b/backend/src/lib/permissions.ts @@ -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" }, diff --git a/backend/src/plugins/auth.logs.test.ts b/backend/src/plugins/auth.logs.test.ts new file mode 100644 index 00000000..81353807 --- /dev/null +++ b/backend/src/plugins/auth.logs.test.ts @@ -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") diff --git a/backend/src/plugins/database-request-gate.ts b/backend/src/plugins/database-request-gate.ts index d2f1617e..0262426f 100644 --- a/backend/src/plugins/database-request-gate.ts +++ b/backend/src/plugins/database-request-gate.ts @@ -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) diff --git a/backend/src/routes/logs.ts b/backend/src/routes/logs.ts new file mode 100644 index 00000000..b4d96c5b --- /dev/null +++ b/backend/src/routes/logs.ts @@ -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() + 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 = { "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 diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 86f0a9b4..749fcdd9 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -715,13 +715,21 @@ export class MikrotikClient { return this.get(firewallRestPath(family, "address-list")) } + async getLogBuffer(): Promise { + const entries = await this.get("/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 { 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("/log?buffer=memory&.proplist=.id,time,topics,message") + const entries = await this.getLogBuffer() return entries.slice(-Math.min(limit, 200)) } diff --git a/backend/src/services/router-logs-collector.ts b/backend/src/services/router-logs-collector.ts new file mode 100644 index 00000000..1ce99648 --- /dev/null +++ b/backend/src/services/router-logs-collector.ts @@ -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 { + const { rows } = await pool.query(`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) { + 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() +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>() +let active = 0 +const waiters: (() => void)[] = [] +async function slot() { if (active >= 4) await new Promise(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 { + 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 | undefined +let retentionTimer: ReturnType | 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] : []) } diff --git a/backend/src/services/router-logs-model.test.ts b/backend/src/services/router-logs-model.test.ts new file mode 100644 index 00000000..48441daa --- /dev/null +++ b/backend/src/services/router-logs-model.test.ts @@ -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[]) => 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)"') +}) diff --git a/backend/src/services/router-logs-model.ts b/backend/src/services/router-logs-model.ts new file mode 100644 index 00000000..74e65164 --- /dev/null +++ b/backend/src/services/router-logs-model.ts @@ -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(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('"', '""') + '"' +} diff --git a/backend/src/services/router-logs-query.ts b/backend/src/services/router-logs-query.ts new file mode 100644 index 00000000..f2a61680 --- /dev/null +++ b/backend/src/services/router-logs-query.ts @@ -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(`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 + const buckets = new Map() + 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(`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(`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(`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(`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 } +} diff --git a/backend/src/services/router-logs-sql.test.ts b/backend/src/services/router-logs-sql.test.ts new file mode 100644 index 00000000..08f73ee5 --- /dev/null +++ b/backend/src/services/router-logs-sql.test.ts @@ -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(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(resolve => router.close(() => resolve())) + await pool.end() + await admin.query(`DROP SCHEMA ${schema} CASCADE`) + await admin.end() +} diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index a89d4cdf..da1945f0 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -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([ + "router_logs", "traffic", "servers_rest_ping", "uptime_resources", @@ -130,6 +133,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise { 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 { 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 = { + 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 }, diff --git a/backend/src/types/scheduler-run-snapshot.ts b/backend/src/types/scheduler-run-snapshot.ts index 78f83fb1..2448f5d7 100644 --- a/backend/src/types/scheduler-run-snapshot.ts +++ b/backend/src/types/scheduler-run-snapshot.ts @@ -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 diff --git a/components/logs/log-detail-sheet.tsx b/components/logs/log-detail-sheet.tsx new file mode 100644 index 00000000..5eaad03a --- /dev/null +++ b/components/logs/log-detail-sheet.tsx @@ -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 { if (!open) onClose() }}> + {entry && } + +} +function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) { + const [context, setContext] = useState> | null>(null), [error, setError] = useState(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 <> + +
{entry.routerId}
+ Событие RouterOS{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)} +
+
+ + СобытиеИсходные поляКонтекст + +

{entry.message}

+
+ {[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => )} +
+
+
{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}
+ +

До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.

+ {error ? Контекст не получен{error} : !context ? : [context.before, [context.entry], context.after].flat().map(row => )} +
+
+ +} +function FragmentPair({ label, value }: { label: string; value: string }) { return <>
{label}
{value}
} diff --git a/components/logs/log-presentation.tsx b/components/logs/log-presentation.tsx new file mode 100644 index 00000000..08dd88c9 --- /dev/null +++ b/components/logs/log-presentation.tsx @@ -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 = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" } +export const LEVEL_COLORS: Record = { 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 +} +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({text.slice(start, found)}{text.slice(found, found + search.length)}) + 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" }) : "—" } diff --git a/components/logs/logs-collector-settings.tsx b/components/logs/logs-collector-settings.tsx new file mode 100644 index 00000000..cda7e8b7 --- /dev/null +++ b/components/logs/logs-collector-settings.tsx @@ -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(null), [summary, setSummary] = useState(null) + const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false) + const [error, setError] = useState(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("") + const lifetime = useRef(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 Логи MikroTikREST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд. + {error && Данные сборщика не подтверждены{error}} + {!settings && !error && } +
+ {notice &&

{notice}

} +

Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.

+ {summary?.states.length ?
{summary.states.map(state =>
{state.serverName}{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}{formatLogTime(state.lastCollectedAt)}{state.gapAt && Возможный разрыв: {formatLogTime(state.gapAt)}}{state.lastError && {state.lastError}}
)}
:

Результатов сбора по устройствам пока нет.

} +
+} diff --git a/components/logs/logs-grid.tsx b/components/logs/logs-grid.tsx new file mode 100644 index 00000000..763aabd8 --- /dev/null +++ b/components/logs/logs-grid.tsx @@ -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[]>(() => [ + { accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => {formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && *} }, + { accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => {row.original.serverName} }, + { accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => }, + { accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => {row.original.topics.join(", ")} }, + { accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => }, + { id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => }, + ], [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

Событий за выбранный период нет

} + tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}> + +
Журнал событийЗагружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}
Колонки} />
+
{toolbar}
+
{ if ((event.target as HTMLElement).scrollTop > 16) onPause() }}> + element.getBoundingClientRect().height }} /> +
+
* Использовано время получения. Часовой пояс отображения: браузер.{hasMore && }
+
+ +
+} diff --git a/components/logs/logs-workspace.tsx b/components/logs/logs-workspace.tsx new file mode 100644 index 00000000..bd617d43 --- /dev/null +++ b/components/logs/logs-workspace.tsx @@ -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 = { "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, 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 }> } +function LogsRoute() { + const { backendUrl, mode, prefsHydrated } = useDataSource() + return prefsHydrated ? :

Загружаем источник…

+} +function LogsCatalog({ base, active }: { base: string; active: boolean }) { + const params = useSearchParams(), selectedId = params.get("server") ?? "" + const [servers, setServers] = useState>([]), [error, setError] = useState(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 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={} />}> + {!active ? Демонстрационный источникДля просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных. : error ? Каталог устройств не подтверждён{error} : !ready || !selectedId ? : } + +} +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(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(() => 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>([]), [exporting, setExporting] = useState(false) + const exportController = useRef(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 = { 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 Некорректные фильтры в ссылкеПроверьте устройство, уровни и период. + return
+

Логи MikroTik

{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}

+ + + +
+ {range === "custom" &&
{["from", "to"].map(key => )}
} +
{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}{live && }
+ {collectorEnabled === false && История доступна, сбор новых событий выключенАдминистратор может включить коллектор в разделе «Сбор данных».} + {(result.streamError || state?.lastError) && Live требует внимания{state?.lastError ?? result.streamError}} + {state?.gapAt && Возможен разрыв историиБуфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.} +
Поток событийВыберите столбец, чтобы открыть его период
{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => {label}: {count?.toLocaleString("ru-RU") ?? "—"})}
+ {result.summaryError ? {result.summaryError} : !result.summary ? : result.summary.buckets.length === 0 ?
За выбранный период событий нет
: [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}> + { 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" }) }}> new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /> formatLogTime(String(value))} />} />{logLevels.map(level => )} + } +
+ {result.error && Журнал не загружен{result.error}} + +
{ event.preventDefault(); navigate({ q: search || null }) }}> setSearch(event.target.value)} />
+ 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} /> +
setViewName(event.target.value)} />{views.map(view => )}
+ } /> + {result.entries.length >= 5000 &&

Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.

} +

REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.

+ setSelected(null)} onSelect={openEntry} /> +
+} diff --git a/docs/logs.md b/docs/logs.md new file mode 100644 index 00000000..f0b7848e --- /dev/null +++ b/docs/logs.md @@ -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=` или через общий журнал. Новые таблицы входят в полный `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` и права создать временную схему; при недоступной БД завершится ошибкой, +а не выдаст пропуск за успешную проверку. diff --git a/hooks/use-router-logs.ts b/hooks/use-router-logs.ts new file mode 100644 index 00000000..2eebbc22 --- /dev/null +++ b/hooks/use-router-logs.ts @@ -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([]), [page, setPage] = useState(null) + const [summary, setSummary] = useState(null), [stream, setStream] = useState(null) + const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false) + const [error, setError] = useState(null), [summaryError, setSummaryError] = useState(null), [streamError, setStreamError] = useState(null) + const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0) + const pending = useRef([]), pausedRef = useRef(paused), initialCursor = useRef(null) + const lifetime = useRef(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 | 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(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 } +} diff --git a/lib/auth.ts b/lib/auth.ts index fa405b30..ec9784c4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -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" diff --git a/lib/navigation.ts b/lib/navigation.ts index 37bb7d8b..91eb5473 100644 --- a/lib/navigation.ts +++ b/lib/navigation.ts @@ -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"] }, diff --git a/lib/scheduler-run-snapshot.ts b/lib/scheduler-run-snapshot.ts index 2054a886..04d122fa 100644 --- a/lib/scheduler-run-snapshot.ts +++ b/lib/scheduler-run-snapshot.ts @@ -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 = { traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" } + const arrays: Record = { 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 { diff --git a/lib/scheduler-settings.ts b/lib/scheduler-settings.ts index ab22fec3..c0f2868a 100644 --- a/lib/scheduler-settings.ts +++ b/lib/scheduler-settings.ts @@ -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 = { + router_logs: "Логи MikroTik", traffic: "Трафик", servers_rest_ping: "Серверы: REST API", uptime_resources: "Uptime: ресурсы", @@ -32,6 +34,7 @@ export const SCHEDULER_JOB_LABELS: Record = { /** Кратко — для раскрытой карточки прогона и подсказок. */ export const SCHEDULER_JOB_DESCRIPTIONS: Record = { + router_logs: "REST-сбор буфера memory в PostgreSQL. Параметры — в панели «Логи MikroTik» ниже; live ускоряет опрос выбранного устройства до 2 секунд.", traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в PostgreSQL.", servers_rest_ping: "Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.", diff --git a/next.config.ts b/next.config.ts index b0c1165c..e6a3c254 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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", diff --git a/package.json b/package.json index 58d8055a..d31d0a7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9177d031..432c70b4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./servers.js" +export * from "./logs.js" export * from "./alerts.js" export * from "./events.js" export * from "./certificates.js" diff --git a/packages/contracts/src/logs.ts b/packages/contracts/src/logs.ts new file mode 100644 index 00000000..41390711 --- /dev/null +++ b/packages/contracts/src/logs.ts @@ -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 +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 +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 +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 +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 +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 +export const logStreamSchema = z.object({ entries: z.array(logEntrySchema), cursor: id, state: logStateSchema.nullable(), enabled: z.boolean() }) +export type LogStream = z.infer +export type LogStreamReset = { reason: "database-maintenance" } diff --git a/playwright.config.ts b/playwright.config.ts index 7d545654..1560eb88 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/playwright.logs-context.config.ts b/playwright.logs-context.config.ts new file mode 100644 index 00000000..44500c77 --- /dev/null +++ b/playwright.logs-context.config.ts @@ -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" } }, +}) diff --git a/shared/api/logs.ts b/shared/api/logs.ts new file mode 100644 index 00000000..5541a3e3 --- /dev/null +++ b/shared/api/logs.ts @@ -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(schema: z.ZodType, 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, 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) +} diff --git a/tests/e2e/logs-context.spec.ts b/tests/e2e/logs-context.spec.ts new file mode 100644 index 00000000..f2c52b40 --- /dev/null +++ b/tests/e2e/logs-context.spec.ts @@ -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(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(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() +})