Files
LokiStack/frontend/src/pages/logs/index.tsx
T
opsx 6364a70215 tune(logs): 每页条数默认 1000→200,上下文默认 5 条(依据实测数据)
=== 一、每页条数: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
2026-10-28 16:18:48 +00:00

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>
)
}