feat(analysis): 长范围应用排行改走「小时级预聚合」,不再扫日志内容
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 问题(实测)===
按应用排行必须读日志内容(Loki 3.4 的 index/volume 不支持 app 维度聚合,
aggregateBy=app 无效),成本 ∝ 时间范围:
   5 分钟  ≈ 1.3GB  →  9.8s
  15 分钟  ≈ 3.9GB  → 19.5s
   1 小时  ≈  15GB  → 预计 60~90s
   6 小时  ≈  92GB  → 5~8 分钟
  24 小时  ≈ 370GB  → 不可用
用户在时间范围里选 6h/24h 时,页面必然卡死。

=== 方案 ===
新增 models.AppVolumeSnapshot + services/app_volume.go:
  - 后台任务每小时第 5 分钟抓「上一个完整小时」的 per-app 条数
  - 用**瞬时查询**(/loki/api/v1/query + [1h])而不是 query_range:
    单点评估、扫描量相同、省掉多步开销;并绕开 LokiMetrics 的 90s 上限
    与重型查询信号量(后台任务不该和前台抢令牌)
  - 每源每小时只留 top 50 应用,保留 90 天
  - 幂等:同 (hour, source, ns, app) 更新而非重复插入
  - 超出 Loki 26h 保留期的窗口跳过(抓也抓不到,白等几分钟)
新增 GET /api/v1/logs/app-ranking?sourceId&from&to&limit → 读库毫秒级

前端:analysis 判断范围 > 60 分钟就走预聚合接口,并停掉实时重查询
(rankingEnabled 置 false);表格副标题说明「读的是预聚合快照,已留存 N 小时,
不含错误数与趋势」。

=== 顺带修掉的根因 bug ===
useInView 原来用 useRef + useEffect([inView]):分析页首屏 data===null 时
内容块不渲染,ref.current 为 null,effect 直接 return;等数据到了 DOM 才挂上,
但依赖没变 → effect 不重跑 → IntersectionObserver 从未创建 → inView 永远 false
→ 重查询永不发出 → 页面一直显示"没有应用维度的数据"。
改成 callback ref(节点挂载即作为依赖),并加 scroll/resize 兜底 + 挂载瞬间量一次。
另外分析页加 8 秒无条件兜底:可见性判定再失灵,8 秒后也一定查一次。
默认时间范围 15 分钟 → 5 分钟(19.5s → 9.8s)。

=== 实测 ===
  Pod = Hub latest = 98de87e7f895
  5 分钟窗口 by-app:9.8s(75 序列);15 分钟:19.5s
This commit is contained in:
opsx committed 2026-10-30 16:35:35 +00:00
1 parent 7f6fb364de
commit d0adc095dd
11 files changed
+467 -40

No files matched your search

