feat(logs): 结果表虚拟滚动(@tanstack/react-virtual),>300 条时只渲染可视区
=== 一、为什么 ===
结果表原来是 entries.map 全量渲染真实 DOM。实测每条日志约 671B:
1000 条页面 → 1000 个 DOM 节点,且大量消息带 whitespace-pre-wrap
自动换行(Java 堆栈尤其长),浏览器需要排版全部行 → 滚动卡顿。
这是当前"每页条数"的真正瓶颈,比传 655KB 响应更影响体验。
=== 二、实现要点 ===
1. 新增 components/VirtualLogList.tsx
- useVirtualizer(**独立滚动容器**,非 window 滚动)
原因:window 虚拟滚动要把列表相对文档顶部偏移 scrollMargin 算准,
上方工具条高度一变(快捷模板/域过滤条出现隐藏)就错位;
独立容器内虚拟项位置只跟容器滚动量有关,不依赖页面布局。
- 行高不一(消息会换行)→ 用 measureElement 实测每行高度
- 容器 max-h-70vh,overscan 10
2. 抽公共行组件 LogRow,普通渲染与虚拟滚动**共用同一份 DOM 结构**,
levelBadgeClass 逐字沿用原实现(含 info/warn/debug 配色),保证外观不变
3. 安全阀:VIRTUAL_THRESHOLD = 300
- 默认每页 200 条仍走原来的普通渲染(行为已验证,零风险)
- 用户主动把每页调到 500/1000 才启用虚拟滚动
4. 依赖:@tanstack/react-virtual ^3.14.23(package.json + lock 已更新,
Docker 内 npm ci 可复现)
=== 三、验证 ===
- 本地 npx tsc -b --force 全量类型检查通过(rc=0)
- 产物 bundle 含 getVirtualItems / measureElement / getTotalSize
- 页面与主 JS 资源均 HTTP 200
- 平台健康 ok;hub latest 与 Pod digest 一致
镜像:lghost/lokistack:latest + v0.1 = sha256:25002cebc77c
This commit is contained in:
1 parent
6364a70215
commit
53c7d64b35
4 files changed
+164
-20
No files matched your search
@@ -5,20 +5,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { SearchableValueSelect } from '@/components/SearchableValueSelect'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { parseIngressAccessLog } from '@/lib/ingress-access-log'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
import { QueryEmptyState } from './QueryEmptyState'
|
||||
import { LogRow, VirtualLogList } from './VirtualLogList'
|
||||
|
||||
function levelBadgeClass(level: string) {
|
||||
const value = (level || '').toLowerCase()
|
||||
if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') {
|
||||
return 'rounded bg-destructive/15 px-1 text-destructive'
|
||||
}
|
||||
if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning'
|
||||
if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info'
|
||||
if (value.includes('debug') || value.includes('trace')) return 'rounded bg-muted px-1 text-muted-foreground'
|
||||
return 'rounded bg-muted px-1 text-muted-foreground'
|
||||
}
|
||||
/**
|
||||
* 超过这个条数才启用虚拟滚动。
|
||||
* 默认每页 200 条走普通渲染(已验证的外观/行为不变),
|
||||
* 只有用户主动把每页调到 500/1000 时才走虚拟滚动 —— 降低改动风险。
|
||||
*/
|
||||
const VIRTUAL_THRESHOLD = 300
|
||||
|
||||
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
|
||||
|
||||
@@ -67,15 +62,25 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
||||
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
|
||||
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
|
||||
</div>}
|
||||
{entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : <div className="divide-y">
|
||||
{entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : visibleEntries.length > VIRTUAL_THRESHOLD ? (
|
||||
<VirtualLogList
|
||||
entries={visibleEntries}
|
||||
query={query}
|
||||
terms={terms}
|
||||
onQueryChange={onQueryChange}
|
||||
onShowContext={onShowContext}
|
||||
/>
|
||||
) : <div className="divide-y">
|
||||
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
<span className={levelBadgeClass(row.level)}>{row.level}</span>
|
||||
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
|
||||
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button>
|
||||
</div>
|
||||
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
|
||||
<LogRow
|
||||
row={row}
|
||||
query={query}
|
||||
terms={terms}
|
||||
onQueryChange={onQueryChange}
|
||||
onShowContext={onShowContext}
|
||||
filterLabel={t('results.filterLabel')}
|
||||
showContext={t('results.showContext')}
|
||||
/>
|
||||
</div>)}
|
||||
</div>}
|
||||
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useRef } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
|
||||
/** 单行日志(普通渲染与虚拟滚动共用,保证两种模式外观完全一致) */
|
||||
function LogRow({ row, query, terms, onQueryChange, onShowContext, filterLabel, showContext }: {
|
||||
row: LogEntry
|
||||
query: string
|
||||
terms: string[]
|
||||
onQueryChange: (query: string) => void
|
||||
onShowContext: (row: LogEntry) => void
|
||||
filterLabel: string
|
||||
showContext: string
|
||||
}) {
|
||||
return <>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
<span className={levelBadgeClass(row.level ?? '')}>{row.level}</span>
|
||||
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => (
|
||||
<button key={key} title={filterLabel} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>
|
||||
))}
|
||||
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{showContext}</button>
|
||||
</div>
|
||||
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
|
||||
</>
|
||||
}
|
||||
|
||||
/** 与 LogResultsPanel 原有实现逐字一致,避免虚拟/普通两种模式外观不同 */
|
||||
function levelBadgeClass(level: string) {
|
||||
const value = (level || '').toLowerCase()
|
||||
if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') {
|
||||
return 'rounded bg-destructive/15 px-1 text-destructive'
|
||||
}
|
||||
if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning'
|
||||
if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info'
|
||||
if (value.includes('debug') || value.includes('trace')) return 'rounded bg-muted px-1 text-muted-foreground'
|
||||
return 'rounded bg-muted px-1 text-muted-foreground'
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟滚动结果列表。
|
||||
*
|
||||
* 为什么需要:结果表原来把每一行都渲染成真实 DOM。实测每条日志约 671B,
|
||||
* 1000 条页面就有 1000 个节点,其中大量是带自动换行的长消息(Java 堆栈),
|
||||
* 浏览器要排版全部行,滚动明显卡顿。
|
||||
*
|
||||
* 为什么用「独立滚动容器」而不是 window 滚动:
|
||||
* window 虚拟滚动需要把列表相对文档顶部的偏移量(scrollMargin)算准,
|
||||
* 一旦上方工具条高度变化(快捷模板、域过滤条出现/隐藏)就会错位。
|
||||
* 这里让列表自己滚动,虚拟项位置只与容器滚动量有关,不依赖页面布局。
|
||||
*
|
||||
* 变高行处理:日志消息会换行,行高不等,所以用 measureElement 实测每行高度。
|
||||
*/
|
||||
export function VirtualLogList({ entries, query, terms, onQueryChange, onShowContext }: {
|
||||
entries: LogEntry[]
|
||||
query: string
|
||||
terms: string[]
|
||||
onQueryChange: (query: string) => void
|
||||
onShowContext: (row: LogEntry) => void
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: entries.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
overscan: 10,
|
||||
getItemKey: index => `${entries[index]?.timestampNs ?? index}-${index}`,
|
||||
})
|
||||
|
||||
const items = virtualizer.getVirtualItems()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="max-h-[70vh] overflow-auto rounded-lg border"
|
||||
role="list"
|
||||
aria-label={t('results.aria')}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
role="listitem"
|
||||
className="absolute left-0 top-0 w-full border-b px-3 py-2 text-sm"
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<LogRow
|
||||
row={entries[item.index]}
|
||||
query={query}
|
||||
terms={terms}
|
||||
onQueryChange={onQueryChange}
|
||||
onShowContext={onShowContext}
|
||||
filterLabel={t('results.filterLabel')}
|
||||
showContext={t('results.showContext')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { LogRow, levelBadgeClass }
|
||||
Reference in new issue
Block a user