feat(logs,analysis): 分页改"加载更多"+触底自动加载;修懒加载不触发;指标短缓存

=== 1. 日志列表:去掉页码分页,改「加载更多」+ 触底自动加载(节流)===
为什么不用"上一页/下一页/共 N 页":
  - Loki 是**游标分页**,不支持 offset,页码天生跳不了页
  - 规模上没意义:15 分钟 4000 万条 → 按 200/页算 40 万页
  - 带 |= / |~ 行过滤时总数根本算不出来(index/stats 只接受 label matcher)
日志排查的真实交互是"继续往下看",所以改为:
  - 列表底部:「已加载 N 条 · 约 M 条」+「加载更多」按钮
  - 滚动到底**自动加载**,但 2 秒节流 —— 每批都要后端扫 Loki,
    快速滚动连续触发会撞并发护栏(429),反而比手动点更卡
  - 到 5000 条停止并提示"缩小时间范围或加过滤条件"(虚拟滚动只省 DOM,
    不省数组内存,无上限会一直涨)
  - 修正语义 bug:分页原来是 setEntries(result.entries) **整页替换**,
    改成追加(否则"加载更多"等于翻页)
  - 删除 pageCursors 翻页栈与 prevPage/nextPage/pageOf/pageOfTotal 文案

=== 2. /logs/stats 错误映射:Loki 400 → 我们的 400 + 人话 ===
之前带行过滤时 Loki 回 400 "only label matchers are supported",
被统一包装成 502「Kubernetes API 代理返回 HTTP 400…」,
前端只能当失败静默 → 用户看到"总页数没了"却不知道原因。
现在返回 400 +「含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计」,
前端据此在条数旁给出提示。

=== 3. 分析页:修"延迟加载永远不触发"(数据一直不出来)===
现象:应用排行/流图/饼图/活跃应用全是"没有应用维度的数据",每日量"已有 0 天",
但接口实测正常(by-app 返回 75 条序列、daily-volume 返回 2 天)。
根因:这三块用 useInView 门控,而 ref 挂在 <Card> 上,观察器不触发 → 永远 false
→ hook 直接 return → 页面永远空。
修法:
  - 改用普通 <div ref> 包裹,不依赖组件是否转发 ref
  - useInView 加**几何兜底**:1 秒后若元素已在视口内就直接置 true
    (有些容器里 IntersectionObserver 的 root 判定会和预期不一致)

=== 4. 分析页指标短缓存(26s → 11ms)===
`sum by (app)(count_over_time(...))` 扫 15 分钟 astralx(约 6.6GB)实测 16~28 秒,
Loki 自带结果缓存不命中(时间窗每次都在变)。点刷新/切页都要再等半分钟。
现在按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 2 分钟取整当 key:
  实测第 1 次 23514ms → 第 2 次 **11ms**
代价:命中缓存时数据最多旧 ~2 分钟,对聚合视图可接受。

=== 5. 仪表盘 ===
去掉「平台自检」「N 项需关注」两段文字:卡片本身就是自检结果,
标题重复一遍没信息量;状态改由图标+边框颜色表达。

=== 6. 其它文案修正 ===
饼图空态从「没有命名空间维度的数据」改为「没有应用维度的数据」
(饼图上一版已改成应用维度,文案没跟上)。

=== 实测(1da8a148d756)===
  Pod = Hub latest = v0.1 = 1da8a148d756
  stats:纯 selector → 200(7005 万条);带 |= error → 400 + 人话
  分析指标缓存:23514ms → 11ms
  每日量:2 天(10-27 32.5GB / 10-28 356.5GB)
  文案:加载更多 / 已到末尾 / 达到上限 均进包;代码中已无 prevPage/nextPage 引用
This commit is contained in:
opsx committed 2026-10-28 23:36:32 +00:00
1 parent bad61e5027
commit fbefcc86df
10 files changed
+231 -69

No files matched your search