+7
View File
@@ -45,6 +45,13 @@ export const logsApi = {
http.post<{ streams: number; chunks: number; bytes: number; entries: number }>(
'/logs/stats', data, { timeout: 25000, ...opts }),
/** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */
/** 应用排行(长范围读平台预聚合快照,毫秒级,不扫日志内容) */
appRanking: (sourceId: number, from: number, to: number, limit = 10, opts?: { signal?: AbortSignal }) =>
http.get<{ list: Array<{ app: string; namespace: string; entries: number; fromHistory: boolean }>; hours: number }>(
`/logs/app-ranking?sourceId=${sourceId}&from=${from}&to=${to}&limit=${limit}`,
opts,
),
/** 每日日志量历史(读平台库,毫秒级) */
dailyVolume: (sourceId: number, days: number, opts?: { signal?: AbortSignal }) =>
http.get<DailyVolumePoint[]>(`/logs/daily-volume?sourceId=${sourceId}&days=${days}`, opts),
+47 -31
View File
@@ -1,64 +1,80 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
/**
* useInView 判断元素是否进入视口(默认提前 200px 触发)。
*
* 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测大集群
* 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。
* 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。
* 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测 15 分钟
* 十几秒)。改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,慢数据后补。
*
* ⚠️ 历史 bug(务必保留这个写法):
* 最初用 `useRef` + `useEffect([inView])`。分析页首屏 `data === null` 时
* 整个内容块不渲染,`ref.current` 是 null,effect 直接 return;
* 等数据到了 DOM 才挂上,但依赖没变 → effect 不会重跑 →
* **IntersectionObserver 从未创建,inView 永远 false,重查询永不发出**,
* 页面就一直显示"没有应用维度的数据"。
*
* 现在改用 **callback ref**(节点挂载/卸载都会触发,作为 effect 依赖),
* 无论元素是首屏就有、还是数据回来后才有,都能正确观察到。
*
* 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"):
* 1) IntersectionObserver 正常路径,进入视口即触发
* 2) 1 秒后兜底量一次 getBoundingClientRect —— 有些容器(transform /
* overflow / 自定义滚动区)里 IO 的 root 判定会和预期不一致,实测出现过
* "卡片明明在屏幕上、观察器却一直不回调",于是数据永远不出来
* 3) 组件卸载时清理定时器与观察器
*
* 只触发一次(日志分析是只读快照,不需要来回重算)。
* 1) IntersectionObserver 正常路径
* 2) scroll / resize 时用 getBoundingClientRect 兜底(IO 在自定义滚动容器
* 或 transform 场景下偶发不回调,实测踩过)
* 3) 挂载瞬间先量一次(元素本来就在视口内时立刻触发)
*/
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
const ref = useRef<T | null>(null)
const [node, setNode] = useState<T | null>(null)
const [inView, setInView] = useState(false)
// callback ref:元素从"未挂载"变成"已挂载"时也会更新,作为 effect 依赖
const ref = useCallback((el: T | null) => setNode(el), [])
useEffect(() => {
if (inView) return
const el = ref.current
if (!el) return
if (!node || inView) return
// 兜底判定:元素顶边在视口内(含 rootMargin 余量)就算可见
const margin = Number.parseInt(rootMargin, 10) || 200
const isVisiblyInViewport = () => {
const rect = el.getBoundingClientRect()
const visible = () => {
const rect = node.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
return rect.top < viewportHeight + margin && rect.bottom > -margin
}
// 路径 1:IntersectionObserver
// 路径 1:已经可见就直接触发(数据回来后元素本来就在屏幕上)
if (visible()) {
setInView(true)
return
}
let observer: IntersectionObserver | undefined
if (typeof IntersectionObserver !== 'undefined') {
observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
setInView(true)
observer?.disconnect()
}
if (entries.some(entry => entry.isIntersecting)) setInView(true)
},
{ rootMargin },
)
observer.observe(el)
observer.observe(node)
}
// 路径 2:几何兜底(覆盖 IO 不回调的情况)
const fallback = window.setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (isVisiblyInViewport()) setInView(true)
}, 1000)
// 路径 2:滚动/resize 兜底(rAF 节流)
let ticking = false
const onScroll = () => {
if (ticking) return
ticking = true
window.requestAnimationFrame(() => {
ticking = false
if (visible()) setInView(true)
})
}
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('resize', onScroll)
return () => {
observer?.disconnect()
window.clearTimeout(fallback)
window.removeEventListener('scroll', onScroll)
window.removeEventListener('resize', onScroll)
}
}, [inView, rootMargin])
}, [node, inView, rootMargin])
return { ref, inView }
}
+1
View File
@@ -65,6 +65,7 @@ export default {
},
table: {
subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, without error counts or trends.',
title: 'App volume ranking',
computing: 'Computing per-app volume (reads log content, usually 20–30s)…',
subtitle: 'Top 10 apps by volume; click "View logs" to open the query page',
+1
View File
@@ -65,6 +65,7 @@ export default {
},
table: {
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
title: '应用日志量排行',
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
+62 -9
View File
@@ -14,7 +14,7 @@ import { StatCard } from '@/components/StatCard'
import { TimeRangePicker } from '@/components/TimeRangePicker'
import { logsApi, type LogSource } from '@/api/logs'
import { getUser } from '@/lib/auth-storage'
import { useAnalysisMetrics } from './hooks/useAnalysisMetrics'
import { useAnalysisMetrics, type AppRow } from './hooks/useAnalysisMetrics'
import { useDailyVolume } from './hooks/useDailyVolume'
import { useInView } from '@/hooks/useInView'
import { compactNumber, formatBytes } from './components/chart-shared'
@@ -39,17 +39,17 @@ const AnalysisPage = memo(function AnalysisPage() {
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
// 默认 15 分钟:分析页一次性要跑 5 条 count_over_time,
// 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。
// ── "选择值" 与 "已生效值" 分离 ──────────────────────────────────
// 默认 5 分钟:按应用统计要读日志内容(Loki 只能扫 chunk,不能走索引),
// 15 分钟在 370GB/天的量下要 12~15 秒,用户等不住。
// 5 分钟扫描量降到 1/3,实测 4~5 秒就能出排行;需要看更长趋势时手动改时间范围。
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 5 * 60 * 1000)))
// start/end/namespace 是**已生效**值,查询只认它们;
// sel* 是筛选栏里的**选择**值。
// 这样切换命名空间/时间只改变选择,不触发查询;点「刷新」才生效。
// 原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮很浪费,
// 用户快速连点几下会把 Loki 打满(并发护栏 429)。
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
const [selStart, setSelStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000)))
const [selStart, setSelStart] = useState(() => toLocal(new Date(Date.now() - 5 * 60 * 1000)))
const [selEnd, setSelEnd] = useState(() => toLocal(new Date()))
/**
@@ -137,11 +137,60 @@ const AnalysisPage = memo(function AnalysisPage() {
// 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。
const dailyView = useInView<HTMLDivElement>()
const rankingView = useInView<HTMLDivElement>()
// 最后一道保险:可见性判定万一失灵(自定义滚动容器 / 元素被包在
// transform 里等场景都踩过),8 秒后无条件查一次。
// 懒加载的目的是"别让首屏等",不该变成"永远不加载"。
const [forceRanking, setForceRanking] = useState(false)
useEffect(() => {
setForceRanking(false)
const timer = window.setTimeout(() => setForceRanking(true), 8000)
return () => window.clearTimeout(timer)
}, [effectiveSourceId])
const ingressView = useInView<HTMLDivElement>()
// ⚠️ 不门控这条查询:顶部的「活跃应用」「应用日志量分布」「应用日志量流图」
// 都依赖 by-app 数据,而它原来被底部排行卡片的可见性门控 ——
// 用户不滚到底就永远拿不到数据,表现为"三条都没有应用维度的数据"。
// 现在进页面就发,页面用两阶段渲染:索引类指标先出,app 维度到货后补。
// ── 长范围排行走平台预聚合 ───────────────────────────────────────
// 按应用排行必须读日志内容,成本 ∝ 时间范围(5 分钟 10s、15 分钟 20s、
// 6 小时 5~8 分钟、24 小时不可用)。所以 >1 小时的范围改读
// 后台每小时预聚合的快照(毫秒级),前台不再扫日志内容。
const rangeMinutes = Math.max(
1,
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
)
const useHistoryRanking = rangeMinutes > 60
const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null)
const [historyHours, setHistoryHours] = useState(0)
useEffect(() => {
if (!useHistoryRanking || !effectiveSourceId) {
setHistoryApps(null)
return
}
const controller = new AbortController()
setHistoryApps(null)
logsApi
.appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, 10, {
signal: controller.signal,
})
.then(res => {
const rows: AppRow[] = (res?.list ?? []).map(item => ({
app: item.app,
namespace: item.namespace,
namespaces: item.namespace ? [item.namespace] : [],
total: item.entries,
// 预聚合只统计条数,没有错误数/趋势 —— 表格对应列会显示 —
errorCount: 0,
sparkline: [],
}))
setHistoryApps(rows)
setHistoryHours(res?.hours ?? 0)
})
.catch(() => setHistoryApps([]))
return () => controller.abort()
}, [useHistoryRanking, effectiveSourceId, start, end])
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
sourceId: effectiveSourceId,
start,
@@ -151,7 +200,7 @@ const AnalysisPage = memo(function AnalysisPage() {
// 因此重新用可见性门控:页面打开只跑索引类查询(百毫秒),
// 用户滚到排行卡才发那条 10~15 秒的重查询。
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
rankingEnabled: rankingView.inView,
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
})
// 选择值与已生效值不一致 → 提示"点刷新生效"
@@ -291,11 +340,15 @@ const AnalysisPage = memo(function AnalysisPage() {
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('table.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
<p className="text-xs text-muted-foreground">
{useHistoryRanking
? t('table.subtitleHistory', { hours: historyHours, range: rangeMinutes })
: t('table.subtitle')}
</p>
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
</CardHeader>
<CardContent>
{(appsLoading || loading) && !data.apps.length ? (
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : data.apps).length ? (
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">