124 lines
8.8 KiB
TypeScript
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 }
|
|
}
|