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

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