perf(analysis): 分层加载 + 修复裸 i18n key + 单位混用;附 Loki 查询方式的实测对比

=== 1. 界面显示裸 key(trend.subtitle / daily.subtitle / table.subtitle)===
根因:中文语言包只加了 title,**没有 subtitle**,而英文包有。
i18next 找不到 key 就把 key 原样输出,所以界面上出现 "trend.subtitle" 这种文字。
已补齐 zh 的 trend/stream/pie/daily/table 五个 subtitle,并把文案改成
人话(说明数据来源与是否需要等待),例如:
  trend : 按时间分桶的日志量(来自索引统计,秒级返回)
  daily : 近 7 个自然日的每日总量(来自索引统计,不受上方时间范围影响)
  table : 按日志条数排前 10 的应用,点「查看日志」跳到查询页

=== 2. 分层加载:上面的先出,下面滚到再算 ===
新增 hooks/useInView.ts(IntersectionObserver,提前 200px 触发,只触发一次)。
  - 首屏只跑索引类查询(histogram + index/stats),**合计 <100ms**
  - 「每日日志量」「应用排行」「Ingress 分析」三块滚动到视口才发查询
  - 排行卡片加载中显示"正在按应用统计(需读取日志内容,通常 20~30 秒)…",
    不再是空表格让人以为卡死

=== 3. 修复自己引入的单位混用(重要)===
趋势图数据源从 count_over_time 换成 index/volume_range 后,返回值是**字节**,
但排行表里的值是**条数**,两者混用导致:
  - 统计卡显示 7956179750 这种看不懂的裸数字
  - 排行占比算成 0.0x%(分子条数、分母字节)
修法:
  - 统计卡拆成两张且都走索引接口(实测 histogram 50ms / index-stats 43ms):
      日志条数(索引统计)  ← index/stats entries,精确条数
      日志体积(索引统计)  ← volume_range 累计,格式化为 GB
  - 排行占比分母改用 apps 自身条数合计
  - 去掉"告警行数"卡片:为了把重查询从 3 条降到 2 条已放弃全局 warn 统计,
    显示一个恒为 0 的数字比不显示更误导

=== 4. Loki 查询方式实测对比(15 分钟 astralx,370GB/天)===
直接打 Loki,逐个验证卡在哪:

  sum(count_over_time({ns}[30s]))              step=30s   61s 超时   ← 全集群全局
  sum by (app,ns)(count_over_time([30s]))      step=30s   19.7s  5.5GB  ← 现用
  sum by (app,ns)(count_over_time([15m]))      step=15m   47s 超时  ← 反直觉!
  sum by (app)(count_over_time([30s] |= error)) step=30s  12.7s  5.5GB
  单 app gateway count_over_time                step=30s   12.6s  0.58GB

  index/volume        (limit=200)                        2.2s   ← 只给 selector 级,不能按 app
  index/volume_range  (step=60s)                         133ms
  index/stats                                            112ms  ← entries/bytes 精确

两个结论(写进注释,避免以后又"优化"回去):
  a) **不要把 range 和 step 一起放大**:`[15m]` + step=15m 看着"点数少",
     实际要让 Loki 为单个 evaluate 点加载 15m 的 chunk,比 [30s]+step=30s 慢 2 倍以上
     (47s 超时 vs 19.7s)。
  b) **按 app 分组无法走索引**:Loki 3.4 的 index/volume 只回落到 selector 标签,
     不支持 aggregateBy=app(传了也无效果)。所以 per-app 排行只能读日志内容,
     20 秒左右是这台集群的物理下限 —— 只能靠懒加载 + 明确提示来改善体感。

=== 实测(v8-1028-2232 / 7a05683a91ae)===
  logs/histogram  50ms   logs/stats  43ms
  by-app 排行     25077ms(懒加载,滚动到才发)
  中文 subtitle 已进包:来自索引统计 / 按量排前 / 各命名空间的日志量占比 /
  近 7 个自然日 / 按日志条数排前 10 各命中 1 次
