Files
MikrotikManager/backend/src/services/router-logs-query.ts
T
Denozordec 5efd0339fb
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
feat(logs): добавить раздел логов с историей и живым потоком
2026-10-16 22:13:48 +07:00

69 lines
5.6 KiB
TypeScript

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 }
}