"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([]), [page, setPage] = useState(null) const [summary, setSummary] = useState(null), [stream, setStream] = useState(null) const [loading, setLoading] = useState(enabled), [fetchingMore, setFetchingMore] = useState(false) const [error, setError] = useState(null), [summaryError, setSummaryError] = useState(null), [streamError, setStreamError] = useState(null) const [revision, setRevision] = useState(0), [pendingCount, setPendingCount] = useState(0) const pending = useRef([]), pausedRef = useRef(paused), initialCursor = useRef(null) const lifetime = useRef(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 | 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(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 } }