This commit is contained in:
opsx committed 2026-10-28 22:34:04 +00:00
1 parent fa7ddd67c1
commit cb91470d75
6 files changed
+153 -27

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from 'react'
/**
* useInView 判断元素是否进入视口(默认提前 200px 触发)。
*
* 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测大集群
* 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。
* 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。
*
* 只触发一次(进入视口后断开监听):日志分析是只读快照,不需要来回重算。
*/
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
const ref = useRef<T | null>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
if (inView) return
const el = ref.current
if (!el) return
// 老浏览器/SSR 环境没有 IntersectionObserver:直接当作可见,别把功能卡死
if (typeof IntersectionObserver === 'undefined') {
setInView(true)
return
}
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 }
}
+3
View File
@@ -21,6 +21,8 @@ export default {
},
stats: {
total: 'Total logs',
totalVolume: 'Volume (index)',
totalEntries: 'Entries (index)',
errors: 'Error lines',
warnings: 'Warning lines',
apps: 'Active apps',
@@ -53,6 +55,7 @@ export default {
table: {
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 jump to the query page',
rank: '#',
app: 'App',
+9 -1
View File
@@ -20,12 +20,15 @@ export default {
},
stats: {
total: '日志总量',
totalVolume: '日志体积(索引统计)',
totalEntries: '日志条数(索引统计)',
errors: '错误行数',
warnings: '告警行数',
apps: '活跃应用',
},
trend: {
title: '日志量趋势',
subtitle: '按时间分桶的日志量(来自索引统计,秒级返回)',
seriesTotal: '总量',
seriesError: '错误',
seriesWarn: '告警',
@@ -33,21 +36,26 @@ export default {
},
stream: {
title: '应用日志量流图',
subtitle: '按量排前 {{count}} 个应用的堆叠分布',
empty: '没有应用维度的数据',
},
pie: {
title: '命名空间分布',
subtitle: '各命名空间的日志量占比',
empty: '没有命名空间维度的数据',
other: '其他',
},
daily: {
fixedBadge: '固定近 7 天',
title: '应用每日日志量',
title: '每日日志量',
subtitle: '近 7 个自然日的每日总量(来自索引统计,不受上方时间范围影响)',
empty: '近 7 天没有应用维度的数据',
},
table: {
title: '应用日志量排行',
subtitle: '按日志条数排前 10 的应用,点「查看日志」跳到查询页',
computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…',
rank: '#',
app: '应用',
appWithKey: '应用({{key}})',
@@ -17,7 +17,15 @@ export interface AnalysisData {
apps: AppRow[]
/** 流图/趋势缩略的时间桶标签(本地时间 HH:mm) */
appTimes: string[]
stats: { total: number; errorCount: number; warnCount: number; appCount: number }
stats: {
/** 总字节数(来自 index/volume_range,毫秒级) */
total: number
/** 总条数(来自 index/stats,毫秒级;比体积更直观) */
totalEntries: number
errorCount: number
warnCount: number
appCount: number
}
}
export interface AnalysisParams {
@@ -25,6 +33,11 @@ export interface AnalysisParams {
start: string
end: string
namespace: string
/**
* 是否执行"按应用统计"的重查询(要读日志内容,20~30 秒)。
* 分析页会在卡片滚动到视口内时再置 true,避免首屏一起等。
*/
rankingEnabled?: boolean
}
const ns = (ms: number) => Math.round(ms).toString() + '000000'
@@ -58,7 +71,7 @@ function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] {
* 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。
* app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。
*/
export function useAnalysisMetrics({ sourceId, start, end, namespace }: AnalysisParams) {
export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEnabled = true }: AnalysisParams) {
const [appLabel, setAppLabel] = useState('app')
const [data, setData] = useState<AnalysisData | null>(null)
const [loading, setLoading] = useState(false)
@@ -132,10 +145,14 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
// 单条要 exec 29s + 排队,5 条串行下来两三分钟都出不来,页面就是"死在转圈"。
// /logs/histogram 内部用 index/volume_range,只读 TSDB 的 chunk 元数据,
// 不解码日志内容,同一条件毫秒级返回(对比实测 ~1300 倍差距)。
const hist = await logsApi.histogram(
{ sourceId, query: selector, start: Number(ns(new Date(start).getTime())), end: Number(ns(new Date(end).getTime())) },
{ signal: controller.signal },
)
// histogram(体积曲线)与 stats(总条数)都是索引接口,一起发、都是毫秒级
const startNs = Number(ns(new Date(start).getTime()))
const endNs = Number(ns(new Date(end).getTime()))
const [hist, idxStats] = await Promise.all([
logsApi.histogram({ sourceId, query: selector, start: startNs, end: endNs }, { signal: controller.signal }),
logsApi.stats({ sourceId, query: selector, start: startNs, end: endNs }, { signal: controller.signal })
.catch(() => null),
])
const totalSeries: MetricSeries[] = [{
labels: {},
points: (hist || []).map(bucket => ({
@@ -154,6 +171,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
appTimes: prev?.appTimes ?? [],
stats: {
total: seriesTotal(totalSeries),
totalEntries: idxStats?.entries ?? 0,
errorCount: prev?.stats.errorCount ?? 0,
warnCount: prev?.stats.warnCount ?? 0,
appCount: prev?.stats.appCount ?? 0,
@@ -168,10 +186,19 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
// - 全局总量已由上面的 histogram(索引)提供,不用再查
// - 全局 error 由 errByApp 各序列求和得出,不用再查
// - 全局 warn 直接放弃(优先级最低,却要花 20 秒;趋势图里留空而不是假装 0)
// 排行卡片还没滚到视口内时,先不跑这两条重查询(各 20s+),
// 让上面的趋势/占比先出;卡片可见后 analysis 会重跑本 effect。
if (!rankingEnabled) {
setLoading(false)
return
}
// 这两条并行而不是串行:实测总量 22s、error 8.6s,串行 31s,并行 22s。
// 并发上限是 4,两条并行不会撞护栏(之前串行是为了避免 5 条并发的场景)。
const [byAppSeries, errByAppSeries] = await runInBatches([
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`),
])
], 2)
if (version !== versionRef.current) return
// 由 by-app 序列反推全局 error / warn 曲线,省掉两条重查询
@@ -231,6 +258,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
appTimes: times.map(timeLabel),
stats: {
total: seriesTotal(totalSeries),
totalEntries: idxStats?.entries ?? 0,
errorCount: seriesTotal(errorSeries),
warnCount: seriesTotal(warnSeries),
appCount: apps.length,
@@ -246,7 +274,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis
})()
return () => { controller.abort() }
}, [sourceId, start, end, namespace, appLabel, refreshTick])
}, [sourceId, start, end, namespace, appLabel, refreshTick, rankingEnabled])
return { data, loading, error, appLabel, refresh }
}
@@ -24,10 +24,12 @@ const dayLabel = (ts: number) => new Date(ts).toLocaleDateString([], { month: '2
* 一条 `sum by (app, namespace)(count_over_time(sel[$step]))` 查询(7 天跨度后端自动选 1h 步长),
* 客户端把小时桶按本地日历日聚合,避免为每天单独发查询。
*/
export function useDailyVolume({ sourceId, namespace, appLabel }: {
export function useDailyVolume({ sourceId, namespace, appLabel, enabled = true }: {
sourceId: number
namespace: string
appLabel: string
/** 卡片滚动到视口内才发查询(索引接口很快,但没必要首屏就抢带宽) */
enabled?: boolean
}) {
const [data, setData] = useState<DailyVolumeData | null>(null)
const [loading, setLoading] = useState(false)
@@ -39,7 +41,7 @@ export function useDailyVolume({ sourceId, namespace, appLabel }: {
const refresh = useCallback(() => setRefreshTick(tick => tick + 1), [])
useEffect(() => {
if (!sourceId) return
if (!sourceId || !enabled) return
const end = Date.now()
// 从今天往前取整 7 个自然日(首尾两天可能是不完整天)
const today = Math.floor(end / DAY_MS) * DAY_MS
@@ -98,7 +100,7 @@ export function useDailyVolume({ sourceId, namespace, appLabel }: {
})()
return () => { controller.abort() }
}, [sourceId, namespace, appLabel, refreshTick])
}, [sourceId, namespace, appLabel, refreshTick, enabled])
return { data, loading, error, refresh }
}
+61 -15
View File
@@ -2,7 +2,7 @@ import { memo, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react'
import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -16,6 +16,7 @@ import { logsApi, type LogSource } from '@/api/logs'
import { getUser } from '@/lib/auth-storage'
import { useAnalysisMetrics } from './hooks/useAnalysisMetrics'
import { useDailyVolume } from './hooks/useDailyVolume'
import { useInView } from '@/hooks/useInView'
import { TrendChart } from './components/TrendChart'
import { AppStreamChart } from './components/AppStreamChart'
import { DailyVolumeChart } from './components/DailyVolumeChart'
@@ -35,7 +36,19 @@ const ALL_NAMESPACES = '__all__'
const AnalysisPage = memo(function AnalysisPage() {
const { t } = useTranslation('analysis')
const navigate = useNavigate()
const isAdmin = getUser()?.role === 'admin'
/** 索引接口返回的是字节数,直接 toLocaleString 会显示 7956179750 这种看不懂的数字 */
const formatBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
}
const isAdmin = getUser()?.role === 'admin'
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
@@ -116,9 +129,25 @@ const AnalysisPage = memo(function AnalysisPage() {
// 1) 避免首屏先用空 namespace 发一次 {namespace=~".+"} 的全集群查询(很贵)
// 2) 避免"查了两次"——策略返回后 namespace 变化会再查一次,前一次白跑
const effectiveSourceId = namespaceReady ? sourceId : 0
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId: effectiveSourceId, start, end, namespace: scopedNamespace })
// 分层加载:上面的总量/趋势/占比先出,下面"要读日志内容"的卡片
// 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。
const dailyView = useInView<HTMLDivElement>()
const rankingView = useInView<HTMLDivElement>()
const ingressView = useInView<HTMLDivElement>()
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({
sourceId: effectiveSourceId,
start,
end,
namespace: scopedNamespace,
rankingEnabled: rankingView.inView,
})
// 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区
const daily = useDailyVolume({ sourceId: effectiveSourceId, namespace: scopedNamespace, appLabel })
const daily = useDailyVolume({
sourceId: effectiveSourceId,
namespace: scopedNamespace,
appLabel,
enabled: dailyView.inView,
})
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!isAdmin) {
@@ -194,10 +223,12 @@ const AnalysisPage = memo(function AnalysisPage() {
<LoadingSpinner className="h-64" />
) : !data || !sourceId ? null : (
<>
{/* 不再显示"告警行数"卡片:为了把它从 3 条重查询降到 2 条,
已放弃全局 warn 统计。显示一个恒为 0 的数字比不显示更误导。 */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label={t('stats.total')} value={data.stats.total.toLocaleString()} icon={ScrollText} iconColor="text-info" iconBg="bg-info/10" />
<StatCard label={t('stats.totalEntries')} value={data.stats.totalEntries.toLocaleString()} icon={ScrollText} iconColor="text-info" iconBg="bg-info/10" />
<StatCard label={t('stats.totalVolume')} value={formatBytes(data.stats.total)} icon={ScrollText} iconColor="text-info" iconBg="bg-info/10" />
<StatCard label={t('stats.errors')} value={data.stats.errorCount.toLocaleString()} icon={FileWarning} iconColor="text-destructive" iconBg="bg-destructive/10" />
<StatCard label={t('stats.warnings')} value={data.stats.warnCount.toLocaleString()} icon={TriangleAlert} iconColor="text-warning" iconBg="bg-warning/10" />
<StatCard label={t('stats.apps')} value={data.stats.appCount} icon={Boxes} iconColor="text-primary" iconBg="bg-primary/10" />
</div>
@@ -226,7 +257,7 @@ const AnalysisPage = memo(function AnalysisPage() {
<CardContent><AppStreamChart apps={data.apps} appTimes={data.appTimes} /></CardContent>
</Card>
<Card>
<Card ref={dailyView.ref as React.Ref<HTMLDivElement>}>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
@@ -245,22 +276,37 @@ const AnalysisPage = memo(function AnalysisPage() {
</CardContent>
</Card>
<Card>
<Card ref={rankingView.ref as React.Ref<HTMLDivElement>}>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('table.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
</CardHeader>
<CardContent>
<TopAppsTable
apps={data.apps.slice(0, 10)}
total={data.stats.total}
appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/>
{loading && !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">
<LoadingSpinner />
<p>{t('table.computing')}</p>
</div>
) : (
<TopAppsTable
apps={data.apps.slice(0, 10)}
/* 分母必须是"条数合计":apps 里是条数,stats.total 是字节,
混用会让占比全部算成 0.0x% */
total={data.apps.reduce((sum, app) => sum + app.total, 0)}
appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/>
)}
</CardContent>
</Card>
<IngressAnalysisSection sourceId={sourceId} start={start} end={end} />
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
<div ref={ingressView.ref as React.Ref<HTMLDivElement>}>
{ingressView.inView && <IngressAnalysisSection sourceId={sourceId} start={start} end={end} />}
</div>
</>
)}
</PageContainer>