diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3516175..3b1a2e1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,6 +35,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.14.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -2916,6 +2917,33 @@ "version": "0.3.0", "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.23", + "resolved": "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.14.23.tgz", + "integrity": "sha512-Oyy58qQwdaQieA+A4WUDtnh9D1rv5FZvGdw8og9Xeyh88V6HH94vMSyV5i9ATIbYNe9t5Ix0WXRo9NOTpHDkYw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.19.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.19.8", + "resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.19.8.tgz", + "integrity": "sha512-Oz/SYBo/aVPyQkwtOw7Q23myITUgFIof2glQqydYJAPL6mH2SkJDkvDKZTklB+7gv5jGLZQhyTcd7SO1A1EODQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index fef75ad..8f9a30e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.14.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/frontend/src/pages/logs/components/LogResultsPanel.tsx b/frontend/src/pages/logs/components/LogResultsPanel.tsx index 98ab653..e0acecd 100644 --- a/frontend/src/pages/logs/components/LogResultsPanel.tsx +++ b/frontend/src/pages/logs/components/LogResultsPanel.tsx @@ -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 {t('results.domainHint', { shown: visibleEntries.length, total: entries.length })} } - {entries.length === 0 ? : visibleEntries.length === 0 ?

{t('results.domainNoMatch')}

:
+ {entries.length === 0 ? : visibleEntries.length === 0 ?

{t('results.domainNoMatch')}

: visibleEntries.length > VIRTUAL_THRESHOLD ? ( + + ) :
{visibleEntries.map((row, index) =>
-
- - {row.level} - {Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => )} - -
- +
)}
} {searched && entries.length > 0 &&
diff --git a/frontend/src/pages/logs/components/VirtualLogList.tsx b/frontend/src/pages/logs/components/VirtualLogList.tsx new file mode 100644 index 0000000..3e2025c --- /dev/null +++ b/frontend/src/pages/logs/components/VirtualLogList.tsx @@ -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 <> +
+ + {row.level} + {Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => ( + + ))} + +
+ + +} + +/** 与 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(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 ( +
+
+ {items.map(item => ( +
+ +
+ ))} +
+
+ ) +} + +export { LogRow, levelBadgeClass }