Files
MikrotikManager/hooks/use-router-logs.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

124 lines
8.8 KiB
TypeScript

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