Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5efd0339fb |
No files matched your search
@@ -58,6 +58,7 @@ import {
|
||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
@@ -84,6 +85,7 @@ function fmtMs(ms: number): string {
|
||||
}
|
||||
|
||||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "router_logs") return <div className="flex flex-col gap-2 text-sm">{snap.skipped && <p>Сбор логов выключен или приостановлен.</p>}{snap.servers.map(server => <p key={server.serverId}>{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}</p>)}</div>
|
||||
if (snap.job === "traffic") {
|
||||
const t = snap as TrafficRunSnapshot
|
||||
return (
|
||||
@@ -759,7 +761,7 @@ function DataCollectionContent() {
|
||||
return SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
|
||||
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
|
||||
const enabled = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
@@ -959,6 +961,7 @@ function DataCollectionContent() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLive && can("mm:settings:admin") && <LogsCollectorSettings key={backendUrl} base={backendUrl} onSaved={settings => setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
|
||||
{isLive && (
|
||||
<>
|
||||
<KpiStatGrid
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { LogsWorkspace } from "@/components/logs/logs-workspace"
|
||||
export default function LogsPage() { return <LogsWorkspace /> }
|
||||
@@ -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);
|
||||
@@ -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": {
|
||||
|
||||
@@ -20,6 +20,22 @@ function ts(name: string) {
|
||||
return timestamp(name, { withTimezone: true, mode: "string" as const })
|
||||
}
|
||||
|
||||
export const routerLogSettings = pgTable("router_log_settings", {
|
||||
id: integer("id").primaryKey(), enabled: boolean("enabled").notNull().default(false),
|
||||
intervalSec: integer("interval_sec").notNull().default(10), retentionDays: integer("retention_days").notNull().default(14),
|
||||
})
|
||||
export const routerLogCollectionState = pgTable("router_log_collection_state", {
|
||||
serverId: integer("server_id").primaryKey(), serverName: text("server_name").notNull(), host: text("host").notNull(),
|
||||
generation: integer("generation").notNull().default(0), snapshot: jsonb("snapshot").$type<string[]>().notNull().default([]),
|
||||
lastCollectedAt: ts("last_collected_at"), lastError: text("last_error"), gapAt: ts("gap_at"), nextPollAt: ts("next_poll_at"), failures: integer("failures").notNull().default(0),
|
||||
})
|
||||
export const routerLogEntries = pgTable("router_log_entries", {
|
||||
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(), serverId: integer("server_id").notNull(),
|
||||
serverName: text("server_name").notNull(), host: text("host").notNull(), routerId: text("router_id").notNull(), rawTime: text("raw_time").notNull(),
|
||||
eventAt: ts("event_at"), receivedAt: ts("received_at").notNull(), effectiveAt: ts("effective_at").notNull(),
|
||||
topics: text("topics").array().notNull(), level: text("level").notNull(), message: text("message").notNull(), generation: integer("generation").notNull(),
|
||||
}, table => [index("router_logs_server_time").on(table.serverId, table.effectiveAt, table.id), index("router_logs_time").on(table.effectiveAt, table.id), index("router_logs_level_time").on(table.level, table.effectiveAt), index("router_logs_topics").using("gin", table.topics), index("router_logs_received").on(table.receivedAt), index("router_logs_server_cursor").on(table.serverId, table.id)])
|
||||
|
||||
function idIdentity() {
|
||||
return bigint("id", { mode: "number" }).generatedByDefaultAsIdentity()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { FastifyPluginAsync } from "fastify"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { once } from "node:events"
|
||||
import { logFiltersSchema, logPageQuerySchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||
import { pool } from "../db/index.js"
|
||||
import { env } from "../config.js"
|
||||
import { registerDatabaseStreamAbort } from "../plugins/database-request-gate.js"
|
||||
import { getLogSettings, subscribeLogDevice, updateLogSettings } from "../services/router-logs-collector.js"
|
||||
import { readLogPage, readLogSummary, readLogEntry, readLogContext, readLogStream, logColumns } from "../services/router-logs-query.js"
|
||||
import { csvCell, logWhere } from "../services/router-logs-model.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import { isSchedulerJobsPaused } from "../services/scheduler-running.js"
|
||||
|
||||
const logsRoutes: FastifyPluginAsync = async app => {
|
||||
const streams = new Set<AbortController>()
|
||||
app.addHook("preClose", async () => { for (const stream of streams) stream.abort() })
|
||||
app.get("/logs", async req => {
|
||||
const parsed = logPageQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
|
||||
return readLogPage(parsed.data, parsed.data)
|
||||
})
|
||||
app.get("/logs/summary", async req => {
|
||||
const parsed = logFiltersSchema.safeParse(req.query)
|
||||
if (!parsed.success) throw Object.assign(new Error("Некорректные фильтры журнала"), { statusCode: 400 })
|
||||
return readLogSummary(parsed.data)
|
||||
})
|
||||
app.get("/logs/settings", async () => getLogSettings())
|
||||
app.put("/logs/settings", async (req, reply) => {
|
||||
const parsed = logSettingsPatchSchema.safeParse(req.body)
|
||||
if (!parsed.success) return reply.code(400).send({ error: "Некорректные настройки сборщика логов" })
|
||||
const settings = await updateLogSettings(parsed.data)
|
||||
await refreshScheduler()
|
||||
return { ok: true, settings }
|
||||
})
|
||||
app.get("/logs/export", async (req, reply) => {
|
||||
const parsed = logFiltersSchema.safeParse(req.query)
|
||||
const format = (req.query as { format?: string }).format ?? "csv"
|
||||
if (!parsed.success || !["csv", "json"].includes(format)) return reply.code(400).send({ error: "Некорректный экспорт" })
|
||||
const where = logWhere(parsed.data)
|
||||
const rows = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT 50001`, where.values)).rows
|
||||
if (rows.length > 50000) return reply.code(422).send({ error: "Найдено более 50 000 событий. Сузьте период или фильтры для экспорта." })
|
||||
reply.header("Content-Disposition", `attachment; filename="router-logs.${format}"`)
|
||||
if (format === "json") return reply.type("application/json; charset=utf-8").send(JSON.stringify(rows))
|
||||
const columns = ["effectiveAt", "serverName", "host", "level", "topics", "message", "rawTime", "receivedAt", "timeKind"]
|
||||
const csv = [columns.map(csvCell).join(","), ...rows.map(row => columns.map(key => csvCell(Array.isArray(row[key]) ? row[key].join(",") : row[key] instanceof Date ? row[key].toISOString() : row[key])).join(","))].join("\r\n")
|
||||
return reply.type("text/csv; charset=utf-8").send("\uFEFF" + csv)
|
||||
})
|
||||
app.get("/logs/live", async (req, reply) => {
|
||||
const parsed = logFiltersSchema.safeParse(req.query)
|
||||
const cursor = (req.query as { cursor?: string }).cursor
|
||||
if (!parsed.success || !parsed.data.serverId || !cursor || !/^\d{1,20}$/.test(cursor)) return reply.code(400).send({ error: "Выберите устройство и передайте курсор истории" })
|
||||
const filters = parsed.data
|
||||
const available = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
|
||||
if (!available.rowCount) return reply.code(404).send({ error: "Устройство отключено или удалено" })
|
||||
if (isSchedulerJobsPaused()) return reply.code(503).send({ error: "Сбор приостановлен для обслуживания БД" })
|
||||
const controller = new AbortController()
|
||||
streams.add(controller)
|
||||
const onClose = () => controller.abort()
|
||||
registerDatabaseStreamAbort(req, () => {
|
||||
if (reply.raw.headersSent && !reply.raw.destroyed) reply.raw.write('event: reset\ndata: {"reason":"database-maintenance"}\n\n')
|
||||
controller.abort()
|
||||
})
|
||||
req.raw.on("close", onClose)
|
||||
const unsubscribe = subscribeLogDevice(filters.serverId!)
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0); reply.raw.setTimeout(0)
|
||||
const headers: Record<string, string> = { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no" }
|
||||
const origin = req.headers.origin
|
||||
if (origin && (env.CORS_ORIGIN === "*" || env.CORS_ORIGIN === origin)) { headers["Access-Control-Allow-Origin"] = origin; headers["Access-Control-Allow-Credentials"] = "true"; headers.Vary = "Origin" }
|
||||
reply.raw.writeHead(200, headers)
|
||||
let after = cursor
|
||||
try {
|
||||
while (!controller.signal.aborted && !isSchedulerJobsPaused()) {
|
||||
// Long connections must not outlive the JWT they were authenticated with.
|
||||
if (env.authRequired && req.user.exp && Date.now() >= req.user.exp * 1000) break
|
||||
const exists = await pool.query("SELECT id FROM servers WHERE id=$1 AND enabled=true", [filters.serverId])
|
||||
if (!exists.rowCount) { reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Устройство отключено или удалено" })}\n\n`); break }
|
||||
const batch = await readLogStream(filters, after)
|
||||
after = batch.cursor
|
||||
if (!reply.raw.write(`event: batch\nid: ${after}\ndata: ${JSON.stringify(batch)}\n\n`)) await once(reply.raw, "drain", { signal: controller.signal })
|
||||
if (batch.entries.length < 200) await sleep(1000, undefined, { signal: controller.signal })
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted && !reply.raw.destroyed) reply.raw.write(`event: error\ndata: ${JSON.stringify({ error: "Поток прерван; выполняется переподключение" })}\n\n`)
|
||||
req.log.debug({ err: error }, "Logs SSE closed")
|
||||
} finally { streams.delete(controller); unsubscribe(); req.raw.off("close", onClose); reply.raw.end() }
|
||||
})
|
||||
app.get("/logs/:id", async (req, reply) => {
|
||||
const id = String((req.params as { id: string }).id)
|
||||
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
|
||||
const entry = await readLogEntry(id)
|
||||
return entry ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
|
||||
})
|
||||
app.get("/logs/:id/context", async (req, reply) => {
|
||||
const id = String((req.params as { id: string }).id)
|
||||
if (!/^\d{1,20}$/.test(id)) return reply.code(400).send({ error: "Некорректный ID" })
|
||||
return await readLogContext(id) ?? reply.code(404).send({ error: "Событие не найдено или срок хранения истёк" })
|
||||
})
|
||||
}
|
||||
export default logsRoutes
|
||||
@@ -715,13 +715,21 @@ export class MikrotikClient {
|
||||
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
|
||||
}
|
||||
|
||||
async getLogBuffer(): Promise<RosLogEntry[]> {
|
||||
const entries = await this.get<unknown>("/log?buffer=memory&.proplist=.id,time,topics,message", 8_000)
|
||||
if (!Array.isArray(entries) || entries.some(e => !e || [".id", "time", "topics", "message"].some(k => typeof e[k] !== "string"))) {
|
||||
throw new Error("RouterOS: некорректный буфер логов")
|
||||
}
|
||||
return entries as RosLogEntry[]
|
||||
}
|
||||
|
||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1) {
|
||||
throw new RangeError("limit должен быть положительным целым числом.")
|
||||
}
|
||||
// REST query parameters filter properties; limit is not a print row limit.
|
||||
// Read only the default buffer and retain its latest entries in native order.
|
||||
const entries = await this.get<RosLogEntry[]>("/log?buffer=memory&.proplist=.id,time,topics,message")
|
||||
const entries = await this.getLogBuffer()
|
||||
return entries.slice(-Math.min(limit, 200))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { LogSettings } from "@mmapp/contracts"
|
||||
import { pool, dbAll } from "../db/index.js"
|
||||
import type { Server } from "../db/schema.js"
|
||||
import { listEnabledMonitoringServers } from "./monitoring-server-catalog.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { logLevel, logSignature, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
|
||||
import { endSchedulerJob, isSchedulerJobsPaused, tryBeginSchedulerJob } from "./scheduler-running.js"
|
||||
import type { RouterLogsRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
|
||||
export async function getLogSettings(): Promise<LogSettings> {
|
||||
const { rows } = await pool.query<LogSettings>(`SELECT enabled, interval_sec AS "intervalSec", retention_days AS "retentionDays" FROM router_log_settings WHERE id=1`)
|
||||
if (!rows[0]) throw new Error("Настройки логов не найдены; проверьте миграцию БД")
|
||||
return rows[0]
|
||||
}
|
||||
export async function updateLogSettings(patch: Partial<LogSettings>) {
|
||||
await pool.query(`UPDATE router_log_settings SET enabled=COALESCE($1,enabled), interval_sec=COALESCE($2,interval_sec), retention_days=COALESCE($3,retention_days) WHERE id=1`, [patch.enabled, patch.intervalSec, patch.retentionDays])
|
||||
return getLogSettings()
|
||||
}
|
||||
|
||||
const liveDevices = new Map<number, number>()
|
||||
export function subscribeLogDevice(serverId: number) {
|
||||
liveDevices.set(serverId, (liveDevices.get(serverId) ?? 0) + 1)
|
||||
let released = false
|
||||
return () => { if (released) return; released = true; const n = (liveDevices.get(serverId) ?? 1) - 1; if (n) liveDevices.set(serverId, n); else liveDevices.delete(serverId) }
|
||||
}
|
||||
const inflight = new Map<number, Promise<{ added: number; gap: boolean; error?: string }>>()
|
||||
let active = 0
|
||||
const waiters: (() => void)[] = []
|
||||
async function slot() { if (active >= 4) await new Promise<void>(resolve => waiters.push(resolve)); else active++ }
|
||||
function release() { const next = waiters.shift(); if (next) next(); else active-- }
|
||||
|
||||
async function collectDevice(server: Server, settings: LogSettings, force: boolean) {
|
||||
await slot()
|
||||
const client = await pool.connect().catch(error => { release(); throw error })
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
const lock = await client.query<{ locked: boolean }>("SELECT pg_try_advisory_xact_lock(872314,$1) AS locked", [server.id])
|
||||
if (!lock.rows[0].locked || isSchedulerJobsPaused()) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
|
||||
const admission = await client.query<{ enabled: boolean }>("SELECT enabled FROM router_log_settings WHERE id=1")
|
||||
if (!admission.rows[0]?.enabled) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
|
||||
await client.query(`INSERT INTO router_log_collection_state(server_id,server_name,host) VALUES($1,$2,$3) ON CONFLICT DO NOTHING`, [server.id, server.name, server.host])
|
||||
const prev = (await client.query<{ snapshot: string[]; generation: number; last_collected_at: Date | null; next_poll_at: Date | null; failures: number }>("SELECT * FROM router_log_collection_state WHERE server_id=$1 FOR UPDATE", [server.id])).rows[0]
|
||||
const now = Date.now()
|
||||
const interval = liveDevices.has(server.id) ? 2000 : settings.intervalSec * 1000
|
||||
const backoff = prev.failures ? Math.min(60_000, interval * 2 ** Math.min(prev.failures, 5)) : interval
|
||||
if (!force && (prev.next_poll_at && prev.next_poll_at.getTime() > now || prev.last_collected_at && now - prev.last_collected_at.getTime() < interval)) {
|
||||
await client.query("COMMIT"); return { added: 0, gap: false }
|
||||
}
|
||||
let rows
|
||||
try { rows = await MikrotikClient.fromServer(server).getLogBuffer() }
|
||||
catch (error) {
|
||||
const message = (error instanceof Error ? error.message : String(error)).slice(0, 1024)
|
||||
await client.query(`UPDATE router_log_collection_state SET last_error=$2, failures=failures+1, next_poll_at=$3,server_name=$4,host=$5 WHERE server_id=$1`, [server.id, message, new Date(now + backoff).toISOString(), server.name, server.host])
|
||||
await client.query("COMMIT")
|
||||
return { added: 0, gap: false, error: message }
|
||||
}
|
||||
const current = await client.query("SELECT id FROM servers WHERE id=$1 AND enabled=true AND updated_at=$2", [server.id, server.updatedAt])
|
||||
if (!current.rowCount) { await client.query("ROLLBACK"); return { added: 0, gap: false } }
|
||||
const snapshot = rows.map(logSignature)
|
||||
const { offset, gap } = reconcileLogSnapshot(prev.snapshot, snapshot)
|
||||
const generation = prev.generation + (gap ? 1 : 0)
|
||||
const receivedAt = new Date().toISOString()
|
||||
const newRows = rows.slice(offset).map(row => {
|
||||
const topics = row.topics.split(",").map(t => t.trim()).filter(Boolean)
|
||||
const eventAt = parseLogTime(row.time)
|
||||
return { server_id: server.id, server_name: server.name, host: server.host, router_id: row[".id"], raw_time: row.time, event_at: eventAt, received_at: receivedAt, effective_at: eventAt ?? receivedAt, topics, level: logLevel(topics), message: row.message, generation }
|
||||
})
|
||||
if (newRows.length) await client.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation)
|
||||
SELECT server_id,server_name,host,router_id,raw_time,event_at,received_at,effective_at,topics,level,message,generation
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(server_id int,server_name text,host text,router_id text,raw_time text,event_at timestamptz,received_at timestamptz,effective_at timestamptz,topics text[],level text,message text,generation int)`, [JSON.stringify(newRows)])
|
||||
await client.query(`UPDATE router_log_collection_state SET snapshot=$2::jsonb,generation=$3,last_collected_at=$4,last_error=NULL,failures=0,next_poll_at=NULL,gap_at=CASE WHEN $5 THEN $4::timestamptz ELSE gap_at END,server_name=$6,host=$7 WHERE server_id=$1`, [server.id, JSON.stringify(snapshot), generation, receivedAt, gap, server.name, server.host])
|
||||
await client.query("COMMIT")
|
||||
return { added: newRows.length, gap }
|
||||
} catch (error) { await client.query("ROLLBACK"); throw error }
|
||||
finally { client.release(); release() }
|
||||
}
|
||||
function collectShared(server: Server, settings: LogSettings, force = false) {
|
||||
const pending = inflight.get(server.id)
|
||||
if (pending) return pending
|
||||
const task = collectDevice(server, settings, force)
|
||||
inflight.set(server.id, task)
|
||||
void task.then(() => inflight.delete(server.id), () => inflight.delete(server.id))
|
||||
return task
|
||||
}
|
||||
|
||||
export async function collectRouterLogsOnce(): Promise<RouterLogsRunSnapshot> {
|
||||
const settings = await getLogSettings()
|
||||
const result: RouterLogsRunSnapshot = { v: 1, job: "router_logs", sampledAt: new Date().toISOString(), servers: [], errors: [] }
|
||||
if (!settings.enabled || isSchedulerJobsPaused()) return { ...result, skipped: true }
|
||||
const servers = await listEnabledMonitoringServers()
|
||||
result.servers = await Promise.all(servers.map(async server => {
|
||||
try { return { serverId: server.id, name: server.name, ...await collectShared(server, settings) } }
|
||||
catch (error) { return { serverId: server.id, name: server.name, added: 0, gap: false, error: error instanceof Error ? error.message : String(error) } }
|
||||
}))
|
||||
result.errors = result.servers.flatMap(row => row.error ? [`${row.name}: ${row.error}`] : [])
|
||||
await pruneRouterLogHistory(settings.retentionDays)
|
||||
return result
|
||||
}
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let retentionTimer: ReturnType<typeof setInterval> | undefined
|
||||
export async function pruneRouterLogHistory(retentionDays: number) {
|
||||
await pool.query(`DELETE FROM router_log_entries WHERE id IN (SELECT id FROM router_log_entries WHERE received_at < now() - ($1 * interval '1 day') ORDER BY received_at LIMIT 10000)`, [retentionDays])
|
||||
}
|
||||
export function stopLogsLiveTimer() { if (timer) clearInterval(timer); if (retentionTimer) clearInterval(retentionTimer); timer = undefined; retentionTimer = undefined }
|
||||
export function startLogsLiveTimer() {
|
||||
stopLogsLiveTimer()
|
||||
// Retention continues even when collection is disabled, and drains before DB maintenance.
|
||||
const prune = () => {
|
||||
if (!tryBeginSchedulerJob("router_logs_retention")) return
|
||||
void getLogSettings().then(settings => pruneRouterLogHistory(settings.retentionDays)).catch(() => {}).finally(() => endSchedulerJob("router_logs_retention"))
|
||||
}
|
||||
retentionTimer = setInterval(prune, 60_000); retentionTimer.unref()
|
||||
timer = setInterval(() => {
|
||||
if (!liveDevices.size || !tryBeginSchedulerJob("router_logs_live")) return
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await getLogSettings()
|
||||
if (!settings.enabled) return
|
||||
const servers = (await listEnabledMonitoringServers()).filter(s => liveDevices.has(s.id))
|
||||
await Promise.allSettled(servers.map(server => collectShared(server, settings)))
|
||||
} finally { endSchedulerJob("router_logs_live") }
|
||||
})().catch(() => {})
|
||||
}, 1000)
|
||||
timer.unref()
|
||||
}
|
||||
|
||||
export const logStateSql = `SELECT server_id AS "serverId", server_name AS "serverName", host, generation, last_collected_at AS "lastCollectedAt", last_error AS "lastError", gap_at AS "gapAt", next_poll_at AS "nextPollAt", failures FROM router_log_collection_state`
|
||||
export async function getLogStates(serverId?: number) { return dbAll(logStateSql + (serverId ? " WHERE server_id=$1" : " ORDER BY server_name"), serverId ? [serverId] : []) }
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { logFiltersSchema, logSettingsPatchSchema } from "@mmapp/contracts"
|
||||
import { csvCell, logLevel, logSignature, logWhere, parseLogTime, reconcileLogSnapshot } from "./router-logs-model.js"
|
||||
import { permissionForRequest } from "../lib/permissions.js"
|
||||
|
||||
test("buffer overlap survives rotation; real repeated messages and reused IDs remain events", () => {
|
||||
const row = (id: string, message = "same message", time = "08:00:00") => ({ ".id": id, time, message, topics: "system,info" })
|
||||
const a = row("*A"), b = row("*B"), c = row("*C"), d = row("*D")
|
||||
const sig = (...rows: ReturnType<typeof row>[]) => rows.map(logSignature)
|
||||
assert.deepEqual(reconcileLogSnapshot([], sig(a, b)), { offset: 0, gap: false })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b)), { offset: 2, gap: false })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a, b, c), sig(b, c, d)), { offset: 2, gap: false })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(a, b, c)), { offset: 2, gap: false })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a, b), sig(row("*A", "new boot"))), { offset: 0, gap: true })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a, b), []), { offset: 0, gap: true })
|
||||
assert.deepEqual(reconcileLogSnapshot(sig(a), sig(row("*A", "same message", "08:00:01"))), { offset: 0, gap: true })
|
||||
const previous = Array.from({ length: 100_000 }, (_, i) => String(i))
|
||||
assert.equal(reconcileLogSnapshot(previous, [...previous.slice(100), "next"]).offset, 99900)
|
||||
})
|
||||
test("severity and time never fabricate RouterOS facts", () => {
|
||||
assert.equal(logLevel(["system", "error", "critical"]), "critical")
|
||||
assert.equal(logLevel(["firewall"]), "unknown")
|
||||
for (const time of ["08:00:00", "oct/16 08:00:00", "2026-10-16 08:00:00", "bad"]) assert.equal(parseLogTime(time), null)
|
||||
assert.equal(parseLogTime("2026-10-16T08:00:00+07:00"), "2026-10-16T01:00:00.000Z")
|
||||
assert.equal(parseLogTime("2026-02-30T08:00:00Z"), null)
|
||||
})
|
||||
test("filters compile parameterized literal search with OR inside fields", () => {
|
||||
const filters = logFiltersSchema.parse({ serverId: "1", levels: "critical,error", topics: "system,firewall", q: "%' OR true --" })
|
||||
const query = logWhere(filters)
|
||||
assert.ok(query.sql.includes("level = ANY")); assert.ok(query.sql.includes("topics &&"))
|
||||
assert.ok(!query.sql.includes(filters.q)); assert.ok(query.values.includes(filters.q))
|
||||
assert.equal(logFiltersSchema.safeParse({ levels: "panic" }).success, false)
|
||||
assert.equal(logFiltersSchema.safeParse({ from: "2026-10-17T00:00:00Z", to: "2026-10-16T00:00:00Z" }).success, false)
|
||||
assert.equal(logSettingsPatchSchema.safeParse({ enabled: true, retentionDays: 0 }).success, false)
|
||||
assert.equal(logSettingsPatchSchema.safeParse({ intervalSec: 1 }).success, false)
|
||||
assert.equal(logSettingsPatchSchema.safeParse({ unknown: true }).success, false)
|
||||
})
|
||||
test("log endpoints use existing read and admin grants", () => {
|
||||
for (const path of ["/api/logs", "/api/logs/live", "/api/logs/summary", "/api/logs/export", "/api/logs/12/context"]) assert.equal(permissionForRequest("GET", path), "mm:servers:read")
|
||||
for (const method of ["GET", "PUT"]) assert.equal(permissionForRequest(method, "/api/logs/settings"), "mm:settings:admin")
|
||||
})
|
||||
test("CSV escapes quotes, multiline text and spreadsheet formulas", () => {
|
||||
assert.equal(csvCell('hello,"router"\nline'), '"hello,""router""\nline"')
|
||||
assert.equal(csvCell("=HYPERLINK(1)"), '"\'=HYPERLINK(1)"')
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LogFilters, LogLevel } from "@mmapp/contracts"
|
||||
import type { RosLogEntry } from "../types/server.js"
|
||||
import { createHash } from "node:crypto"
|
||||
|
||||
export function logLevel(topics: string[]): LogLevel {
|
||||
return (["critical", "error", "warning", "info", "debug"] as const).find(level => topics.includes(level)) ?? "unknown"
|
||||
}
|
||||
export function logSignature(entry: RosLogEntry): string {
|
||||
return createHash("sha256").update(JSON.stringify([entry[".id"], entry.time, entry.topics, entry.message])).digest("hex")
|
||||
}
|
||||
/** Longest suffix/prefix overlap, linear in buffer size. Signatures include RouterOS identity. */
|
||||
export function reconcileLogSnapshot(previous: string[], current: string[]) {
|
||||
if (!previous.length) return { offset: 0, gap: false }
|
||||
if (!current.length) return { offset: 0, gap: true }
|
||||
const combined: (string | null)[] = [...current, null, ...previous]
|
||||
const pi = new Array<number>(combined.length).fill(0)
|
||||
for (let i = 1; i < combined.length; i++) {
|
||||
let j = pi[i - 1]
|
||||
while (j && combined[i] !== combined[j]) j = pi[j - 1]
|
||||
if (combined[i] === combined[j]) j++
|
||||
pi[i] = j
|
||||
}
|
||||
const offset = pi[pi.length - 1]
|
||||
return { offset, gap: offset === 0 }
|
||||
}
|
||||
export function parseLogTime(raw: string): string | null {
|
||||
// RouterOS timestamps without an explicit offset cannot be interpreted as UTC safely.
|
||||
if (!/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(raw)) return null
|
||||
const date = raw.slice(0, 10), calendar = new Date(date + "T00:00:00Z")
|
||||
if (!Number.isFinite(calendar.getTime()) || calendar.toISOString().slice(0, 10) !== date || Number(raw.slice(11, 13)) > 23) return null
|
||||
const ms = Date.parse(raw.replace(" ", "T"))
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
||||
}
|
||||
export function logWhere(filters: LogFilters, extra: string[] = [], initial: unknown[] = []) {
|
||||
const values = [...initial], clauses = [...extra]
|
||||
const bind = (value: unknown) => { values.push(value); return `$${values.length}` }
|
||||
if (filters.serverId) clauses.push(`server_id = ${bind(filters.serverId)}`)
|
||||
if (filters.from) clauses.push(`effective_at >= ${bind(filters.from)}::timestamptz`)
|
||||
if (filters.to) clauses.push(`effective_at <= ${bind(filters.to)}::timestamptz`)
|
||||
if (filters.levels) clauses.push(`level = ANY(${bind(filters.levels.split(","))}::text[])`)
|
||||
if (filters.topics) clauses.push(`topics && ${bind(filters.topics.split(",").map(t => t.trim()).filter(Boolean))}::text[]`)
|
||||
if (filters.q) clauses.push(`strpos(lower(message), lower(${bind(filters.q)})) > 0`)
|
||||
if (filters.preset === "login-failure") clauses.push(`(lower(message) LIKE '%login failure%' OR lower(message) LIKE '%login failed%' OR lower(message) LIKE '%authentication failed%')`)
|
||||
if (filters.preset === "interfaces") clauses.push(`topics && ARRAY['interface','bridge','ethernet','wireless','wifi']::text[]`)
|
||||
if (filters.preset === "routing") clauses.push(`topics && ARRAY['route','routing','bgp','ospf','bfd']::text[]`)
|
||||
if (filters.preset === "firewall") clauses.push(`'firewall' = ANY(topics)`)
|
||||
return { sql: clauses.length ? clauses.join(" AND ") : "TRUE", values }
|
||||
}
|
||||
export function csvCell(value: unknown): string {
|
||||
let text = String(value ?? "")
|
||||
if (/^(?:\s*[=+\-@]|[\t\r])/.test(text)) text = "'" + text
|
||||
return '"' + text.replaceAll('"', '""') + '"'
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { PoolClient } from "pg"
|
||||
import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts"
|
||||
import { pool } from "../db/index.js"
|
||||
import { getLogSettings, getLogStates } from "./router-logs-collector.js"
|
||||
import { logWhere } from "./router-logs-model.js"
|
||||
|
||||
export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation`
|
||||
export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") }
|
||||
export function decodeLogCursor(cursor: string): [string, string] {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(cursor, "base64url").toString())
|
||||
if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && Number.isFinite(Date.parse(value[0])) && /^\d+$/.test(value[1])) return value as [string, string]
|
||||
} catch { /* invalid cursor */ }
|
||||
throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 })
|
||||
}
|
||||
export async function logWatermark(client: PoolClient, serverId?: number) {
|
||||
const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${serverId ? " WHERE server_id=$1" : ""}`, serverId ? [serverId] : [])
|
||||
return res.rows[0].id
|
||||
}
|
||||
export async function readLogPage(filters: LogFilters, options: { cursor?: string; watermark?: string; limit: number }) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
const watermark = options.watermark ?? await logWatermark(client, filters.serverId)
|
||||
const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark]
|
||||
if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) }
|
||||
const where = logWhere(filters, conditions, values)
|
||||
const rows = (await client.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT $${where.values.length + 1}`, [...where.values, options.limit + 1])).rows
|
||||
await client.query("COMMIT")
|
||||
const entries = rows.slice(0, options.limit)
|
||||
return { entries, nextCursor: rows.length > options.limit ? encodeLogCursor(entries[entries.length - 1]) : null, watermark }
|
||||
} catch (error) { await client.query("ROLLBACK"); throw error } finally { client.release() }
|
||||
}
|
||||
export async function readLogSummary(filters: LogFilters) {
|
||||
const where = logWhere(filters)
|
||||
const from = filters.from ? Date.parse(filters.from) : Date.now() - 900_000
|
||||
const to = filters.to ? Date.parse(filters.to) : Date.now()
|
||||
const bucketSec = Math.max(1, Math.ceil((to - from) / 1000 / 60))
|
||||
const { rows } = await pool.query<{ at: Date; level: string; count: number }>(`SELECT to_timestamp(floor(extract(epoch FROM effective_at)/$${where.values.length + 1})*$${where.values.length + 1}) AS at,level,count(*)::int AS count FROM router_log_entries WHERE ${where.sql} GROUP BY 1,2 ORDER BY 1`, [...where.values, bucketSec])
|
||||
const levels = Object.fromEntries(logLevels.map(level => [level, 0])) as Record<typeof logLevels[number], number>
|
||||
const buckets = new Map<string, { at: string } & typeof levels>()
|
||||
for (const row of rows) {
|
||||
const at = row.at.toISOString()
|
||||
const level = row.level as typeof logLevels[number]
|
||||
levels[level] += row.count
|
||||
if (!buckets.has(at)) buckets.set(at, { at, ...Object.fromEntries(logLevels.map(l => [l, 0])) } as { at: string } & typeof levels)
|
||||
buckets.get(at)![level] += row.count
|
||||
}
|
||||
return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId) }
|
||||
}
|
||||
export async function readLogEntry(id: string) {
|
||||
return (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1`, [id])).rows[0] ?? null
|
||||
}
|
||||
export async function readLogContext(id: string) {
|
||||
const entry = await readLogEntry(id)
|
||||
if (!entry) return null
|
||||
const before = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
|
||||
const after = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows
|
||||
return { entry, before: before.reverse(), after }
|
||||
}
|
||||
export async function readLogStream(filters: LogFilters, cursor: string) {
|
||||
// A committed upper bound is safe per device: writers are serialized by its advisory lock.
|
||||
// Advance across non-matching events too, so a quiet filtered stream never rescans its backlog.
|
||||
const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor])
|
||||
const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark])
|
||||
const entries = (await pool.query<LogEntry>(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY id LIMIT 200`, where.values)).rows
|
||||
return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled }
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Integration/performance tests use a fresh, disposable schema, never application tables. */
|
||||
import assert from "node:assert/strict"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import pg from "pg"
|
||||
import { logEntrySchema, logFiltersSchema, logPageSchema, logSummarySchema } from "@mmapp/contracts"
|
||||
import { pool } from "../db/index.js"
|
||||
import { env } from "../config.js"
|
||||
import { collectRouterLogsOnce, getLogStates, subscribeLogDevice, updateLogSettings } from "./router-logs-collector.js"
|
||||
import { readLogContext, readLogPage, readLogStream, readLogSummary } from "./router-logs-query.js"
|
||||
|
||||
const schema = "test_router_logs_" + randomBytes(6).toString("hex")
|
||||
const admin = new pg.Pool({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 3000 })
|
||||
try { await admin.query("SELECT 1") } catch { await admin.end(); console.error("PostgreSQL недоступен; SQL/live/performance проверки не выполнены"); process.exit(1) }
|
||||
if (!/^[a-z_0-9]+$/.test(schema)) throw new Error("Unsafe test schema")
|
||||
await admin.query(`CREATE SCHEMA ${schema}`)
|
||||
pool.options.options = `-c search_path=${schema}`
|
||||
let entries = Array.from({ length: 301 }, (_, i) => ({ ".id": `*${i}`, time: "12:00:00", topics: "system,info", message: "entry " + i }))
|
||||
let requests = 0, denied = false
|
||||
const router = createServer((req, res) => {
|
||||
requests++
|
||||
const url = new URL(req.url!, "http://127.0.0.1")
|
||||
assert.equal(url.pathname, "/rest/log")
|
||||
assert.equal(url.searchParams.get("buffer"), "memory")
|
||||
res.writeHead(denied ? 401 : 200, { "content-type": "application/json" }); res.end(JSON.stringify(denied ? { error: "denied" } : entries))
|
||||
})
|
||||
await new Promise<void>(resolve => router.listen(0, "127.0.0.1", resolve))
|
||||
const address = router.address()
|
||||
assert.ok(address && typeof address !== "string")
|
||||
try {
|
||||
const initial = await readFile(new URL("../../drizzle/0000_postgresql.sql", import.meta.url), "utf8")
|
||||
const serverSql = initial.match(/CREATE TABLE IF NOT EXISTS servers \([\s\S]*?\n\);/)![0]
|
||||
await pool.query(serverSql)
|
||||
await pool.query(await readFile(new URL("../../drizzle/0014_router_logs.sql", import.meta.url), "utf8"))
|
||||
const { rows: [server] } = await pool.query<{ id: number }>(`INSERT INTO servers(name,host,port,use_ssl) VALUES('logs-fixture','127.0.0.1',$1,false) RETURNING id`, [address.port])
|
||||
const serverId = Number(server.id)
|
||||
await updateLogSettings({ enabled: true, intervalSec: 5 })
|
||||
const filter = logFiltersSchema.parse({ serverId })
|
||||
await collectRouterLogsOnce()
|
||||
let page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(filter, { limit: 200 }))))
|
||||
assert.equal(page.entries.length, 200); assert.ok(page.nextCursor)
|
||||
const older = await readLogPage(filter, { limit: 200, cursor: page.nextCursor!, watermark: page.watermark })
|
||||
assert.equal(older.entries.length, 101)
|
||||
assert.equal(new Set([...older.entries, ...page.entries].map(e => e.id)).size, 301)
|
||||
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
|
||||
await collectRouterLogsOnce()
|
||||
assert.equal((await readLogSummary(filter)).total, 301, "Repeated snapshot is not reimported")
|
||||
entries = [...entries.slice(5), { ".id": "*302", time: "12:00:01", topics: "system,error,critical", message: "login failure" }]
|
||||
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
|
||||
// Concurrent clients and background collection must issue one read, not duplicate writes.
|
||||
const beforeRequests = requests
|
||||
const a = subscribeLogDevice(serverId), b = subscribeLogDevice(serverId)
|
||||
await Promise.all([collectRouterLogsOnce(), collectRouterLogsOnce(), collectRouterLogsOnce()])
|
||||
a(); b()
|
||||
assert.equal(requests - beforeRequests, 1)
|
||||
const stream = await readLogStream(filter, page.watermark)
|
||||
assert.equal(stream.entries.length, 1)
|
||||
const unmatched = await readLogStream(logFiltersSchema.parse({ serverId, q: "not-present-in-buffer" }), page.watermark)
|
||||
assert.equal(unmatched.entries.length, 0)
|
||||
assert.equal(unmatched.cursor, stream.cursor, "Filtered stream advances across non-matching entries")
|
||||
const critical = logEntrySchema.parse(JSON.parse(JSON.stringify(stream.entries[0])))
|
||||
assert.equal(critical.level, "critical"); assert.equal(critical.timeKind, "receipt")
|
||||
const context = await readLogContext(critical.id)
|
||||
assert.equal(context?.before.length, 10)
|
||||
const filtered = logFiltersSchema.parse({ serverId, levels: "critical,error", q: "LOGIN" })
|
||||
assert.equal((await readLogPage(filtered, { limit: 200 })).entries.length, 1)
|
||||
const summary = logSummarySchema.parse(JSON.parse(JSON.stringify(await readLogSummary(filtered))))
|
||||
assert.equal(summary.total, 1); assert.equal(summary.levels.critical, 1)
|
||||
entries = [{ ".id": "*1", time: "01:00:00", topics: "system,warning", message: "reboot" }]
|
||||
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
|
||||
await collectRouterLogsOnce()
|
||||
assert.equal((await getLogStates(serverId))[0].generation, 1)
|
||||
assert.ok((await getLogStates(serverId))[0].gapAt)
|
||||
denied = true
|
||||
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute'")
|
||||
await collectRouterLogsOnce()
|
||||
assert.ok((await getLogStates(serverId))[0].lastError)
|
||||
const deniedRequests = requests
|
||||
await collectRouterLogsOnce()
|
||||
assert.equal(requests, deniedRequests, "Error backoff prevents repeated logins")
|
||||
denied = false
|
||||
await pool.query("UPDATE router_log_collection_state SET last_collected_at=now()-interval '1 minute',next_poll_at=NULL")
|
||||
await collectRouterLogsOnce()
|
||||
assert.equal((await getLogStates(serverId))[0].lastError, null)
|
||||
// Receipt-based retention, including events whose device timestamp is wrong.
|
||||
await pool.query(`UPDATE router_log_entries SET received_at=now()-interval '15 days' WHERE id=$1`, [critical.id])
|
||||
await collectRouterLogsOnce()
|
||||
assert.equal((await pool.query("SELECT id FROM router_log_entries WHERE id=$1", [critical.id])).rowCount, 0)
|
||||
await pool.query("DELETE FROM servers WHERE id=$1", [serverId])
|
||||
assert.ok((await readLogSummary(filter)).total > 0, "Deleting device preserves collected history")
|
||||
const count = process.env.LOGS_PERFORMANCE === "1" ? 1_000_000 : 5000
|
||||
const started = performance.now()
|
||||
await pool.query(`INSERT INTO router_log_entries(server_id,server_name,host,router_id,raw_time,received_at,effective_at,topics,level,message,generation)
|
||||
SELECT 999,'perf','192.0.2.1',i::text,'12:00:00',now(),now()-(i * interval '0.1 seconds'),ARRAY['system','info'],'info','performance event '||i,0 FROM generate_series(1,$1::int) AS i`, [count])
|
||||
const performanceFilters = logFiltersSchema.parse({ serverId: 999, from: new Date(Date.now() - 14 * 86400000).toISOString() })
|
||||
const readStarted = performance.now()
|
||||
page = logPageSchema.parse(JSON.parse(JSON.stringify(await readLogPage(performanceFilters, { limit: 200 }))))
|
||||
assert.equal(page.entries.length, 200)
|
||||
const performanceSummary = await readLogSummary(performanceFilters)
|
||||
assert.equal(performanceSummary.total, count)
|
||||
console.log(`PASS SQL: snapshot, rotation, real repeats, concurrency, restart-safe state, cursor/SSE, filters, context, retention, deletion; ${count} events inserted in ${Math.round(readStarted - started)}ms; history+summary ${Math.round(performance.now() - readStarted)}ms`)
|
||||
} finally {
|
||||
await new Promise<void>(resolve => router.close(() => resolve()))
|
||||
await pool.end()
|
||||
await admin.query(`DROP SCHEMA ${schema} CASCADE`)
|
||||
await admin.end()
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
|
||||
import { getBackupScheduleSettings } from "./backup-service.js"
|
||||
import { collectGeoipUpdateOnce } from "./geoip-update-collector.js"
|
||||
import { getGeoipSettings } from "./geoip-settings.js"
|
||||
import { collectRouterLogsOnce, getLogSettings, startLogsLiveTimer, stopLogsLiveTimer } from "./router-logs-collector.js"
|
||||
import {
|
||||
endSchedulerJob,
|
||||
isSchedulerJobRunning,
|
||||
@@ -61,6 +62,7 @@ import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { runRecordedSchedulerJob, type SchedulerRunReceipt } from "./scheduler-recorded-run.js"
|
||||
|
||||
export const JOB_KEYS = [
|
||||
"router_logs",
|
||||
"traffic",
|
||||
"servers_rest_ping",
|
||||
"uptime_resources",
|
||||
@@ -81,6 +83,7 @@ let schedulerGeneration = 0
|
||||
const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
const QUIET_SCHEDULER_OK_JOBS = new Set<SchedulerJobKey>([
|
||||
"router_logs",
|
||||
"traffic",
|
||||
"servers_rest_ping",
|
||||
"uptime_resources",
|
||||
@@ -130,6 +133,9 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<SchedulerRu
|
||||
collect: async () => {
|
||||
let snapshot: SchedulerRunSnapshot | undefined
|
||||
switch (jobKey) {
|
||||
case "router_logs":
|
||||
snapshot = await collectRouterLogsOnce()
|
||||
break
|
||||
case "traffic":
|
||||
snapshot = await collectTrafficOnce()
|
||||
break
|
||||
@@ -221,6 +227,7 @@ export async function runSchedulerJobNow(jobKey: string): Promise<SchedulerRunRe
|
||||
}
|
||||
|
||||
function clearAllTimers() {
|
||||
stopLogsLiveTimer()
|
||||
clearAlertCollectorHooksTimer()
|
||||
for (const t of timers.values()) clearInterval(t)
|
||||
timers.clear()
|
||||
@@ -233,6 +240,14 @@ export async function refreshScheduler(): Promise<void> {
|
||||
const superseded = () => generation !== schedulerGeneration || isSchedulerJobsPaused()
|
||||
clearAllTimers()
|
||||
|
||||
const logs = await getLogSettings()
|
||||
if (superseded()) return
|
||||
startLogsLiveTimer()
|
||||
if (logs.enabled) {
|
||||
void executeSchedulerJob("router_logs")
|
||||
timers.set("router_logs", setInterval(() => { void executeSchedulerJob("router_logs") }, logs.intervalSec * 1000))
|
||||
}
|
||||
|
||||
const traffic = await getTrafficSettings()
|
||||
if (superseded()) return
|
||||
if (traffic.enabled) {
|
||||
@@ -398,6 +413,7 @@ async function lastRunForJob(jobKey: string) {
|
||||
}
|
||||
|
||||
export async function getSchedulerStatus() {
|
||||
const logs = await getLogSettings()
|
||||
const traffic = await getTrafficSettings()
|
||||
const uptime = await getUptimeSettings()
|
||||
const apiPing = await getServersApiPingSettings()
|
||||
@@ -411,6 +427,7 @@ export async function getSchedulerStatus() {
|
||||
const spdOn = uptime.speedEnabled ?? uptime.enabled
|
||||
|
||||
const jobMeta: Record<SchedulerJobKey, { enabled: boolean; intervalSec: number }> = {
|
||||
router_logs: { enabled: logs.enabled, intervalSec: logs.intervalSec },
|
||||
traffic: { enabled: traffic.enabled, intervalSec: traffic.intervalSec },
|
||||
servers_rest_ping: { enabled: apiPing.enabled, intervalSec: apiPing.intervalSec },
|
||||
uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec },
|
||||
|
||||
@@ -245,7 +245,17 @@ export interface GeoipUpdateRunSnapshot {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface RouterLogsRunSnapshot {
|
||||
v: 1
|
||||
job: "router_logs"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
errors: string[]
|
||||
servers: { serverId: number; name: string; added: number; gap: boolean; error?: string }[]
|
||||
}
|
||||
|
||||
export type SchedulerRunSnapshot =
|
||||
| RouterLogsRunSnapshot
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
| PingRunSnapshot
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
// Adapted from ReUI PRO sheet-14: event header, facts, tabs and stepping context.
|
||||
// https://reui.io/preview/base/sheet-14
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { z } from "zod"
|
||||
import type { LogEntry } from "@mmapp/contracts"
|
||||
import { toast } from "sonner"
|
||||
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
|
||||
import { Sheet, SheetContent, SheetTitle, SheetDescription, SheetHeader } from "@/components/ui/sheet"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { getLogContext } from "@/shared/api/logs"
|
||||
import { LevelBadge, formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogDetailSheet({ entry, base, onClose, onSelect }: { entry: LogEntry | null; base: string; onClose: () => void; onSelect: (entry: LogEntry) => void }) {
|
||||
return <Sheet open={!!entry} onOpenChange={open => { if (!open) onClose() }}><SheetContent className="data-[side=right]:w-full data-[side=right]:sm:max-w-2xl overflow-y-auto">
|
||||
{entry && <LogDetail key={entry.id} entry={entry} base={base} onSelect={onSelect} />}
|
||||
</SheetContent></Sheet>
|
||||
}
|
||||
function LogDetail({ entry, base, onSelect }: { entry: LogEntry; base: string; onSelect: (entry: LogEntry) => void }) {
|
||||
const [context, setContext] = useState<Awaited<ReturnType<typeof getLogContext>> | null>(null), [error, setError] = useState<string | null>(null), [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
void getLogContext(base, entry.id, ac.signal).then(data => { if (!ac.signal.aborted) { setContext(data); setError(null) } }).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный ответ контекста" : e.message) })
|
||||
return () => ac.abort()
|
||||
}, [base, entry.id, retry])
|
||||
const copy = () => { void navigator.clipboard.writeText(JSON.stringify(entry, null, 2)).then(() => toast.success("Событие скопировано"), () => toast.error("Не удалось скопировать событие")) }
|
||||
return <>
|
||||
<SheetHeader className="gap-3 border-b">
|
||||
<div className="flex flex-wrap items-center gap-2"><LevelBadge level={entry.level} /><span className="text-xs text-muted-foreground font-mono">{entry.routerId}</span></div>
|
||||
<SheetTitle>Событие RouterOS</SheetTitle><SheetDescription>{entry.serverName} · {entry.host} · {formatLogTime(entry.effectiveAt)}</SheetDescription>
|
||||
<div className="flex flex-wrap gap-2"><Button size="sm" variant="outline" onClick={copy}><CopyIcon />Копировать</Button><Button size="sm" variant="outline" nativeButton={false} render={<Link href={`/servers?server=${entry.serverId}`} />}><ExternalLinkIcon />Устройство</Button></div>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="details" className="min-w-0 px-4 pb-4">
|
||||
<TabsList variant="line"><TabsTrigger value="details">Событие</TabsTrigger><TabsTrigger value="raw">Исходные поля</TabsTrigger><TabsTrigger value="context">Контекст</TabsTrigger></TabsList>
|
||||
<TabsContent value="details" className="flex flex-col gap-4">
|
||||
<p className="whitespace-pre-wrap break-words font-mono text-sm">{entry.message}</p>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-x-6 gap-y-3 text-sm">
|
||||
{[["Topics", entry.topics.join(", ")], ["Время RouterOS", entry.rawTime], ["Получено", formatLogTime(entry.receivedAt)], ["Источник времени", entry.timeKind === "receipt" ? "Время получения: дата или часовой пояс RouterOS неоднозначны" : "Время RouterOS"], ["Поколение буфера", String(entry.generation)]].map(([label, value]) => <FragmentPair key={label} label={label} value={value} />)}
|
||||
</dl>
|
||||
</TabsContent>
|
||||
<TabsContent value="raw"><pre className="overflow-auto whitespace-pre-wrap break-all text-xs font-mono">{JSON.stringify({ ".id": entry.routerId, time: entry.rawTime, topics: entry.topics.join(","), message: entry.message }, null, 2)}</pre></TabsContent>
|
||||
<TabsContent value="context" className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">До 10 событий до и после записи на этом MikroTik. Фильтры журнала здесь не применяются.</p>
|
||||
{error ? <Alert variant="destructive"><AlertTitle>Контекст не получен</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !context ? <Skeleton className="h-32 w-full" /> : [context.before, [context.entry], context.after].flat().map(row => <Button key={row.id} variant="ghost" className="h-auto justify-start whitespace-normal text-left" onClick={() => onSelect(row)} aria-current={row.id === entry.id ? "true" : undefined}><span className="flex min-w-0 flex-col gap-1"><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.effectiveAt)} · {row.topics.join(", ")}</span><span className="break-all font-mono text-xs">{row.message}</span></span></Button>)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
}
|
||||
function FragmentPair({ label, value }: { label: string; value: string }) { return <><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words">{value}</dd></> }
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
import { Fragment } from "react"
|
||||
import { AlertTriangleIcon, CircleAlertIcon, CircleHelpIcon, InfoIcon, BugIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import type { LogLevel } from "@mmapp/contracts"
|
||||
|
||||
export const LEVEL_LABELS: Record<LogLevel, string> = { critical: "Критическая", error: "Ошибка", warning: "Предупреждение", info: "Информация", debug: "Отладка", unknown: "Не определён" }
|
||||
export const LEVEL_COLORS: Record<LogLevel, string> = { critical: "var(--destructive)", error: "var(--destructive)", warning: "var(--warning)", info: "var(--info)", debug: "var(--muted-foreground)", unknown: "var(--border)" }
|
||||
export function LevelBadge({ level }: { level: LogLevel }) {
|
||||
const Icon = level === "critical" || level === "error" ? CircleAlertIcon : level === "warning" ? AlertTriangleIcon : level === "debug" ? BugIcon : level === "unknown" ? CircleHelpIcon : InfoIcon
|
||||
return <Badge variant={level === "critical" || level === "error" ? "destructive-light" : level === "warning" ? "warning-light" : level === "info" ? "info-light" : "secondary"} size="sm"><Icon aria-hidden="true" className="size-3" />{LEVEL_LABELS[level]}</Badge>
|
||||
}
|
||||
export function HighlightMessage({ text, search }: { text: string; search: string }) {
|
||||
if (!search) return <>{text}</>
|
||||
const lower = text.toLocaleLowerCase(), needle = search.toLocaleLowerCase(), parts = []
|
||||
let start = 0, found = lower.indexOf(needle)
|
||||
while (found >= 0) {
|
||||
parts.push(<Fragment key={start}>{text.slice(start, found)}<mark className="bg-primary/15 text-foreground">{text.slice(found, found + search.length)}</mark></Fragment>)
|
||||
start = found + search.length; found = lower.indexOf(needle, start)
|
||||
}
|
||||
return <>{parts}{text.slice(start)}</>
|
||||
}
|
||||
export function formatLogTime(at: string | null) { return at ? new Date(at).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "—" }
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { type LogSettings, type LogSummary } from "@mmapp/contracts"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { getLogSettings, getLogSummary, putLogSettings } from "@/shared/api/logs"
|
||||
import { can, ensureAuthConfig } from "@/lib/auth"
|
||||
import { formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogsCollectorSettings({ base, onSaved }: { base: string; onSaved?: (settings: LogSettings) => void }) {
|
||||
const [settings, setSettings] = useState<LogSettings | null>(null), [summary, setSummary] = useState<LogSummary | null>(null)
|
||||
const [interval, setIntervalDraft] = useState("10"), [retention, setRetention] = useState("14"), [enabled, setEnabled] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null), [saving, setSaving] = useState(false), [revision, setRevision] = useState(0), [notice, setNotice] = useState("")
|
||||
const lifetime = useRef<AbortController | null>(null), busy = useRef(false)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController(); lifetime.current = ac
|
||||
void ensureAuthConfig().then(async () => {
|
||||
if (!can("mm:settings:admin")) throw new Error("Настройки логов доступны с mm:settings:admin")
|
||||
const data = await getLogSettings(base, ac.signal)
|
||||
if (ac.signal.aborted) return
|
||||
setSettings(data); setEnabled(data.enabled); setIntervalDraft(String(data.intervalSec)); setRetention(String(data.retentionDays)); setError(null)
|
||||
const stats = await getLogSummary(base, new URLSearchParams({ from: new Date(Date.now() - 900000).toISOString() }), ac.signal)
|
||||
if (!ac.signal.aborted) setSummary(stats)
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof Error ? e.message : String(e)) })
|
||||
return () => ac.abort()
|
||||
}, [base, revision])
|
||||
const save = async () => {
|
||||
if (busy.current || !settings || !can("mm:settings:admin")) return
|
||||
if (!/^\d+$/.test(interval) || !/^\d+$/.test(retention)) { setError("Укажите целые значения интервала и срока хранения"); return }
|
||||
if (Number(interval) < 5 || Number(interval) > 3600 || Number(retention) < 1 || Number(retention) > 365) { setError("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней"); return }
|
||||
busy.current = true; setSaving(true); setNotice("")
|
||||
const signal = lifetime.current?.signal
|
||||
try {
|
||||
const confirmed = await putLogSettings(base, { enabled, intervalSec: Number(interval), retentionDays: Number(retention) }, signal)
|
||||
if (signal?.aborted) return
|
||||
setSettings(confirmed); setError(null); setNotice("Настройки сборщика логов сохранены"); onSaved?.(confirmed)
|
||||
} catch (e) { if (!signal?.aborted) setError(e instanceof Error ? e.message : String(e)) }
|
||||
finally { busy.current = false; if (!signal?.aborted) setSaving(false) }
|
||||
}
|
||||
return <Frame id="router-logs" dense><FrameHeader><FrameTitle>Логи MikroTik</FrameTitle><FrameDescription>REST-сбор буфера memory, сохранение в PostgreSQL. При открытом live опрос выбранного устройства ускоряется до 2 секунд.</FrameDescription></FrameHeader><FramePanel className="flex flex-col gap-4">
|
||||
{error && <Alert variant="destructive"><AlertTitle>Данные сборщика не подтверждены</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" disabled={saving} onClick={() => setRevision(n => n + 1)}>Повторить загрузку</Button></AlertDescription></Alert>}
|
||||
{!settings && !error && <Skeleton className="h-24 w-full" />}
|
||||
<div className="flex flex-wrap items-end gap-4"><label className="flex items-center gap-2 text-sm"><Switch checked={enabled} onCheckedChange={setEnabled} disabled={!settings || saving} aria-label="Включить сбор логов" />Собирать логи</label><label className="flex flex-col gap-1 text-xs">Фоновый интервал, секунд<Input aria-label="Интервал сбора логов" className="w-40" inputMode="numeric" value={interval} onChange={e => setIntervalDraft(e.target.value)} disabled={!settings || saving} /></label><label className="flex flex-col gap-1 text-xs">Хранить, дней<Input aria-label="Срок хранения логов" className="w-40" inputMode="numeric" value={retention} onChange={e => setRetention(e.target.value)} disabled={!settings || saving} /></label><Button size="sm" disabled={!settings || saving} onClick={() => void save()}>{saving ? "Сохранение…" : "Сохранить настройки логов"}</Button></div>
|
||||
{notice && <p role="status" className="text-sm">{notice}</p>}
|
||||
<p className="text-xs text-muted-foreground">Интервал: 5–3600 секунд, хранение: 1–365 дней. Буфер MikroTik может перезаписаться между опросами. Новые правила логирования на устройствах не создаются.</p>
|
||||
{summary?.states.length ? <div className="flex flex-col gap-2">{summary.states.map(state => <div key={state.serverId} className="flex flex-wrap items-center gap-2 border-t py-2 text-xs"><Link href={`/logs?server=${state.serverId}`} className="font-medium underline">{state.serverName}</Link><Badge size="sm" variant={state.lastError ? "warning-light" : state.lastCollectedAt ? "success-light" : "secondary"}>{state.lastError ? "Ошибка" : state.lastCollectedAt ? "Получено" : "Ожидание"}</Badge><span className="text-muted-foreground">{formatLogTime(state.lastCollectedAt)}</span>{state.gapAt && <span>Возможный разрыв: {formatLogTime(state.gapAt)}</span>}{state.lastError && <span role="status" className="break-all">{state.lastError}</span>}</div>)}</div> : <p className="text-xs text-muted-foreground">Результатов сбора по устройствам пока нет.</p>}
|
||||
</FramePanel></Frame>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
// ReUI PRO solution-dev-ops-2: Frame-led grid, toolbar and display settings.
|
||||
// https://reui.io/preview/base/solution-dev-ops-2
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useTable, type ColumnDef } from "@tanstack/react-table"
|
||||
import { elementScroll, observeElementRect, observeElementOffset } from "@tanstack/react-virtual"
|
||||
import type { LogEntry } from "@mmapp/contracts"
|
||||
import { CopyIcon, Columns3Icon, WrapTextIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { DataGrid, DataGridContainer, DataGridTableVirtual, DataGridColumnVisibility, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DATA_GRID_LABELS_RU } from "@/lib/data-grid-labels-ru"
|
||||
import { HighlightMessage, LevelBadge, formatLogTime } from "./log-presentation"
|
||||
|
||||
export function LogsGrid({ entries, loading, total, search, toolbar, onSelect, onPause, hasMore, fetchingMore, loadMore, reset }: {
|
||||
entries: LogEntry[]; loading: boolean; total: number | null; search: string; toolbar: ReactNode;
|
||||
onSelect: (entry: LogEntry) => void; onPause: () => void; hasMore: boolean; fetchingMore: boolean; loadMore: () => void; reset: () => void
|
||||
}) {
|
||||
const [wrap, setWrap] = useState(false)
|
||||
const [narrow, setNarrow] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)")
|
||||
const update = () => setNarrow(mq.matches)
|
||||
queueMicrotask(update); mq.addEventListener("change", update)
|
||||
return () => mq.removeEventListener("change", update)
|
||||
}, [])
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, LogEntry>[]>(() => [
|
||||
{ accessorKey: "effectiveAt", header: "Время", size: 165, cell: ({ row }) => <span className="font-mono text-xs tabular-nums" title={row.original.timeKind === "receipt" ? `Время получения; RouterOS: ${row.original.rawTime}` : row.original.rawTime}>{formatLogTime(row.original.effectiveAt)}{row.original.timeKind === "receipt" && <span aria-label="Время получения"> *</span>}</span> },
|
||||
{ accessorKey: "serverName", header: "Устройство", size: 150, cell: ({ row }) => <span className="truncate" title={row.original.host}>{row.original.serverName}</span> },
|
||||
{ accessorKey: "level", header: "Уровень", size: 165, cell: ({ row }) => <LevelBadge level={row.original.level} /> },
|
||||
{ accessorKey: "topics", header: "Topics", size: 180, cell: ({ row }) => <span className="truncate text-xs text-muted-foreground font-mono" title={row.original.topics.join(", ")}>{row.original.topics.join(", ")}</span> },
|
||||
{ accessorKey: "message", header: narrow ? "Событие" : "Сообщение", size: narrow ? 300 : 600, enableHiding: false, cell: ({ row }) => <Button variant="ghost" className="h-auto min-w-0 w-full justify-start whitespace-normal text-left" onClick={event => { event.stopPropagation(); onSelect(row.original) }} aria-label={`Открыть событие ${row.original.id}`}><span className="flex min-w-0 flex-col gap-1">{narrow && <span className="flex flex-wrap items-center gap-1"><LevelBadge level={row.original.level} /><span className="text-xs text-muted-foreground tabular-nums">{formatLogTime(row.original.effectiveAt)}</span></span>}<span className={`min-w-0 font-mono text-xs ${wrap || narrow ? "whitespace-pre-wrap break-words" : "truncate"}`}><HighlightMessage text={row.original.message} search={search} /></span></span></Button> },
|
||||
{ id: "copy", header: "", size: 46, enableHiding: false, cell: ({ row }) => <Button size="icon-sm" variant="ghost" aria-label="Копировать сообщение" onClick={event => { event.stopPropagation(); void navigator.clipboard.writeText(row.original.message).then(() => toast.success("Сообщение скопировано"), () => toast.error("Не удалось скопировать сообщение")) }}><CopyIcon /></Button> },
|
||||
], [wrap, narrow, search, onSelect])
|
||||
const table = useTable({ features: dataGridFeatures, data: entries, columns, getRowId: row => row.id, manualPagination: true, rowCount: entries.length })
|
||||
useEffect(() => { table.setColumnVisibility({ effectiveAt: !narrow, serverName: !narrow, level: !narrow, topics: !narrow, copy: !narrow }) }, [narrow, table])
|
||||
return <DataGrid table={table} recordCount={entries.length} isLoading={loading} loadingMode="skeleton" i18n={{ labels: DATA_GRID_LABELS_RU }} onRowClick={onSelect}
|
||||
tableLayout={{ dense: true, headerSticky: true, columnsVisibility: true, width: "fixed" }}
|
||||
emptyMessage={<div className="flex flex-col items-center gap-2 p-6"><p>Событий за выбранный период нет</p><Button size="sm" variant="outline" onClick={reset}>Сбросить фильтры</Button></div>}
|
||||
tableClassNames={{ edgeCell: "first:ps-(--frame-panel-header-px) last:pe-(--frame-panel-header-px)" }}>
|
||||
<Frame dense className="min-w-0 w-full">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><FrameTitle>Журнал событий</FrameTitle><FrameDescription className="text-xs tabular-nums">Загружено {entries.length.toLocaleString("ru-RU")}{total !== null ? ` · Найдено ${total.toLocaleString("ru-RU")}` : ""}</FrameDescription></div><div className="flex items-center gap-2"><Button size="sm" variant="outline" aria-pressed={wrap} onClick={() => setWrap(v => !v)}><WrapTextIcon /><span className="max-sm:sr-only">Перенос</span></Button><DataGridColumnVisibility table={table} trigger={<Button variant="outline" size="sm"><Columns3Icon />Колонки</Button>} /></div></FrameHeader>
|
||||
<FramePanel className="p-0"><div className="flex flex-col gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">{toolbar}</div>
|
||||
<div onScrollCapture={event => { if ((event.target as HTMLElement).scrollTop > 16) onPause() }}>
|
||||
<DataGridContainer border={false}><DataGridTableVirtual height={420} estimateSize={48} overscan={8} hasMore={hasMore} isFetchingMore={fetchingMore} onFetchMore={loadMore} virtualizerOptions={{ scrollToFn: elementScroll, observeElementRect, observeElementOffset, measureElement: element => element.getBoundingClientRect().height }} /></DataGridContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t p-3 text-xs text-muted-foreground"><span>* Использовано время получения. Часовой пояс отображения: браузер.</span>{hasMore && <Button size="sm" variant="outline" disabled={fetchingMore} onClick={loadMore}>{fetchingMore ? "Загрузка…" : "Загрузить ещё"}</Button>}</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DataGrid>
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { z } from "zod"
|
||||
import { logFiltersSchema, logLevels, type LogEntry } from "@mmapp/contracts"
|
||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||
import { DownloadIcon, PauseIcon, PlayIcon, RefreshCwIcon, SearchIcon, BookmarkPlusIcon, ActivityIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||
import { Frame, FrameHeader, FrameTitle, FrameDescription, FramePanel } from "@/components/reui/frame"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { Filters } from "@/components/reui/filters/filters"
|
||||
import { createFilterQuery, createFilterRule, flattenFilterConditions } from "@/components/reui/filters/filters-query"
|
||||
import type { FilterField, FilterQuery } from "@/components/reui/filters/filters-types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can, ensureAuthConfig, getClaims } from "@/lib/auth"
|
||||
import { firewallServersSchema } from "@/lib/firewall-response"
|
||||
import { FILTER_LABELS_RU } from "@/lib/data-filters/filter-labels-ru"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { exportLogs } from "@/shared/api/logs"
|
||||
import { useRouterLogs } from "@/hooks/use-router-logs"
|
||||
import { LogsGrid } from "./logs-grid"
|
||||
import { LogDetailSheet } from "./log-detail-sheet"
|
||||
import { LEVEL_COLORS, LEVEL_LABELS, formatLogTime } from "./log-presentation"
|
||||
|
||||
const RANGES = [{ value: "15m", label: "15 минут" }, { value: "1h", label: "1 час" }, { value: "24h", label: "24 часа" }, { value: "7d", label: "7 дней" }, { value: "14d", label: "14 дней" }, { value: "custom", label: "Свой период" }]
|
||||
const RANGE_MS: Record<string, number> = { "15m": 900000, "1h": 3600000, "24h": 86400000, "7d": 604800000, "14d": 1209600000 }
|
||||
const PRESETS = [{ value: "", label: "Все события" }, { value: "errors", label: "Ошибки и critical" }, { value: "warnings", label: "Предупреждения" }, { value: "login-failure", label: "Ошибки входа · шаблон" }, { value: "interfaces", label: "Интерфейсы" }, { value: "routing", label: "Маршрутизация" }, { value: "firewall", label: "Firewall" }]
|
||||
const viewSchema = z.array(z.object({ name: z.string().max(60), query: z.string().max(4000) })).max(20)
|
||||
function navigate(values: Record<string, string | null>, replace = false) {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
for (const [key, value] of Object.entries(values)) { if (value) params.set(key, value); else params.delete(key) }
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", "/logs?" + params)
|
||||
}
|
||||
export function LogsWorkspace() { return <Suspense fallback={<Skeleton className="h-64 w-full" />}><LogsRoute /></Suspense> }
|
||||
function LogsRoute() {
|
||||
const { backendUrl, mode, prefsHydrated } = useDataSource()
|
||||
return prefsHydrated ? <LogsCatalog key={JSON.stringify([backendUrl, mode])} base={backendUrl} active={mode === "live"} /> : <p role="status" className="p-4">Загружаем источник…</p>
|
||||
}
|
||||
function LogsCatalog({ base, active }: { base: string; active: boolean }) {
|
||||
const params = useSearchParams(), selectedId = params.get("server") ?? ""
|
||||
const [servers, setServers] = useState<z.infer<typeof firewallServersSchema>>([]), [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false), [loading, setLoading] = useState(active), [retry, setRetry] = useState(0)
|
||||
useEffect(() => {
|
||||
const ac = new AbortController()
|
||||
void ensureAuthConfig().then(async () => {
|
||||
if (!active || ac.signal.aborted) return
|
||||
if (!can("mm:servers:read")) throw new Error("Для чтения логов требуется mm:servers:read")
|
||||
const rows = firewallServersSchema.parse(await requestJson(base, "/api/servers", { signal: ac.signal }))
|
||||
if (ac.signal.aborted) return
|
||||
setServers(rows); setReady(true); setError(null)
|
||||
if (!new URLSearchParams(window.location.search).has("server")) navigate({ server: String(rows.find(s => s.enabled)?.id ?? ALL_SERVERS_ID) }, true)
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(e instanceof z.ZodError ? "Некорректный каталог устройств" : e.message) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
|
||||
return () => ac.abort()
|
||||
}, [base, active, retry])
|
||||
const items: ServerTileItem[] = servers.map(s => ({ id: String(s.id), name: s.name, host: s.host, country: s.country, type: s.type, status: s.status ?? "unknown", enabled: s.enabled, selectable: true }))
|
||||
// Keep an explicit historical ID in the rail after deletion; history remains addressable.
|
||||
if (selectedId && selectedId !== ALL_SERVERS_ID && !items.some(s => s.id === selectedId) && /^\d+$/.test(selectedId)) items.push({ id: selectedId, name: `История устройства #${selectedId}`, host: "Удалено или отсутствует в каталоге", enabled: false, selectable: true })
|
||||
const selected = servers.find(s => String(s.id) === selectedId)
|
||||
return <ServerRailLayout items={items} selectedId={selectedId} preserveSelection showAll allCount={servers.length} showCount={false} loading={loading} onSelect={id => navigate({ server: id, live: id === ALL_SERVERS_ID ? "0" : null })} contentClassName="min-w-0 min-h-0 overflow-y-auto p-2 md:p-3"
|
||||
header={<PageHeader crumbs={[{ label: "Инструменты" }, { label: "Логи" }]} actions={<><ServerRailMobileButton /><Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)} disabled={!active || loading}><RefreshCwIcon />Обновить устройства</Button></>} />}>
|
||||
{!active ? <Alert variant="info"><AlertTitle>Демонстрационный источник</AlertTitle><AlertDescription>Для просмотра сохранённых логов и live-потока выберите «Живые» в настройках источника данных.</AlertDescription></Alert> : error ? <Alert variant="destructive"><AlertTitle>Каталог устройств не подтверждён</AlertTitle><AlertDescription>{error}<Button size="sm" variant="outline" onClick={() => setRetry(n => n + 1)}>Повторить</Button></AlertDescription></Alert> : !ready || !selectedId ? <Skeleton className="h-80 w-full" /> : <LogExplorer key={JSON.stringify([base, params.toString()])} base={base} raw={params.toString()} selectedName={selected?.name ?? "История"} liveAvailable={!!selected?.enabled} />}
|
||||
</ServerRailLayout>
|
||||
}
|
||||
function LogExplorer({ base, raw, selectedName, liveAvailable }: { base: string; raw: string; selectedName: string; liveAvailable: boolean }) {
|
||||
const params = useMemo(() => new URLSearchParams(raw), [raw])
|
||||
const [anchor] = useState(() => Date.now())
|
||||
const range = params.get("range") ?? "15m", server = params.get("server") ?? ALL_SERVERS_ID
|
||||
const live = server !== ALL_SERVERS_ID && liveAvailable && params.get("live") !== "0" && range !== "custom"
|
||||
const parsed = useMemo(() => {
|
||||
const from = range === "custom" ? params.get("from") : new Date(anchor - (RANGE_MS[range] ?? 900000)).toISOString()
|
||||
return logFiltersSchema.safeParse({ serverId: server === ALL_SERVERS_ID ? undefined : server, from: from || undefined, to: range === "custom" ? params.get("to") || undefined : live ? undefined : new Date(anchor).toISOString(), levels: params.get("levels") ?? "", topics: params.get("topics") ?? "", q: params.get("q") ?? "", preset: params.get("preset") ?? "" })
|
||||
}, [params, server, range, anchor, live])
|
||||
const query = useMemo(() => { const query = new URLSearchParams(); if (parsed.success) for (const [key, value] of Object.entries(parsed.data)) if (value !== undefined && value !== "") query.set(key, String(value)); return query.toString() }, [parsed])
|
||||
const [paused, setPaused] = useState(false), [selected, setSelected] = useState<LogEntry | null>(null)
|
||||
const valid = parsed.success && RANGES.some(r => r.value === range) && (range !== "custom" || !!params.get("from") && !!params.get("to"))
|
||||
const result = useRouterLogs(base, query, valid, live, paused || !!selected, live ? RANGE_MS[range] ?? 900000 : 0)
|
||||
const [search, setSearch] = useState(params.get("q") ?? "")
|
||||
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() => createFilterQuery(["levels", "topics"].flatMap(field => params.get(field) ? [createFilterRule({ id: field, path: [field], operator: "is_any_of", value: params.get(field)!.split(",") })] : [])))
|
||||
const [viewName, setViewName] = useState(""), [views, setViews] = useState<z.infer<typeof viewSchema>>([]), [exporting, setExporting] = useState(false)
|
||||
const exportController = useRef<AbortController | null>(null)
|
||||
const storageKey = `mm:logs:views:${base}:${getClaims()?.sub ?? "local"}`
|
||||
useEffect(() => { queueMicrotask(() => { try { setViews(viewSchema.parse(JSON.parse(localStorage.getItem(storageKey) ?? "[]"))) } catch { setViews([]) } }); return () => exportController.current?.abort() }, [storageKey])
|
||||
const fields: FilterField[] = useMemo(() => [
|
||||
{ id: "levels", label: "Уровень", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: logLevels.map(value => ({ value, label: LEVEL_LABELS[value] })) },
|
||||
{ id: "topics", label: "Topics", type: "multiselect", operators: [{ value: "is_any_of", label: "любой из", arity: "many" }], defaultOperator: "is_any_of", options: [...new Set(["system", "account", "interface", "firewall", "route", "routing", "bgp", "ospf", "bfd", "dhcp", "dns", "wireguard", "ipsec", "script", ...result.entries.flatMap(row => row.topics), ...(params.get("topics")?.split(",") ?? [])])].sort().map(value => ({ value, label: value })) },
|
||||
], [result.entries, params])
|
||||
const applyFilters = (next: FilterQuery) => {
|
||||
setFilterQuery(next)
|
||||
const conditions = flattenFilterConditions(next), values: Record<string, string | null> = { levels: null, topics: null }
|
||||
for (const condition of conditions) if (condition.values.length) values[condition.field] = condition.values.join(",")
|
||||
navigate(values)
|
||||
}
|
||||
const reset = () => navigate({ q: null, levels: null, topics: null, preset: null, range: "15m", from: null, to: null })
|
||||
const openEntry = useCallback((entry: LogEntry) => setSelected(entry), [])
|
||||
const fetchMore = result.loadMore
|
||||
const loadMore = useCallback(() => { setPaused(true); void fetchMore() }, [fetchMore])
|
||||
const pause = useCallback(() => setPaused(true), [])
|
||||
const state = result.stream?.state ?? result.summary?.states.find(s => String(s.serverId) === server)
|
||||
const collectorEnabled = result.stream?.enabled ?? result.summary?.settings.enabled
|
||||
const saveView = () => {
|
||||
if (!viewName.trim()) return
|
||||
const savedQuery = new URLSearchParams(raw); savedQuery.delete("server")
|
||||
const next = [...views.filter(v => v.name !== viewName.trim()), { name: viewName.trim().slice(0, 60), query: savedQuery.toString() }].slice(-20)
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(next)); setViews(next); setViewName(""); toast.success("Представление сохранено") } catch { toast.error("Не удалось сохранить представление") }
|
||||
}
|
||||
const doExport = async (format: "csv" | "json") => {
|
||||
const ac = new AbortController(); exportController.current = ac; setExporting(true)
|
||||
const exportQuery = new URLSearchParams(query)
|
||||
if (live) exportQuery.set("from", new Date(Date.now() - (RANGE_MS[range] ?? 900000)).toISOString())
|
||||
try { await exportLogs(base, exportQuery, format, ac.signal) } catch (e) { if (!ac.signal.aborted) toast.error(e instanceof Error ? e.message : "Экспорт не завершён") } finally { if (!ac.signal.aborted) setExporting(false) }
|
||||
}
|
||||
if (!parsed.success || !RANGES.some(r => r.value === range) || range === "custom" && (!params.get("from") || !params.get("to"))) return <Alert variant="destructive"><AlertTitle>Некорректные фильтры в ссылке</AlertTitle><AlertDescription>Проверьте устройство, уровни и период.<Button size="sm" variant="outline" onClick={() => navigate({ server: ALL_SERVERS_ID, q: null, levels: null, topics: null, preset: null, range: null, from: null, to: null })}>Сбросить ссылку</Button></AlertDescription></Alert>
|
||||
return <div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><div className="flex min-w-0 flex-col gap-0.5"><h1 className="text-lg font-semibold tracking-tight">Логи MikroTik</h1><p className="text-sm text-muted-foreground">{server === ALL_SERVERS_ID ? "Все серверы · сохранённая история" : selectedName}</p></div><div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={range} items={RANGES} onValueChange={value => { if (!value) return; navigate(value === "custom" ? { range: value, from: new Date(anchor - 900000).toISOString(), to: new Date(anchor).toISOString(), live: "0" } : { range: value, from: null, to: null }) }}><SelectTrigger size="sm" aria-label="Период логов"><SelectValue /></SelectTrigger><SelectContent>{RANGES.map(r => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}</SelectContent></Select>
|
||||
<Button size="sm" variant={live ? "default" : "outline"} disabled={!liveAvailable || range === "custom"} onClick={() => navigate({ live: live ? "0" : null })}><ActivityIcon />{live ? "Live включён" : "История"}</Button><Button size="sm" variant="outline" onClick={result.reload}><RefreshCwIcon />Обновить</Button>
|
||||
<Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("csv")}><DownloadIcon />CSV</Button><Button size="sm" variant="outline" disabled={exporting} onClick={() => void doExport("json")}>JSON</Button>
|
||||
</div></div>
|
||||
{range === "custom" && <div className="flex flex-wrap gap-3">{["from", "to"].map(key => <label key={key} className="flex min-w-0 flex-col gap-1 text-xs">{key === "from" ? "Начало (UTC)" : "Конец (UTC)"}<Input type="datetime-local" aria-label={key === "from" ? "Начало периода UTC" : "Конец периода UTC"} value={params.get(key)?.slice(0, 16) ?? ""} onChange={event => { if (event.target.value) navigate({ [key]: new Date(event.target.value + "Z").toISOString() }) }} /></label>)}</div>}
|
||||
<div role="status" className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant={!collectorEnabled ? "secondary" : result.streamError || state?.lastError ? "warning-light" : live && result.stream ? "success-light" : "info-light"}>{collectorEnabled === undefined ? "Проверяем сборщик" : !collectorEnabled ? "Сборщик выключен" : result.streamError ? "Переподключение" : state?.lastError ? "Ошибка сбора" : live && result.stream ? "Live подключён" : "История"}</Badge><span>Последний сбор: {formatLogTime(state?.lastCollectedAt ?? result.summary?.states.map(s => s.lastCollectedAt).filter((s): s is string => !!s).sort().at(-1) ?? null)}</span>{live && <Button size="sm" variant="outline" onClick={() => { if (paused) setPaused(false); else setPaused(true) }}>{paused ? <PlayIcon /> : <PauseIcon />}{paused ? `Новых событий: ${result.pendingCount}` : "Пауза"}</Button>}</div>
|
||||
{collectorEnabled === false && <Alert variant="info"><AlertTitle>История доступна, сбор новых событий выключен</AlertTitle><AlertDescription>Администратор может включить коллектор в разделе <Link className="underline" href="/data-collection#router-logs">«Сбор данных»</Link>.</AlertDescription></Alert>}
|
||||
{(result.streamError || state?.lastError) && <Alert variant="warning"><AlertTitle>Live требует внимания</AlertTitle><AlertDescription>{state?.lastError ?? result.streamError}</AlertDescription></Alert>}
|
||||
{state?.gapAt && <Alert variant="warning"><AlertTitle>Возможен разрыв истории</AlertTitle><AlertDescription>Буфер изменился без пересечения с предыдущим снимком: {formatLogTime(state.gapAt)}. Количество недоступных событий неизвестно.</AlertDescription></Alert>}
|
||||
<Frame dense><FrameHeader className="flex-row flex-wrap items-center justify-between gap-3"><div className="flex flex-col gap-0.5"><FrameTitle>Поток событий</FrameTitle><FrameDescription>Выберите столбец, чтобы открыть его период</FrameDescription></div><div className="flex flex-wrap gap-2">{[["Событий", result.summary?.total], ["Ошибок", result.summary ? result.summary.levels.error + result.summary.levels.critical : undefined], ["Предупреждений", result.summary?.levels.warning]].map(([label, count]) => <span key={label} className="text-xs text-muted-foreground">{label}: <strong className="text-foreground tabular-nums">{count?.toLocaleString("ru-RU") ?? "—"}</strong></span>)}</div></FrameHeader><FramePanel>
|
||||
{result.summaryError ? <Alert variant="warning"><AlertDescription>{result.summaryError}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert> : !result.summary ? <Skeleton className="h-32 w-full" /> : result.summary.buckets.length === 0 ? <div className="flex h-32 items-center justify-center text-sm text-muted-foreground">За выбранный период событий нет</div> : <ChartContainer className="h-36 w-full" config={Object.fromEntries(logLevels.map(level => [level, { label: LEVEL_LABELS[level], color: LEVEL_COLORS[level] }]))}>
|
||||
<BarChart accessibilityLayer data={result.summary.buckets} onClick={value => { const at = value.activeLabel; if (!at || !result.summary) return; const buckets = result.summary.buckets, i = buckets.findIndex(b => b.at === at); if (i < 0) return; const width = Math.max(1000, Math.ceil(((parsed.data.to ? Date.parse(parsed.data.to) : anchor) - Date.parse(parsed.data.from ?? new Date(anchor - 900000).toISOString())) / 60000) * 1000); navigate({ range: "custom", from: buckets[i].at, to: new Date(Date.parse(buckets[i].at) + width - 1).toISOString(), live: "0" }) }}><XAxis dataKey="at" tickFormatter={value => new Date(value).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} minTickGap={40} tickLine={false} axisLine={false} /><YAxis allowDecimals={false} width={32} tickLine={false} axisLine={false} /><ChartTooltip content={<ChartTooltipContent labelFormatter={value => formatLogTime(String(value))} />} />{logLevels.map(level => <Bar key={level} dataKey={level} stackId="events" fill={`var(--color-${level})`} maxBarSize={28} isAnimationActive={false} />)}</BarChart>
|
||||
</ChartContainer>}
|
||||
</FramePanel></Frame>
|
||||
{result.error && <Alert variant="destructive"><AlertTitle>Журнал не загружен</AlertTitle><AlertDescription>{result.error}<Button size="sm" variant="outline" onClick={result.reload}>Повторить</Button></AlertDescription></Alert>}
|
||||
<LogsGrid entries={result.entries} loading={result.loading} total={result.summary?.total ?? null} search={parsed.data.q} onSelect={openEntry} onPause={pause} hasMore={result.hasMore} fetchingMore={result.fetchingMore} loadMore={loadMore} reset={reset} toolbar={<>
|
||||
<div className="flex flex-wrap items-center gap-2"><form className="flex min-w-0 flex-1 basis-full gap-2 sm:basis-auto sm:max-w-md" onSubmit={event => { event.preventDefault(); navigate({ q: search || null }) }}><Input maxLength={500} aria-label="Поиск по сообщениям" placeholder="Сообщение, IP, интерфейс…" value={search} onChange={event => setSearch(event.target.value)} /><Button size="sm" variant="outline" aria-label="Найти события" type="submit"><SearchIcon /></Button></form><Select value={params.get("preset") || (params.get("levels") === "critical,error" ? "errors" : params.get("levels") === "warning" ? "warnings" : "")} items={PRESETS} onValueChange={value => { navigate(value === "errors" ? { levels: "critical,error", preset: null } : value === "warnings" ? { levels: "warning", preset: null } : { preset: value || null, levels: null }) }}><SelectTrigger size="sm" aria-label="Быстрый срез"><SelectValue placeholder="Все события" /></SelectTrigger><SelectContent>{PRESETS.map(p => <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>)}</SelectContent></Select><Button size="sm" variant="ghost" onClick={reset}>Сбросить</Button></div>
|
||||
<Filters size="sm" fields={fields} query={filterQuery} labels={FILTER_LABELS_RU} onBeforeQueryChange={(next, details) => details.reason !== "negate" && next.rules.every(rule => rule.type === "rule") && new Set(next.rules.map(rule => rule.type === "rule" ? rule.path[0] : "")).size === next.rules.length} onQueryChange={applyFilters} />
|
||||
<div className="flex flex-wrap items-center gap-2"><Input aria-label="Название представления" placeholder="Название представления" className="w-full sm:w-48" maxLength={60} value={viewName} onChange={event => setViewName(event.target.value)} /><Button size="sm" variant="outline" disabled={!viewName.trim()} onClick={saveView}><BookmarkPlusIcon />Сохранить</Button>{views.map(view => <Button key={view.name} size="sm" variant="ghost" onClick={() => { const saved = new URLSearchParams(view.query); saved.set("server", server); window.history.pushState(null, "", "/logs?" + saved) }}>{view.name}</Button>)}</div>
|
||||
</>} />
|
||||
{result.entries.length >= 5000 && <p role="status" className="text-xs text-muted-foreground">Показаны последние 5 000 загруженных событий. Для более ранних записей задайте период или используйте экспорт.</p>}
|
||||
<p className="text-xs text-muted-foreground">REST читает буфер memory. Переполнение, очистка и перезагрузка MikroTik могут привести к пропускам. Сохранённая история остаётся в БД.</p>
|
||||
<LogDetailSheet entry={selected} base={base} onClose={() => setSelected(null)} onSelect={openEntry} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Логи MikroTik
|
||||
|
||||
Раздел `/logs` использует общую панель выбора серверов, как `/filters` и `/firewall`.
|
||||
История хранится в PostgreSQL; live читает уже сохранённые события через SSE.
|
||||
|
||||
## Включение
|
||||
|
||||
При следующем запуске backend автоматически применяется миграция `0014_router_logs.sql`.
|
||||
В «Сбор данных → Логи MikroTik» администратор включает сбор. По умолчанию он выключен,
|
||||
фоновый интервал — 10 секунд, хранение — 14 дней. Настройки требуют `mm:settings:admin`;
|
||||
журнал, контекст, статистика и экспорт — `mm:servers:read` (также write/admin этой секции).
|
||||
|
||||
При открытом live выбранный включённый MikroTik опрашивается раз в 2 секунды.
|
||||
Вкладки разделяют опрос; одновременно опрашиваются не более четырёх устройств.
|
||||
Целевая задержка при штатной нагрузке — до 5 секунд. Ошибки сети вызывают backoff до минуты.
|
||||
«Все серверы», выключенные и удалённые устройства доступны в режиме истории.
|
||||
|
||||
Коллектор читает только buffer=memory и не меняет правила RouterOS. После очистки,
|
||||
перезагрузки или переполнения буфера возможны пропуски. Потеря пересечения снимков
|
||||
помечается как возможный разрыв без выдуманного числа пропущенных событий. Время без
|
||||
явного часового пояса не интерпретируется как UTC: журнал использует отмеченное время
|
||||
получения и сохраняет исходную строку RouterOS.
|
||||
|
||||
События удаляются пакетами по времени получения. Очистка продолжает работать и при
|
||||
выключенном сборщике. Дедупликация хранит SHA-256 отпечатки, а не копии сообщений.
|
||||
Удаление устройства не удаляет его историю; повторно открыть её можно по ссылке
|
||||
`/logs?server=<id>` или через общий журнал. Новые таблицы входят в полный `pg_dump`.
|
||||
Планировщик и фоновые задачи ожидаются, а SSE закрываются перед обслуживанием БД.
|
||||
Перед закрытием поток получает `reset`: интерфейс предлагает заново загрузить историю
|
||||
после обслуживания, чтобы не продолжать старый курсор поверх восстановленной БД.
|
||||
|
||||
## Интерфейс и ReUI PRO
|
||||
|
||||
Сохранены компоненты и стиль проекта base-nova. Исходники блоков получены из
|
||||
лицензионного реестра ReUI и адаптированы под RouterOS; демонстрационные данные,
|
||||
изменения инфраструктуры и workflow review из блоков не используются.
|
||||
|
||||
- Основа таблицы, toolbar и графика: [Infra Audit Log](https://reui.io/blocks/solutions/dev-ops/solution-dev-ops-2), [превью](https://reui.io/preview/base/solution-dev-ops-2).
|
||||
- Панель записи, исходные поля и контекст: [Audit Event Detail](https://reui.io/blocks/application/sheet/sheet-14), [превью](https://reui.io/preview/base/sheet-14).
|
||||
- [Data Grid API](https://reui.io/docs/components/base/data-grid), [превью](https://reui.io/components/data-grid).
|
||||
- [Filters API](https://reui.io/docs/components/base/filters), [превью](https://reui.io/components/filters).
|
||||
- [Frame API](https://reui.io/docs/components/base/frame), [превью](https://reui.io/components/frame).
|
||||
- [Badge API](https://reui.io/docs/components/base/badge), [превью](https://reui.io/components/badge).
|
||||
|
||||
Фильтры между полями соединяются AND, значения уровня/topics — OR. Поиск сообщения
|
||||
регистронезависимый, буквальный. «Ошибки входа» — явно обозначенный текстовый шаблон.
|
||||
Представления хранятся локально с привязкой к пользователю и backend. Экспорт CSV/JSON
|
||||
применяет те же фильтры; выборки свыше 50 000 записей отклоняются с предложением сузить
|
||||
период. Формулы spreadsheet в CSV нейтрализуются.
|
||||
|
||||
При прокрутке и открытии записи обновление видимой таблицы приостанавливается, но
|
||||
сбор продолжается. Буфер отображения ограничен 5 000 строками; более ранние записи
|
||||
доступны через период/экспорт. Смена сервера/источника/фильтров отменяет старые запросы.
|
||||
|
||||
## Проверки
|
||||
|
||||
- `npm --prefix backend run build` — contracts и backend.
|
||||
- `npm --prefix backend run test:logs` — дедупликация, нормализация, фильтры, CSV, JWT/RBAC и REST-совместимость терминала.
|
||||
- `npm --prefix backend run test:logs:pg` — интеграция с PostgreSQL и локальным HTTP-стендом RouterOS; используется отдельная случайная схема, которая удаляется после проверки.
|
||||
- Для нагрузочной SQL-проверки установить `LOGS_PERFORMANCE=1` перед `test:logs:pg`: миллион событий, курсорная история и агрегация.
|
||||
- `npm run test:logs` — Playwright: выбор серверов, детали, URL/поиск, экспорт, live-пауза, устаревшие ответы, 5 000 строк, mobile/desktop, светлая/тёмная темы.
|
||||
|
||||
Браузерный профиль использует Webpack и отдельный каталог `tmp/next-logs-test`, чтобы
|
||||
не конфликтовать с кэшем запущенного dev-сервера. SQL-профиль требует доступного
|
||||
`DATABASE_URL` и права создать временную схему; при недоступной БД завершится ошибкой,
|
||||
а не выдаст пропуск за успешную проверку.
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { logStreamSchema, type LogEntry, type LogPage, type LogStream, type LogSummary } from "@mmapp/contracts"
|
||||
import { getLogs, getLogSummary } from "@/shared/api/logs"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
|
||||
export function mergeLogEntries(previous: LogEntry[], incoming: LogEntry[], limit = 5000) {
|
||||
return [...new Map([...previous, ...incoming].map(row => [row.id, row])).values()].sort((a, b) => b.effectiveAt.localeCompare(a.effectiveAt) || (BigInt(a.id) < BigInt(b.id) ? 1 : -1)).slice(0, limit)
|
||||
}
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : String(error)
|
||||
export function useRouterLogs(base: string, query: string, enabled: boolean, live: boolean, paused: boolean, windowMs = 0) {
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]), [page, setPage] = useState<LogPage | null>(null)
|
||||
const [summary, setSummary] = useState<LogSummary | null>(null), [stream, setStream] = useState<LogStream | null>(null)
|
||||
const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null), [summaryError, setSummaryError] = useState<string | null>(null), [streamError, setStreamError] = useState<string | null>(null)
|
||||
const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0)
|
||||
const pending = useRef<LogEntry[]>([]), pausedRef = useRef(paused), initialCursor = useRef<string | null>(null)
|
||||
const lifetime = useRef<AbortController | null>(null), moreBusy = useRef(false)
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
const flush = useCallback(() => { const buffered = pending.current; setEntries(prev => mergeLogEntries(prev, buffered).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs)); pending.current = []; setPendingCount(0) }, [windowMs])
|
||||
useEffect(() => {
|
||||
if (!paused && pending.current.length) flush()
|
||||
}, [paused, flush])
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
const ac = new AbortController(); lifetime.current = ac
|
||||
queueMicrotask(() => { if (!ac.signal.aborted) { setLoading(true); setError(null) } })
|
||||
const currentQuery = () => { const params = new URLSearchParams(query); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString()); return params }
|
||||
void getLogs(base, currentQuery(), ac.signal).then(data => {
|
||||
if (ac.signal.aborted) return
|
||||
setEntries(data.entries); setPage(data); initialCursor.current = data.watermark
|
||||
}).catch(e => { if (!ac.signal.aborted) setError(message(e)) }).finally(() => { if (!ac.signal.aborted) setLoading(false) })
|
||||
const readSummary = () => { void getLogSummary(base, currentQuery(), ac.signal).then(data => { if (!ac.signal.aborted) { setSummary(data); setSummaryError(null) } }).catch(e => { if (!ac.signal.aborted) setSummaryError(message(e)) }) }
|
||||
readSummary()
|
||||
const timer = setInterval(readSummary, 10_000)
|
||||
return () => { ac.abort(); clearInterval(timer); initialCursor.current = null }
|
||||
}, [base, query, enabled, revision, windowMs])
|
||||
|
||||
const ready = page !== null && !loading && !error
|
||||
useEffect(() => {
|
||||
if (!enabled || !live || !ready || !initialCursor.current) return
|
||||
const ac = new AbortController()
|
||||
let cursor = initialCursor.current, retry = 1000
|
||||
void (async () => {
|
||||
while (!ac.signal.aborted) {
|
||||
const connection = new AbortController(), abort = () => connection.abort()
|
||||
ac.signal.addEventListener("abort", abort, { once: true })
|
||||
let watchdog: ReturnType<typeof setTimeout> | undefined
|
||||
const heartbeat = () => { clearTimeout(watchdog); watchdog = setTimeout(() => connection.abort(), 20_000) }
|
||||
try {
|
||||
heartbeat()
|
||||
const params = new URLSearchParams(query); params.set("cursor", cursor); if (windowMs) params.set("from", new Date(Date.now() - windowMs).toISOString())
|
||||
const res = await fetch(resolveApiUrl(base, "/api/logs/live?" + params), { headers: withAuthHeaders({ Accept: "text/event-stream" }), signal: connection.signal, credentials: "include" })
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.json().catch(() => null)
|
||||
throw Object.assign(new Error(body?.error ?? `Live недоступен (HTTP ${res.status})`), { status: res.status })
|
||||
}
|
||||
const reader = res.body.getReader(), decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
try {
|
||||
while (!ac.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) throw new Error("Поток завершён; переподключение…")
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""
|
||||
if (buffer.length > 8 * 1024 * 1024) throw new Error("Слишком большой ответ live")
|
||||
for (const block of blocks) {
|
||||
const type = block.match(/^event:\s*(.+)$/m)?.[1]
|
||||
const data = block.split(/\r?\n/).filter(line => line.startsWith("data:")).map(line => line.slice(5).trim()).join("\n")
|
||||
if (type === "reset") {
|
||||
const reason = "Поток остановлен для обслуживания БД. После завершения обновите журнал, чтобы заново загрузить историю."
|
||||
if (!ac.signal.aborted) setError(reason)
|
||||
return
|
||||
}
|
||||
if (type === "error") throw new Error(JSON.parse(data).error)
|
||||
if (type !== "batch") continue
|
||||
const batch = logStreamSchema.parse(JSON.parse(data))
|
||||
const expectedServer = new URLSearchParams(query).get("serverId")
|
||||
if (batch.entries.some(row => String(row.serverId) !== expectedServer) || batch.state && String(batch.state.serverId) !== expectedServer || BigInt(batch.cursor) < BigInt(cursor)) throw new Error("Live-поток относится к другому устройству или содержит некорректный курсор")
|
||||
heartbeat(); retry = 1000; cursor = batch.cursor
|
||||
if (ac.signal.aborted) return
|
||||
setStream(batch); setStreamError(null)
|
||||
if (batch.entries.length) {
|
||||
if (pausedRef.current) {
|
||||
pending.current = mergeLogEntries(pending.current, batch.entries)
|
||||
setPendingCount(n => n + batch.entries.length)
|
||||
} else setEntries(prev => mergeLogEntries(prev, batch.entries).filter(row => !windowMs || Date.parse(row.effectiveAt) >= Date.now() - windowMs))
|
||||
} else if (!pausedRef.current && windowMs) {
|
||||
setEntries(prev => { const firstExpired = prev.findIndex(row => Date.parse(row.effectiveAt) < Date.now() - windowMs); return firstExpired < 0 ? prev : prev.slice(0, firstExpired) })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}) }
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return
|
||||
setStreamError(message(e))
|
||||
if (e && typeof e === "object" && "status" in e && [401, 403, 404].includes(Number(e.status))) return
|
||||
} finally { clearTimeout(watchdog); connection.abort(); ac.signal.removeEventListener("abort", abort) }
|
||||
await new Promise<void>(resolve => {
|
||||
const done = () => { clearTimeout(timer); ac.signal.removeEventListener("abort", done); resolve() }
|
||||
const timer = setTimeout(done, retry); ac.signal.addEventListener("abort", done, { once: true }); if (ac.signal.aborted) done()
|
||||
})
|
||||
retry = Math.min(15_000, retry * 2)
|
||||
}
|
||||
})()
|
||||
return () => ac.abort()
|
||||
}, [base, query, enabled, live, ready, revision, windowMs])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!page?.nextCursor || moreBusy.current || !enabled) return
|
||||
moreBusy.current = true; setFetchingMore(true)
|
||||
const signal = lifetime.current?.signal
|
||||
try {
|
||||
const params = new URLSearchParams(query); params.set("cursor", page.nextCursor); params.set("watermark", page.watermark)
|
||||
const data = await getLogs(base, params, signal)
|
||||
if (signal?.aborted) return
|
||||
setEntries(prev => mergeLogEntries(prev, data.entries)); setPage(data)
|
||||
} catch (e) { if (!signal?.aborted) setError(message(e)) }
|
||||
finally { moreBusy.current = false; if (!signal?.aborted) setFetchingMore(false) }
|
||||
}, [base, query, enabled, page])
|
||||
const reload = useCallback(() => { pending.current = []; setPendingCount(0); setRevision(n => n + 1) }, [])
|
||||
return { entries, summary, stream, loading, fetchingMore, error, summaryError, streamError, pendingCount, hasMore: !!page?.nextCursor && entries.length < 5000, loadMore, reload, flush }
|
||||
}
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -114,7 +114,16 @@ export interface GeoipUpdateRunSnapshot {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface RouterLogsRunSnapshot {
|
||||
v: 1
|
||||
job: "router_logs"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
errors: string[]
|
||||
servers: { serverId: number; name: string; added: number; gap: boolean; error?: string }[]
|
||||
}
|
||||
export type SchedulerRunSnapshot =
|
||||
| RouterLogsRunSnapshot
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
| PingRunSnapshot
|
||||
@@ -234,7 +243,7 @@ export function parseSchedulerRunSnapshot(raw: unknown): SchedulerRunSnapshot |
|
||||
if (o.skipped !== undefined && typeof o.skipped !== "boolean") return null
|
||||
if (o.fatalError !== undefined && typeof o.fatalError !== "string") return null
|
||||
for (const field of ["errors", "eventErrors"]) if (o[field] !== undefined && (!Array.isArray(o[field]) || !(o[field] as unknown[]).every(error => typeof error === "string"))) return null
|
||||
const arrays: Record<string, string> = { traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
|
||||
const arrays: Record<string, string> = { router_logs: "servers", traffic: "servers", uptime_resources: "servers", servers_rest_ping: "servers", uptime_ping: "probes", uptime_speed: "runs" }
|
||||
if (Object.hasOwn(arrays, o.job)) {
|
||||
if (!Array.isArray(o[arrays[o.job]])) return null
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
/** Последний ключ — движок оповещений (читает БД после коллекторов; см. описание `alert_engine`). */
|
||||
export const SCHEDULER_JOB_KEYS = [
|
||||
"router_logs",
|
||||
"traffic",
|
||||
"servers_rest_ping",
|
||||
"uptime_resources",
|
||||
@@ -17,6 +18,7 @@ export const SCHEDULER_JOB_KEYS = [
|
||||
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
|
||||
|
||||
export const SCHEDULER_JOB_LABELS: Record<string, string> = {
|
||||
router_logs: "Логи MikroTik",
|
||||
traffic: "Трафик",
|
||||
servers_rest_ping: "Серверы: REST API",
|
||||
uptime_resources: "Uptime: ресурсы",
|
||||
@@ -32,6 +34,7 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
|
||||
|
||||
/** Кратко — для раскрытой карточки прогона и подсказок. */
|
||||
export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
||||
router_logs: "REST-сбор буфера memory в PostgreSQL. Параметры — в панели «Логи MikroTik» ниже; live ускоряет опрос выбранного устройства до 2 секунд.",
|
||||
traffic: "Опрос интерфейсов RouterOS, запись сэмплов трафика в PostgreSQL.",
|
||||
servers_rest_ping:
|
||||
"Проверка доступности каталога серверов: GET /system/identity (RouterOS REST), замер задержки без полного опроса.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"backend"
|
||||
],
|
||||
"scripts": {
|
||||
"test:logs": "playwright test --config playwright.logs-context.config.ts",
|
||||
"dev": "next dev",
|
||||
"build": "npm run build -w @mmapp/contracts && next build",
|
||||
"postinstall": "npm run build -w @mmapp/contracts",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./servers.js"
|
||||
export * from "./logs.js"
|
||||
export * from "./alerts.js"
|
||||
export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const logLevels = ["critical", "error", "warning", "info", "debug", "unknown"] as const
|
||||
export const logLevelSchema = z.enum(logLevels)
|
||||
export type LogLevel = z.infer<typeof logLevelSchema>
|
||||
const id = z.string().regex(/^\d+$/)
|
||||
const instant = z.string().datetime({ offset: true })
|
||||
export const logEntrySchema = z.object({
|
||||
id, serverId: z.number().int(), serverName: z.string(), host: z.string(),
|
||||
routerId: z.string(), rawTime: z.string(), eventAt: instant.nullable(), receivedAt: instant,
|
||||
effectiveAt: instant, timeKind: z.enum(["router", "receipt"]), topics: z.array(z.string()),
|
||||
level: logLevelSchema, message: z.string(), generation: z.number().int(),
|
||||
})
|
||||
export type LogEntry = z.infer<typeof logEntrySchema>
|
||||
export const logFiltersSchema = z.object({
|
||||
serverId: z.coerce.number().int().positive().optional(), from: instant.default(() => new Date(Date.now() - 900000).toISOString()), to: instant.optional(),
|
||||
levels: z.string().max(100).default("").refine(v => !v || v.split(",").every(x => (logLevels as readonly string[]).includes(x))),
|
||||
topics: z.string().max(500).default(""), q: z.string().max(500).default(""),
|
||||
preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
|
||||
}).refine(v => !v.from || !v.to || Date.parse(v.from) <= Date.parse(v.to), "Некорректный период")
|
||||
export type LogFilters = z.infer<typeof logFiltersSchema>
|
||||
export const logPageQuerySchema = logFiltersSchema.safeExtend({
|
||||
cursor: z.string().max(200).optional(), watermark: id.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(200),
|
||||
})
|
||||
export const logStateSchema = z.object({ serverId: z.number().int(), serverName: z.string(), host: z.string(),
|
||||
lastCollectedAt: instant.nullable(), lastError: z.string().nullable(), gapAt: instant.nullable(),
|
||||
nextPollAt: instant.nullable(), failures: z.number().int(), generation: z.number().int() })
|
||||
export const logPageSchema = z.object({ entries: z.array(logEntrySchema), nextCursor: z.string().nullable(), watermark: id })
|
||||
export type LogPage = z.infer<typeof logPageSchema>
|
||||
export const logSettingsSchema = z.object({ enabled: z.boolean(), intervalSec: z.number().int().min(5).max(3600), retentionDays: z.number().int().min(1).max(365) })
|
||||
export type LogSettings = z.infer<typeof logSettingsSchema>
|
||||
export const logSettingsPatchSchema = logSettingsSchema.partial().strict().refine(v => Object.keys(v).length > 0)
|
||||
export const logSummarySchema = z.object({ total: z.number(), levels: z.record(logLevelSchema, z.number()),
|
||||
buckets: z.array(z.object({ at: instant, critical: z.number(), error: z.number(), warning: z.number(), info: z.number(), debug: z.number(), unknown: z.number() })),
|
||||
settings: logSettingsSchema, states: z.array(logStateSchema) })
|
||||
export type LogSummary = z.infer<typeof logSummarySchema>
|
||||
export const logStreamSchema = z.object({ entries: z.array(logEntrySchema), cursor: id, state: logStateSchema.nullable(), enabled: z.boolean() })
|
||||
export type LogStream = z.infer<typeof logStreamSchema>
|
||||
export type LogStreamReset = { reason: "database-maintenance" }
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } },
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod"
|
||||
import { logEntrySchema, logPageSchema, logSettingsSchema, logSettingsPatchSchema, logSummarySchema } from "@mmapp/contracts"
|
||||
import { requestBlob, requestJson } from "./http-client"
|
||||
|
||||
const timedSignal = (signal?: AbortSignal, ms = 30000) => AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(ms)])
|
||||
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (!parsed.success) throw new Error("Ответ сервера журнала не подтверждён: некорректные данные")
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export async function getLogs(base: string, params: URLSearchParams, signal?: AbortSignal) {
|
||||
const result = confirmed(logPageSchema, await requestJson(base, "/api/logs?" + params, { signal: timedSignal(signal) }))
|
||||
const server = params.get("serverId")
|
||||
if (server && result.entries.some(row => String(row.serverId) !== server)) throw new Error("Журнал относится к другому устройству")
|
||||
return result
|
||||
}
|
||||
export async function getLogSummary(base: string, params: URLSearchParams, signal?: AbortSignal) {
|
||||
return confirmed(logSummarySchema, await requestJson(base, "/api/logs/summary?" + params, { signal: timedSignal(signal) }))
|
||||
}
|
||||
export async function getLogContext(base: string, id: string, signal?: AbortSignal) {
|
||||
return confirmed(z.object({ entry: logEntrySchema, before: z.array(logEntrySchema), after: z.array(logEntrySchema) }), await requestJson(base, `/api/logs/${encodeURIComponent(id)}/context`, { signal: timedSignal(signal) }))
|
||||
}
|
||||
export async function getLogSettings(base: string, signal?: AbortSignal) { return confirmed(logSettingsSchema, await requestJson(base, "/api/logs/settings", { signal: timedSignal(signal) })) }
|
||||
export async function putLogSettings(base: string, patch: z.infer<typeof logSettingsPatchSchema>, signal?: AbortSignal) {
|
||||
const input = logSettingsPatchSchema.parse(patch)
|
||||
const result = confirmed(z.object({ ok: z.literal(true), settings: logSettingsSchema }), await requestJson(base, "/api/logs/settings", { method: "PUT", body: JSON.stringify(input), signal: timedSignal(signal) }))
|
||||
if (Object.entries(input).some(([key, value]) => result.settings[key as keyof typeof result.settings] !== value)) throw new Error("Сервер подтвердил другие настройки; обновите данные")
|
||||
return result.settings
|
||||
}
|
||||
export async function exportLogs(base: string, params: URLSearchParams, format: "csv" | "json", signal?: AbortSignal) {
|
||||
const query = new URLSearchParams(params); query.set("format", format)
|
||||
const res = await requestBlob(base, "/api/logs/export?" + query, { signal: timedSignal(signal, 60000) })
|
||||
const blob = await res.blob(), url = URL.createObjectURL(blob), anchor = document.createElement("a")
|
||||
anchor.href = url; anchor.download = `router-logs.${format}`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
const headers = { "access-control-allow-origin": "http://127.0.0.1:3101", "access-control-allow-credentials": "true", "access-control-allow-methods": "GET,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
|
||||
const levels = { critical: 1, error: 0, warning: 1, info: 1, debug: 0, unknown: 0 }
|
||||
const settings = { enabled: true, intervalSec: 10, retentionDays: 14 }
|
||||
const servers = [1, 2].map(id => ({ id, name: `MikroTik-${id}`, host: `192.0.2.${id}`, enabled: true, country: "RU", type: "home-router", status: "online" }))
|
||||
const row = (id: number, serverId = 1) => ({ id: String(id), serverId, serverName: `MikroTik-${serverId}`, host: `192.0.2.${serverId}`, routerId: `*${id}`, rawTime: "12:00:00", eventAt: null, receivedAt: new Date().toISOString(), effectiveAt: new Date().toISOString(), timeKind: "receipt", topics: ["system", id === 2 ? "warning" : id === 3 ? "error" : "info"], level: id === 2 ? "warning" : id === 3 ? "critical" : "info", message: `Событие ${id} сервера ${serverId} — interface ether1`, generation: 0 })
|
||||
const state = (id: number) => ({ serverId: id, serverName: `MikroTik-${id}`, host: `192.0.2.${id}`, generation: 0, lastCollectedAt: new Date().toISOString(), lastError: null, gapAt: null, nextPollAt: null, failures: 0 })
|
||||
async function setup(page: Page, options: { count?: number; historyError?: boolean } = {}) {
|
||||
const calls: URL[] = []
|
||||
await page.route("**/health", route => route.fulfill({ headers, json: { status: "ok" } }))
|
||||
await page.route("**/api/**", route => {
|
||||
const url = new URL(route.request().url()), path = url.pathname
|
||||
calls.push(url)
|
||||
if (path === "/api/auth/config") return route.fulfill({ headers, json: { required: false } })
|
||||
if (path === "/api/servers") return route.fulfill({ headers, json: servers })
|
||||
if (path === "/api/logs/settings") return route.fulfill({ headers, json: route.request().method() === "PUT" ? { ok: true, settings: { ...settings, ...route.request().postDataJSON() } } : settings })
|
||||
if (path === "/api/logs/summary") return route.fulfill({ headers, json: { settings, total: options.count ?? 3, levels, states: [state(1), state(2)], buckets: [{ at: new Date(Date.now() - 60000).toISOString(), ...levels }] } })
|
||||
if (path === "/api/logs") return route.fulfill(options.historyError ? { headers, status: 503, json: { error: "История временно недоступна" } } : { headers, json: { entries: Array.from({ length: options.count ?? 3 }, (_, i) => row((options.count ?? 3) - i, Number(url.searchParams.get("serverId") ?? 1))), nextCursor: null, watermark: String(options.count ?? 3) } })
|
||||
if (path === "/api/logs/live") return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [], cursor: url.searchParams.get("cursor"), state: state(Number(url.searchParams.get("serverId"))), enabled: true })}\n\n` })
|
||||
if (/\/api\/logs\/\d+\/context$/.test(path)) { const id = Number(path.split("/").at(-2)); return route.fulfill({ headers, json: { entry: row(id), before: [row(1)], after: [row(4)] } }) }
|
||||
if (path === "/api/logs/export") return route.fulfill({ headers: { ...headers, "content-type": "text/csv", "content-disposition": 'attachment; filename="router-logs.csv"' }, body: "time,message\nnow,test" })
|
||||
return route.fulfill({ headers, json: {} })
|
||||
})
|
||||
return calls
|
||||
}
|
||||
test("Логи: общий выбор серверов, история и детали", async ({ page }) => {
|
||||
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
|
||||
const calls = await setup(page)
|
||||
await page.goto("/logs")
|
||||
await expect(page).toHaveURL(/server=1/)
|
||||
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Открыть событие 3", exact: true }).click()
|
||||
await expect(page.getByRole("dialog")).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: "Событие RouterOS" })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "Исходные поля" }).click()
|
||||
await expect(page.getByRole("tabpanel")).toContainText('".id": "*3"')
|
||||
await page.getByRole("tab", { name: "Контекст", exact: true }).click()
|
||||
await expect(page.getByRole("tabpanel")).toContainText("Событие 4")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.getByRole("option", { name: /MikroTik-2/ }).click()
|
||||
await expect(page).toHaveURL(/server=2/)
|
||||
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
|
||||
await expect.poll(() => calls.some(url => url.pathname === "/api/logs/live" && url.searchParams.get("serverId") === "2")).toBe(true)
|
||||
await page.getByRole("option", { name: /Все серверы/ }).click()
|
||||
await expect(page).toHaveURL(/server=all/)
|
||||
await expect(page.getByText("Все серверы · сохранённая история")).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
test("Логи: поиск в URL, представления и экспорт используют фильтры", async ({ page }) => {
|
||||
const calls = await setup(page)
|
||||
await page.goto("/logs?server=1&live=0")
|
||||
await page.getByRole("textbox", { name: "Поиск по сообщениям" }).fill("ether1")
|
||||
await page.getByRole("button", { name: "Найти события" }).click()
|
||||
await expect(page).toHaveURL(/q=ether1/)
|
||||
await expect.poll(() => calls.some(url => url.pathname === "/api/logs" && url.searchParams.get("q") === "ether1")).toBe(true)
|
||||
await expect(page.locator("mark").first()).toHaveText("ether1")
|
||||
await page.getByRole("textbox", { name: "Название представления" }).fill("Мой WAN")
|
||||
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("button", { name: "CSV", exact: true }).click()
|
||||
await download
|
||||
expect(calls.find(url => url.pathname === "/api/logs/export")?.searchParams.get("q")).toBe("ether1")
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
|
||||
})
|
||||
test("Логи: пауза буферизует новые события", async ({ page }) => {
|
||||
await setup(page)
|
||||
let release!: () => void, started = false
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/logs/live?**", async route => {
|
||||
if (started) return route.fallback()
|
||||
started = true; await gate
|
||||
await route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [row(999)], cursor: "999", state: state(1), enabled: true })}\n\n` }).catch(() => {})
|
||||
})
|
||||
await page.goto("/logs?server=1")
|
||||
await expect.poll(() => started).toBe(true)
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
release()
|
||||
await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible()
|
||||
})
|
||||
test("Логи: поздний ответ прежнего устройства не меняет новый журнал", async ({ page }) => {
|
||||
await setup(page)
|
||||
let release!: () => void, started = false
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/logs?**", async route => {
|
||||
if (new URL(route.request().url()).searchParams.get("serverId") !== "1") return route.fallback()
|
||||
started = true; await gate
|
||||
await route.fulfill({ headers, json: { entries: [{ ...row(888), message: "Поздний ответ первого устройства" }], watermark: "888", nextCursor: null } }).catch(() => {})
|
||||
})
|
||||
await page.goto("/logs?server=1")
|
||||
await expect.poll(() => started).toBe(true)
|
||||
await page.getByRole("option", { name: /MikroTik-2/ }).click()
|
||||
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
|
||||
release()
|
||||
await expect(page.getByText("Поздний ответ первого устройства")).toHaveCount(0)
|
||||
})
|
||||
for (const width of [1440, 390]) for (const theme of ["light", "dark"]) test(`Логи: ${width}px ${theme}, виртуализация 5000 строк`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme)
|
||||
await setup(page, { count: 5000 })
|
||||
await page.goto("/logs?server=1&live=0")
|
||||
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.getByText("Показаны последние 5 000 загруженных событий.", { exact: false })).toBeVisible()
|
||||
expect(await page.locator("tbody tr").count()).toBeLessThan(60)
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
if (width === 390) {
|
||||
await page.getByRole("button", { name: "Серверы", exact: true }).click()
|
||||
await expect(page.getByRole("option", { name: /MikroTik-2/ })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
await page.screenshot({ path: `test-results/logs-${width}-${theme}.png`, fullPage: true })
|
||||
})
|
||||
test("Логи: ошибка истории отображается с повтором", async ({ page }) => {
|
||||
await setup(page, { historyError: true })
|
||||
await page.goto("/logs?server=1&live=0")
|
||||
await expect(page.getByText("История временно недоступна")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
|
||||
})
|
||||
test("Логи: настройки сборщика сохраняют подтверждённые параметры", async ({ page }) => {
|
||||
await setup(page)
|
||||
const writes: unknown[] = []
|
||||
await page.route("**/api/logs/settings", async route => {
|
||||
if (route.request().method() !== "PUT") return route.fallback()
|
||||
const payload = route.request().postDataJSON(); writes.push(payload)
|
||||
await route.fulfill({ headers, json: { ok: true, settings: { ...settings, ...payload } } })
|
||||
})
|
||||
await page.goto("/data-collection#router-logs")
|
||||
await expect(page.getByRole("textbox", { name: "Интервал сбора логов", exact: true })).toBeEnabled({ timeout: 15000 })
|
||||
await page.getByRole("textbox", { name: "Интервал сбора логов", exact: true }).fill("20")
|
||||
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("7")
|
||||
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
|
||||
await expect(page.getByText("Настройки сборщика логов сохранены", { exact: true })).toBeVisible()
|
||||
expect(writes).toEqual([{ enabled: true, intervalSec: 20, retentionDays: 7 }])
|
||||
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("0")
|
||||
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
|
||||
await expect(page.getByText("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней")).toBeVisible()
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
test("Логи: обслуживание БД требует нового снимка истории", async ({ page }) => {
|
||||
await setup(page)
|
||||
let connections = 0
|
||||
await page.route("**/api/logs/live?**", route => {
|
||||
connections++
|
||||
return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: 'event: reset\ndata: {"reason":"database-maintenance"}\n\n' })
|
||||
})
|
||||
await page.goto("/logs?server=1")
|
||||
await expect(page.getByText("Поток остановлен для обслуживания БД.", { exact: false })).toBeVisible()
|
||||
expect(connections).toBe(1)
|
||||
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
|
||||
})
|
||||
Reference in new issue
Block a user