feat(logs): добавить раздел логов с историей и живым потоком
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
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
This commit is contained in:
1 parent
e461dc21bd
commit
5efd0339fb
37 files changed
+1399
-7
No files matched your search
@@ -0,0 +1,123 @@
|
||||
"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 }
|
||||
}
|
||||
Reference in new issue
Block a user