import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import '@/i18n/domains' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { PageContainer } from '@/components/PageContainer' import { TimeRangePicker } from '@/components/TimeRangePicker' import { ErrorState } from '@/components/ErrorState' import { getUser } from '@/lib/auth-storage' import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs' import { Play, Radio, Loader2 } from 'lucide-react' import { QueryInput } from './components/QueryInput' import { LokiLabelBrowser } from './components/LokiLabelBrowser' import { LogVolumeTimeline } from './components/LogVolumeTimeline' import { PolicyGroupsPage } from './components/PolicyGroupsPage' import { SourcesManager } from './components/SourcesManager' import { LogContextDialog, type LogContext } from './components/LogContextDialog' import { LogResultsPanel } from './components/LogResultsPanel' import { literalFilters } from './query-utils' import { readQueryHistory, rememberQuery } from './query-history' const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) const ns = (ms: number) => Math.round(ms).toString() + '000000' type Bucket = { timestamp: string; count: number } /** 是否为主动取消(abort)造成的错误 —— 这类不该提示用户 */ function isAbortError(e: unknown): boolean { return e instanceof DOMException ? e.name === 'AbortError' : typeof e === 'object' && e !== null && (e as { name?: string }).name === 'AbortError' } /** * 把后端错误翻译成用户能看懂的一句话。 * 之前统一显示「日志量统计失败」,把 429/400/超时/Loki 不可用全糊在一起, * 用户既不知道该缩小范围还是该等,也无从判断是不是平台挂了。 */ function describeQueryError(e: unknown, fallback: string): string { const raw = e instanceof Error ? e.message : String(e ?? '') if (!raw) return fallback const lower = raw.toLowerCase() if (raw.includes('统计范围过大') || lower.includes('too large') || lower.includes('range exceeds')) { return raw } if (raw.includes('较多日志量统计') || lower.includes('too many requests') || lower.includes('429')) { return '当前有其他日志量统计正在进行(单实例 Loki 保护),请等几秒后重试' } if (lower.includes('no endpoints available') || lower.includes('http 502') || lower.includes('http 503')) { return 'Loki 暂时不可用(Pod 未就绪或无端点),请稍后重试;若持续请查看 Loki 实例状态' } if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('aborted')) { return '统计超时(数据量过大),请缩小时间范围后重试' } // 其它情况:保留原始信息,便于排查(截断避免撑爆布局) return raw.length > 160 ? raw.slice(0, 160) + '…' : raw } export default function LogsPage() { const { t } = useTranslation('logs') const admin = getUser()?.role === 'admin' const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) const [query, setQuery] = useState('') const [executedQuery, setExecutedQuery] = useState('') const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null) const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 300000))) const [end, setEnd] = useState(() => toLocal(new Date())) // 默认 200:实测 671B/条,200 条约 131KB、DOM 200 行渲染顺畅。 // 后端 maxLogQueryLimit=1000,所以下拉不给 2000/5000(否则静默截断成 1000,是假选项) const [limit, setLimit] = useState(200) const [queryHistory, setQueryHistory] = useState(readQueryHistory) const [entries, setEntries] = useState([]) const [searched, setSearched] = useState(false) const [buckets, setBuckets] = useState([]) const [histogramLoading, setHistogramLoading] = useState(false) // 从 boolean 改为字符串:原来只显示"日志量统计失败", // 真实原因(429 并发超限 / 400 范围过大 / Loki 不可用)全被吞掉。 const [histogramError, setHistogramError] = useState('') /** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */ const [totalEntries, setTotalEntries] = useState(0) /** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */ const histAbortRef = useRef(null) const [cursor, setCursor] = useState('') const [pageIndex, setPageIndex] = useState(0) const [pageCursors, setPageCursors] = useState(['']) const [loading, setLoading] = useState(false) const [loadingStartedAt, setLoadingStartedAt] = useState(null) const [error, setError] = useState('') const [context, setContext] = useState(null) const [tab, setTab] = useState<'query' | 'sources' | 'access'>('query') const [tail, setTail] = useState(false) const requestVersion = useRef(0) const reloadSources = useCallback(async () => { try { const result = await logsApi.sources() setSources(result.list) setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0)) } catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) } }, [t]) useEffect(() => { void reloadSources() }, [reloadSources]) useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(''); setTotalEntries(0); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId]) // 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。 // 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断 useEffect(() => { const prefill = new URLSearchParams(window.location.search).get('q')?.trim() if (!prefill) return setQuery(prefill) void search('', false, undefined, prefill) window.history.replaceState(null, '', window.location.pathname) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) function range(): { start: number; end: number } | null { const a = new Date(start).getTime(), b = new Date(end).getTime() if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError(t('toolbar.rangeInvalid')); return null } return { start: Number(ns(a)), end: Number(ns(b)) } } async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) { const paging = targetPage !== undefined const r = paging ? executedRange : selectedRange ?? range() const searchQuery = paging ? executedQuery : overrideQuery ?? query.trim() if (!r || !sourceId || !searchQuery) return const version = ++requestVersion.current setLoading(true); setLoadingStartedAt(Date.now()); setError('') if (!paging) { setBuckets([]); setHistogramLoading(false); setHistogramError('') } const data: LogQuery = { sourceId, query: searchQuery, ...r, limit, direction: 'backward', cursor: nextCursor } // 关键:发起新查询前,先断掉上一次仍在跑的查询/统计。 // 否则用户连续改条件会产生多条并发重查询,把单实例 Loki 打满 // (历史事故:27 天统计 → OOMKilled → Pod 不 Ready → 全站 502)。 histAbortRef.current?.abort() const histController = new AbortController() histAbortRef.current = histController try { const result = await logsApi.query(data, { signal: histController.signal }) if (version !== requestVersion.current) return if (paging && result.entries.length === 0) { setCursor(''); return } if (!paging) setQueryHistory(rememberQuery(data.query)) setExecutedQuery(data.query) if (!paging) setExecutedRange(r) setSearched(true) setEntries(result.entries) setCursor(result.hasMore ? result.nextCursor : '') setPageIndex(targetPage ?? 0) setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : ['']) if (!paging) { // 总条数:走 index/stats(只读索引,24h 约 1.3s),失败静默不影响日志展示 setTotalEntries(0) void logsApi.stats(data, { signal: histController.signal }) .then(st => { if (version === requestVersion.current) setTotalEntries(Number(st?.entries) || 0) }) .catch(() => undefined) setHistogramLoading(true) setHistogramError('') void logsApi.histogram(data, { signal: histController.signal }).then(histogram => { if (version === requestVersion.current) { setBuckets(histogram.map(item => ({ timestamp: String(item.timestamp), count: Number(item.count) || 0, unit: typeof item.unit === 'string' ? item.unit : undefined, }))) } }).catch((e: unknown) => { // 主动取消不算错误,直接静默 if (isAbortError(e) || histController.signal.aborted) return if (version === requestVersion.current) setHistogramError(describeQueryError(e, t('volume.statsError'))) }).finally(() => { // 无论成功/失败/取消都必须清掉 loading,否则会一直转圈 if (!histController.signal.aborted || version === requestVersion.current) setHistogramLoading(false) }) } } catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : t('toolbar.queryFailed')) } finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } } } useEffect(() => { if (!tail || !sourceId || !query.trim()) return const poll = async () => { const now = Date.now() setEnd(toLocal(new Date(now))) setStart(toLocal(new Date(now - 300000))) try { const result = await logsApi.query({ sourceId, query, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' }) setExecutedQuery(query) setSearched(true) setEntries(result.entries) setCursor(result.hasMore ? result.nextCursor : '') setPageIndex(0) setPageCursors(['']) } catch { /* 静默轮询,手动查询仍会显示错误 */ } } void poll() const timer = window.setInterval(() => void poll(), 3000) return () => window.clearInterval(timer) }, [tail, sourceId, query, limit]) /** 上下文前后条数:默认 5 条(Java 一个异常常拆成 4 条日志,2 条拼不完整),选择后记住 */ const [contextLines, setContextLines] = useState(() => { const saved = Number(localStorage.getItem('logContextLines')) return saved > 0 && saved <= 50 ? saved : 5 }) /** 记住当前查看上下文的那一行,切换条数时原地重新拉取 */ const [contextRow, setContextRow] = useState(null) async function showContext(row: LogEntry, lines = contextLines) { setContextRow(row) try { const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels, before: lines, after: lines, }) setContext({ row, before: result.before.slice().reverse(), after: result.after }) } catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) } } /** 改前后条数:持久化 + 若弹窗开着就原地刷新 */ const changeContextLines = (lines: number) => { setContextLines(lines) try { localStorage.setItem('logContextLines', String(lines)) } catch { /* 隐私模式忽略 */ } if (contextRow) void showContext(contextRow, lines) } const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery]) const applyDomainFilter = (domain: string) => { const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}` setQuery(nextQuery) void search('', false, undefined, nextQuery) } const selectTimeRange = (from: number, to: number) => { setStart(toLocal(new Date(from))); setEnd(toLocal(new Date(to))); setTail(false) void search('', false, { start: Number(ns(from)), end: Number(ns(to)) }) } const pageTitle = tab === 'query' ? t('tabs.query') : tab === 'access' ? t('tabs.access') : t('tabs.sources') const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources') return (
{error && void reloadSources()} />} {tab === 'query' &&
{sources.length === 0 && (

{t('source.missingTitle')}

{t('source.missingBody')}

{admin && ( )}
)}
{ setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
LogQL
void search('', false, undefined, runQuery)} sourceId={sourceId} history={queryHistory} />

{t('toolbar.limitHint')}

} {admin && tab === 'access' && } {admin && tab === 'sources' && void reloadSources()} />} {tab === 'query' && histogramLoading &&
{t('volume.statsLoading')}
} {tab === 'query' && histogramError &&

{histogramError}

} {tab === 'query' && buckets.length > 0 && } {tab === 'query' && 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} totalPages={totalEntries > 0 ? Math.max(1, Math.ceil(totalEntries / Math.max(1, limit))) : undefined} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />} { setContext(null); setContextRow(null) }} />
) }