=== 一、每页条数:1000 太多,实测依据 ===
1. 每次查询响应体 671 B/条 → 1000 条 = **655 KB**
2. 前端结果表**没有虚拟滚动**(无 react-window / tanstack virtual),
1000 行会全部渲染成 DOM 节点 → 滚动卡顿
3. 后端 maxLogQueryLimit 本来就是 1000,我上一版 UI 给了 2000/5000 选项
是"假选项"(会被静默收敛成 1000),本次去掉
4. 密度参考:cex-ipublic-gateway 每天 3.28 亿条 ≈ **3800 条/秒**,
1000 条只覆盖 0.26 秒 —— 靠翻页浏览无意义,应靠时间范围/LogQL 收窄
调整:默认 1000 → **200**(后端自身默认值,约 131KB);选项 [100/200/500/1000]
提示文案改为"日志量大时先收窄时间范围或加过滤,比调大每页条数更有效"
=== 二、上下文档数:1 条不够,默认用 5 ===
实测一个 SpringBoot 异常被拆成 **4 条独立日志**:
16:15:35.259 [reactor-http-epoll-3] ERROR ...AbstractErrorWebExceptionHandler
org.springframework.web.reactive.resource.NoResourceFoundException: 404 NOT_FOUND
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
*__checkpoint ⇢ HTTP HEAD "/" [ExceptionHandlingWebHandler]
→ 前后各 1 条(共 2 条)连一个完整异常都拼不出来
→ 前后各 5 条(共 10 条)能看到"异常 + 前后业务日志",是排障的最小可用集
→ 完整 Java 堆栈(20+ 帧)需要 20-50 条
调整:默认 2 → **5**;选项增加 1 → [1/2/5/10/20/50]
镜像:lghost/lokistack:latest + v0.1 = sha256:2bcc86b8e30d
304 lines
18 KiB
TypeScript
304 lines
18 KiB
TypeScript
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<LogSource[]>([])
|
|
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<LogEntry[]>([])
|
|
const [searched, setSearched] = useState(false)
|
|
const [buckets, setBuckets] = useState<Bucket[]>([])
|
|
const [histogramLoading, setHistogramLoading] = useState(false)
|
|
// 从 boolean 改为字符串:原来只显示"日志量统计失败",
|
|
// 真实原因(429 并发超限 / 400 范围过大 / Loki 不可用)全被吞掉。
|
|
const [histogramError, setHistogramError] = useState<string>('')
|
|
/** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */
|
|
const [totalEntries, setTotalEntries] = useState<number>(0)
|
|
/** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */
|
|
const histAbortRef = useRef<AbortController | null>(null)
|
|
const [cursor, setCursor] = useState('')
|
|
const [pageIndex, setPageIndex] = useState(0)
|
|
const [pageCursors, setPageCursors] = useState<string[]>([''])
|
|
const [loading, setLoading] = useState(false)
|
|
const [loadingStartedAt, setLoadingStartedAt] = useState<number | null>(null)
|
|
const [error, setError] = useState('')
|
|
const [context, setContext] = useState<LogContext | null>(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<number>(() => {
|
|
const saved = Number(localStorage.getItem('logContextLines'))
|
|
return saved > 0 && saved <= 50 ? saved : 5
|
|
})
|
|
/** 记住当前查看上下文的那一行,切换条数时原地重新拉取 */
|
|
const [contextRow, setContextRow] = useState<LogEntry | null>(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 (
|
|
<div>
|
|
<nav aria-label={t('nav.aria')} className="mb-4 flex flex-wrap gap-1 border-b pb-3">
|
|
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button>
|
|
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>}
|
|
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>}
|
|
</nav>
|
|
<PageContainer title={pageTitle} description={description}>
|
|
<div className="space-y-6">
|
|
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
|
|
{tab === 'query' && <section className="space-y-4">
|
|
{sources.length === 0 && (
|
|
<div className="rounded-xl border border-warning/40 bg-warning/10 p-4 text-sm">
|
|
<p className="font-medium text-warning">{t('source.missingTitle')}</p>
|
|
<p className="mt-1 text-muted-foreground">{t('source.missingBody')}</p>
|
|
{admin && (
|
|
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
|
|
{t('source.goConfigure')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Select value={sourceId ? String(sourceId) : undefined} onValueChange={value => setSourceId(Number(value))} disabled={sources.length === 0}>
|
|
<SelectTrigger className="h-9 min-w-40" aria-label={t('toolbar.sourceAria')}>
|
|
<SelectValue placeholder={t('toolbar.sourceEmptyOption')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sources.map(source => <SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
|
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}
|
|
<Select value={String(limit)} onValueChange={value => setLimit(Number(value))}>
|
|
<SelectTrigger className="h-9 w-24" aria-label={t('toolbar.limitLabel')}><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{[100, 200, 500, 1000].map(value => <SelectItem key={value} value={String(value)}>{value}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
|
|
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={(runQuery) => void search('', false, undefined, runQuery)} sourceId={sourceId} history={queryHistory} /></div>
|
|
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? t('toolbar.running') : t('toolbar.run')}</Button>
|
|
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t('toolbar.limitHint')}</p>
|
|
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
|
|
</section>}
|
|
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
|
|
{admin && tab === 'sources' && <SourcesManager sources={sources} onSaved={() => void reloadSources()} />}
|
|
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
|
|
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{histogramError}</p>}
|
|
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
|
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} searched={searched} hasSource={sources.length > 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)} />}
|
|
<LogContextDialog context={context} terms={highlightTerms} lines={contextLines} onLinesChange={changeContextLines} onClose={() => { setContext(null); setContextRow(null) }} />
|
|
</div>
|
|
</PageContainer>
|
|
</div>
|
|
)
|
|
}
|