import type { PoolClient } from "pg" import { logLevels, type LogEntry, type LogFilters } from "@mmapp/contracts" import { pool } from "../db/index.js" import { getLogSettings, getLogStates } from "./router-logs-collector.js" import { logWhere } from "./router-logs-model.js" export const logColumns = `id::text, server_id AS "serverId",server_name AS "serverName",host,router_id AS "routerId",raw_time AS "rawTime",event_at AS "eventAt",received_at AS "receivedAt",effective_at AS "effectiveAt",CASE WHEN event_at IS NULL THEN 'receipt' ELSE 'router' END AS "timeKind",topics,level,message,generation` export function encodeLogCursor(row: LogEntry) { return Buffer.from(JSON.stringify([row.effectiveAt, row.id])).toString("base64url") } export function decodeLogCursor(cursor: string): [string, string] { try { const value = JSON.parse(Buffer.from(cursor, "base64url").toString()) if (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && Number.isFinite(Date.parse(value[0])) && /^\d+$/.test(value[1])) return value as [string, string] } catch { /* invalid cursor */ } throw Object.assign(new Error("Некорректный курсор журнала"), { statusCode: 400 }) } export async function logWatermark(client: PoolClient, serverId?: number) { const res = await client.query<{ id: string }>(`SELECT COALESCE(max(id),0)::text AS id FROM router_log_entries${serverId ? " WHERE server_id=$1" : ""}`, serverId ? [serverId] : []) return res.rows[0].id } export async function readLogPage(filters: LogFilters, options: { cursor?: string; watermark?: string; limit: number }) { const client = await pool.connect() try { await client.query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY") const watermark = options.watermark ?? await logWatermark(client, filters.serverId) const conditions = ["id <= $1::bigint"], values: unknown[] = [watermark] if (options.cursor) { const [at, id] = decodeLogCursor(options.cursor); conditions.push("(effective_at,id) < ($2::timestamptz,$3::bigint)"); values.push(at, id) } const where = logWhere(filters, conditions, values) const rows = (await client.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY effective_at DESC,id DESC LIMIT $${where.values.length + 1}`, [...where.values, options.limit + 1])).rows await client.query("COMMIT") const entries = rows.slice(0, options.limit) return { entries, nextCursor: rows.length > options.limit ? encodeLogCursor(entries[entries.length - 1]) : null, watermark } } catch (error) { await client.query("ROLLBACK"); throw error } finally { client.release() } } export async function readLogSummary(filters: LogFilters) { const where = logWhere(filters) const from = filters.from ? Date.parse(filters.from) : Date.now() - 900_000 const to = filters.to ? Date.parse(filters.to) : Date.now() const bucketSec = Math.max(1, Math.ceil((to - from) / 1000 / 60)) const { rows } = await pool.query<{ at: Date; level: string; count: number }>(`SELECT to_timestamp(floor(extract(epoch FROM effective_at)/$${where.values.length + 1})*$${where.values.length + 1}) AS at,level,count(*)::int AS count FROM router_log_entries WHERE ${where.sql} GROUP BY 1,2 ORDER BY 1`, [...where.values, bucketSec]) const levels = Object.fromEntries(logLevels.map(level => [level, 0])) as Record const buckets = new Map() for (const row of rows) { const at = row.at.toISOString() const level = row.level as typeof logLevels[number] levels[level] += row.count if (!buckets.has(at)) buckets.set(at, { at, ...Object.fromEntries(logLevels.map(l => [l, 0])) } as { at: string } & typeof levels) buckets.get(at)![level] += row.count } return { total: Object.values(levels).reduce((a, b) => a + b, 0), levels, buckets: [...buckets.values()], settings: await getLogSettings(), states: await getLogStates(filters.serverId) } } export async function readLogEntry(id: string) { return (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE id=$1`, [id])).rows[0] ?? null } export async function readLogContext(id: string) { const entry = await readLogEntry(id) if (!entry) return null const before = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)<($2::timestamptz,$3::bigint) ORDER BY effective_at DESC,id DESC LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows const after = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE server_id=$1 AND (effective_at,id)>($2::timestamptz,$3::bigint) ORDER BY effective_at,id LIMIT 10`, [entry.serverId, entry.effectiveAt, id])).rows return { entry, before: before.reverse(), after } } export async function readLogStream(filters: LogFilters, cursor: string) { // A committed upper bound is safe per device: writers are serialized by its advisory lock. // Advance across non-matching events too, so a quiet filtered stream never rescans its backlog. const { rows: [upper] } = await pool.query<{ watermark: string }>(`SELECT GREATEST(COALESCE(max(id),0),$2::bigint)::text AS watermark FROM router_log_entries WHERE server_id=$1`, [filters.serverId, cursor]) const where = logWhere(filters, ["id>$1::bigint", "id<=$2::bigint"], [cursor, upper.watermark]) const entries = (await pool.query(`SELECT ${logColumns} FROM router_log_entries WHERE ${where.sql} ORDER BY id LIMIT 200`, where.values)).rows return { entries, cursor: entries.length === 200 ? entries[entries.length - 1].id : upper.watermark, state: (await getLogStates(filters.serverId))[0] ?? null, enabled: (await getLogSettings()).enabled } }