+9
View File
@@ -363,6 +363,15 @@ func (ctrl *LogsController) Stats(c *gin.Context) {
}
st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End)
if err != nil {
// Loki 的 index/stats 只支持标签选择器。查询里带 `|= "error"` 这类行过滤时
// Loki 回 400 "only label matchers are supported",原来被包装成 502
// ("Kubernetes API 代理返回 HTTP 400…"),前端只能当失败静默处理,
// 用户看到的就是"没有总页数"。这里改成 400 + 人话,前端据此显示
// "含过滤条件时无法统计总页数",而不是假装统计不出来。
if strings.Contains(err.Error(), "only label matchers") {
utils.Error(c, 400, "含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计")
return
}
utils.Error(c, 502, err.Error())
return
}
+65
View File
@@ -16,6 +16,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"k8s.io/client-go/rest"
@@ -624,7 +625,71 @@ type MetricSeries struct {
// LokiMetrics 通用 LogQL 指标查询(日志分析页用):query 为完整指标表达式,
// 其中所有 [$step] 占位符替换为按时间跨度自适应的步长后走 query_range,
// 返回矩阵多序列(直方图只取单序列,这里按 by (...) 分组整表返回)
// ── 分析页指标查询的短缓存 ────────────────────────────────────────────
//
// 为什么需要:`sum by (app)(count_over_time(...))` 在这台集群上扫 15 分钟
// astralx(约 6.6GB)实测 16~28 秒,Loki 的结果缓存也不命中(请求的时间窗
// 每次都在变)。用户点刷新、切页、换 tab 都要再等半分钟。
//
// 做法:按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 **2 分钟取整**再当 key。
// 代价是命中缓存时数据最多旧 2 分钟 × 90 秒 TTL —— 对"日志量趋势/排行"这种
// 聚合视图完全可接受,换来的是重复访问从 ~26s 降到毫秒级。
const (
metricCacheTTL = 90 * time.Second
metricCacheMax = 200
// 时间窗取整粒度:key 用取整后的值,避免"每次点刷新差几秒就穿透"
metricCacheBucket = 2 * time.Minute
)
type metricCacheEntry struct {
series []MetricSeries
expiresAt time.Time
}
var (
metricCacheMu sync.Mutex
metricCache = map[string]metricCacheEntry{}
)
// LokiMetrics 带短缓存的分析指标查询(语义见 metricCache 注释)。
func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
key := ""
if src != nil {
bucket := int64(metricCacheBucket)
key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket)
metricCacheMu.Lock()
if entry, ok := metricCache[key]; ok && time.Now().Before(entry.expiresAt) {
metricCacheMu.Unlock()
return entry.series, nil
}
metricCacheMu.Unlock()
}
series, err := lokiMetricsUncached(ctx, src, query, start, end)
if err != nil || key == "" {
return series, err
}
metricCacheMu.Lock()
if len(metricCache) >= metricCacheMax {
now := time.Now()
for k, v := range metricCache {
if now.After(v.expiresAt) {
delete(metricCache, k)
}
}
// 清完还满:整体重置,避免无界增长把内存吃掉
if len(metricCache) >= metricCacheMax {
metricCache = map[string]metricCacheEntry{}
}
}
metricCache[key] = metricCacheEntry{series: series, expiresAt: time.Now().Add(metricCacheTTL)}
metricCacheMu.Unlock()
return series, nil
}
// lokiMetricsUncached 真正打 Loki 的实现(不带缓存)。
func lokiMetricsUncached(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
if end <= 0 {
end = time.Now().UnixNano()
}
+41 -16
View File
@@ -7,7 +7,14 @@ import { useEffect, useRef, useState } from 'react'
* 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。
* 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。
*
* 只触发一次(进入视口后断开监听):日志分析是只读快照,不需要来回重算。
* 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"):
* 1) IntersectionObserver 正常路径,进入视口即触发
* 2) 1 秒后兜底量一次 getBoundingClientRect —— 有些容器(transform /
* overflow / 自定义滚动区)里 IO 的 root 判定会和预期不一致,实测出现过
* "卡片明明在屏幕上、观察器却一直不回调",于是数据永远不出来
* 3) 组件卸载时清理定时器与观察器
*
* 只触发一次(日志分析是只读快照,不需要来回重算)。
*/
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
const ref = useRef<T | null>(null)
@@ -17,22 +24,40 @@ export function useInView<T extends HTMLElement>(rootMargin = '200px') {
if (inView) return
const el = ref.current
if (!el) return
// 老浏览器/SSR 环境没有 IntersectionObserver:直接当作可见,别把功能卡死
if (typeof IntersectionObserver === 'undefined') {
setInView(true)
return
// 兜底判定:元素顶边在视口内(含 rootMargin 余量)就算可见
const margin = Number.parseInt(rootMargin, 10) || 200
const isVisiblyInViewport = () => {
const rect = el.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
return rect.top < viewportHeight + margin && rect.bottom > -margin
}
// 路径 1:IntersectionObserver
let observer: IntersectionObserver | undefined
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
setInView(true)
observer?.disconnect()
}
},
{ rootMargin },
)
observer.observe(el)
}
// 路径 2:几何兜底(覆盖 IO 不回调的情况)
const fallback = window.setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (isVisiblyInViewport()) setInView(true)
}, 1000)
return () => {
observer?.disconnect()
window.clearTimeout(fallback)
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
setInView(true)
observer.disconnect()
}
},
{ rootMargin },
)
observer.observe(el)
return () => observer.disconnect()
}, [inView, rootMargin])
return { ref, inView }
+9 -4
View File
@@ -139,6 +139,14 @@ export default {
summary: 'Page {{page}} · {{count}} entries',
summary_one: 'Page {{page}} · {{count}} entry',
summaryMore: 'Page {{page}} · {{count}} entries · more available',
summaryLoaded: '{{count}} loaded',
summaryMoreSuffix: ' · more available',
loadedCount: '{{count}} loaded',
totalCount: '≈ {{count}} total',
loadMore: 'Load more',
loadingMore: 'Loading…',
noMore: 'End of results',
loadMoreReached: '{{count}} loaded — limit reached. Narrow the time range or add filters.',
summaryMore_one: 'Page {{page}} · {{count}} entry · more available',
loadingPage: 'Loading log page',
loadingQuery: 'Querying Loki',
@@ -152,10 +160,7 @@ export default {
domainNoMatch: 'No logs match the current domain filter',
filterLabel: 'Filter by this label',
showContext: 'Show 5 lines before and after',
prevPage: 'Previous',
nextPage: 'Next',
pageOf: 'Page {{page}}',
pageOfTotal: 'Page {{page}} of {{total}}',
statsUnsupported: '(total pages unavailable with |= / |~ filters — Loki only counts by labels)',
noSourceTitle: 'No queryable data source',
noSourceHint: 'Ask an administrator to add or authorize a data source',
noMatchTitle: 'No matching log entries',
+1 -1
View File
@@ -46,7 +46,7 @@ export default {
pie: {
title: '应用日志量分布',
subtitle: '按应用看谁在刷日志(前 8 名,其余归入"其他")',
empty: '没有命名空间维度的数据',
empty: '没有应用维度的数据',
other: '其他',
},
daily: {
+9 -4
View File
@@ -136,6 +136,14 @@ export default {
title: '日志结果',
summary: '第 {{page}} 页 · 本页 {{count}} 条',
summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多',
summaryLoaded: '已加载 {{count}} 条',
summaryMoreSuffix: ' · 还有更多',
loadedCount: '已加载 {{count}} 条',
totalCount: '约 {{count}} 条',
loadMore: '加载更多',
loadingMore: '加载中…',
noMore: '已到末尾',
loadMoreReached: '已加载 {{count}} 条,达到上限。请缩小时间范围或加过滤条件',
loadingPage: '正在加载日志页',
loadingQuery: '正在查询 Loki',
loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。',
@@ -148,10 +156,7 @@ export default {
domainNoMatch: '当前域名筛选没有匹配的日志',
filterLabel: '筛选此标签',
showContext: '查看前后各 5 条',
prevPage: '上一页',
nextPage: '下一页',
pageOf: '第 {{page}} 页',
pageOfTotal: '第 {{page}} / {{total}} 页',
statsUnsupported: '(含 |= / |~ 过滤条件时无法统计总页数,Loki 只支持按标签统计)',
noSourceTitle: '暂无可查询的数据源',
noSourceHint: '请联系管理员添加或授权数据源',
noMatchTitle: '未找到匹配的日志记录',
+6 -2
View File
@@ -253,7 +253,8 @@ const AnalysisPage = memo(function AnalysisPage() {
<CardContent><AppStreamChart apps={data.apps} appTimes={data.appTimes} /></CardContent>
</Card>
<Card ref={dailyView.ref as React.Ref<HTMLDivElement>}>
<div ref={dailyView.ref}>
<Card>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
@@ -275,8 +276,10 @@ const AnalysisPage = memo(function AnalysisPage() {
)}
</CardContent>
</Card>
</div>
<Card ref={rankingView.ref as React.Ref<HTMLDivElement>}>
<div ref={rankingView.ref}>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('table.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
@@ -302,6 +305,7 @@ const AnalysisPage = memo(function AnalysisPage() {
)}
</CardContent>
</Card>
</div>
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
<div ref={ingressView.ref as React.Ref<HTMLDivElement>}>
@@ -56,14 +56,6 @@ export const SelfCheckCard = memo(function SelfCheckCard() {
}
if (failed || !data) return null
const problems = data.items.filter(i => i.status !== 'ok')
const overallText =
data.overall === 'ok'
? t('selfCheck.allGood')
: data.overall === 'warn'
? t('selfCheck.needsAttention', { count: problems.length })
: t('selfCheck.hasError', { count: problems.length })
const borderClass =
data.overall === 'ok'
? 'border-success/30'
@@ -74,20 +66,17 @@ export const SelfCheckCard = memo(function SelfCheckCard() {
return (
<Card className={borderClass}>
<CardHeader className="pb-2">
{/* 按使用反馈去掉「平台自检 / N 项需关注」这两段文字:
卡片本身就是自检结果,标题重复一遍没有信息量。
只保留状态图标着色(边框颜色已经能表达 overall)+ 时间 + 刷新。 */}
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="h-4 w-4" />
{t('selfCheck.title')}
<span
className={`ml-1 text-xs font-normal ${
data.overall === 'ok'
? 'text-success'
: data.overall === 'warn'
? 'text-warning'
: 'text-destructive'
}`}
>
{overallText}
</span>
<Activity className={`h-4 w-4 ${
data.overall === 'ok'
? 'text-success'
: data.overall === 'warn'
? 'text-warning'
: 'text-destructive'
}`} />
<span className="ml-auto text-xs font-normal text-muted-foreground">
{new Date(data.checkedAt).toLocaleTimeString()}
</span>
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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 { compactNumber } from '@/pages/analysis/components/chart-shared'
import { QueryEmptyState } from './QueryEmptyState'
import { LogRow, VirtualLogList } from './VirtualLogList'
@@ -17,12 +18,17 @@ const VIRTUAL_THRESHOLD = 100
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, totalPages, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, totalEntries, statsNote, loadMoreReached, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage }: {
entries: LogEntry[]; searched: boolean; hasSource: boolean
pageIndex: number; hasNextPage: boolean; totalPages?: number; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
pageIndex: number; hasNextPage: boolean; totalEntries?: number; statsNote?: string; loadMoreReached?: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void
}) {
const { t } = useTranslation('logs')
// 触底自动加载:滚动到列表底部时自动取下一批,但做 2 秒节流。
// 日志查询每批次都要后端扫 Loki,用户快速滚动如果连续触发会撞并发护栏(429),
// 反而比手动点更卡。所以"能自动,但不会失控"。
const sentinelRef = useRef<HTMLDivElement | null>(null)
const lastAutoLoadRef = useRef(0)
const [domainFilter, setDomainFilter] = useState('')
const [clock, setClock] = useState(0)
useEffect(() => {
@@ -30,6 +36,21 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
const timer = window.setInterval(() => setClock(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [loading])
useEffect(() => {
const el = sentinelRef.current
if (!el || !hasNextPage || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(records => {
if (!records.some(record => record.isIntersecting)) return
if (loading) return
const now = Date.now()
if (now - lastAutoLoadRef.current < 2000) return
lastAutoLoadRef.current = now
onNextPage()
}, { rootMargin: '120px' })
observer.observe(el)
return () => observer.disconnect()
}, [hasNextPage, loading, onNextPage])
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
const visibleEntries = useMemo(() => {
const needle = domainFilter.trim().toLowerCase()
@@ -37,7 +58,7 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
}, [entries, domainFilter])
return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2>
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{t('results.summaryLoaded', { count: entries.length })}{hasNextPage ? t('results.summaryMoreSuffix') : ''}</span></h2>
</div>
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
@@ -83,14 +104,31 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
/>
</div>)}
</div>}
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
<span className="text-xs tabular-nums text-muted-foreground">
{totalPages && totalPages > 1
? t('results.pageOfTotal', { page: pageIndex + 1, total: totalPages })
: t('results.pageOf', { page: pageIndex + 1 })}
</span>
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>{t('results.nextPage')}</Button>
</div>}
{/* 分页改成"加载更多":
Loki 是游标分页、不支持 offset,"第 N 页 / 共 M 页"既跳不了也看不懂
(15 分钟 4000 万条 = 40 万页),而且带 |= / |~ 过滤时总数根本算不出来。
日志排查的真实交互是"继续往下看",所以用 已加载 N 条 + 加载更多。 */}
{searched && entries.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-3">
<span className="text-xs tabular-nums text-muted-foreground">
{t('results.loadedCount', { count: entries.length })}
{totalEntries && totalEntries > 0
? ` · ${t('results.totalCount', { count: compactNumber(totalEntries) })}`
: ''}
</span>
{loadMoreReached ? (
<span className="text-xs text-warning">{t('results.loadMoreReached', { count: entries.length })}</span>
) : hasNextPage ? (
<Button variant="outline" size="sm" disabled={loading} onClick={onNextPage}>
{loading ? t('results.loadingMore') : t('results.loadMore')}
</Button>
) : (
<span className="text-xs text-muted-foreground">{t('results.noMore')}</span>
)}
{statsNote && <span className="text-xs text-muted-foreground">{statsNote}</span>}
{/* 触底哨兵:滚到底部时自动加载(有节流,见下方 effect) */}
{hasNextPage && !loadMoreReached && <div ref={sentinelRef} className="h-1 w-full" />}
</div>
)}
</section>
}
+29 -7
View File
@@ -22,6 +22,8 @@ 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'
/** 无限滚动下最多保留多少条(虚拟滚动只省 DOM,不省数组内存) */
const MAX_LOADED_ENTRIES = 5000
type Bucket = { timestamp: string; count: number }
@@ -99,11 +101,14 @@ export default function LogsPage() {
const [histogramError, setHistogramError] = useState<string>('')
/** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */
const [totalEntries, setTotalEntries] = useState<number>(0)
/** 已加载条数达到上限(停止"加载更多",提示用户缩小范围) */
const [loadMoreReached, setLoadMoreReached] = useState(false)
/** 总页数统计不可用时的提示(如查询带 |= / |~ 行过滤,Loki 只支持按标签统计) */
const [statsNote, setStatsNote] = useState('')
/** 当前在跑的查询/统计控制器:发起新请求前 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('')
@@ -120,7 +125,7 @@ export default function LogsPage() {
} 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])
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(''); setTotalEntries(0); setCursor(''); setPageIndex(0); setSearched(false); setLoading(false) }, [sourceId])
// 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。
// 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断
@@ -141,6 +146,12 @@ export default function LogsPage() {
}
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
const paging = targetPage !== undefined
// 上限保护:无限滚动会把数据一直堆在内存里(虚拟滚动只省 DOM,不省数组内存)。
// 到 5000 条就停,提示用户缩小时间范围或加过滤条件 —— 老实说清楚比卡死好。
if (paging && entries.length >= MAX_LOADED_ENTRIES) {
setLoadMoreReached(true)
return
}
const r = paging ? executedRange : selectedRange ?? range()
const searchQuery = paging ? executedQuery : overrideQuery ?? query.trim()
if (!r || !sourceId || !searchQuery) return
@@ -164,16 +175,28 @@ export default function LogsPage() {
setExecutedQuery(data.query)
if (!paging) setExecutedRange(r)
setSearched(true)
setEntries(result.entries)
// 分页是"加载更多"语义:追加到已有列表,而不是替换。
// (原来这里直接 setEntries(result.entries),翻页会整页替换,看起来像跳页)
setEntries(previous => (paging ? [...previous, ...result.entries] : 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)
setStatsNote('')
setLoadMoreReached(false)
void logsApi.stats(data, { signal: histController.signal })
.then(st => { if (version === requestVersion.current) setTotalEntries(Number(st?.entries) || 0) })
.catch(() => undefined)
.catch((e: unknown) => {
if (version !== requestVersion.current) return
if (isAbortError(e) || histController.signal.aborted) return
// 只有"带行过滤无法统计"这一种情况需要提示用户;
// 其它失败仍静默(不能因为统计失败就干扰看日志)
const msg = e instanceof Error ? e.message : ''
if (msg.includes('无法统计总页数') || msg.includes('only label matchers')) {
setStatsNote(t('results.statsUnsupported'))
}
})
setHistogramLoading(true)
setHistogramError('')
@@ -210,7 +233,6 @@ export default function LogsPage() {
setEntries(result.entries)
setCursor(result.hasMore ? result.nextCursor : '')
setPageIndex(0)
setPageCursors([''])
} catch { /* 静默轮询,手动查询仍会显示错误 */ }
}
void poll()
@@ -312,7 +334,7 @@ export default function LogsPage() {
{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)} />}
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} totalEntries={totalEntries} statsNote={statsNote} loadMoreReached={loadMoreReached} 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)} />}
<LogContextDialog context={context} terms={highlightTerms} lines={contextLines} onLinesChange={changeContextLines} onClose={() => { setContext(null); setContextRow(null) }} />
</div>
</PageContainer>