Files
LokiStack/frontend/src/pages/analysis/index.tsx
T
opsx cb91470d75 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 次
2026-10-28 22:34:04 +00:00

317 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } 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'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ErrorState } from '@/components/ErrorState'
import { LoadingSpinner } from '@/components/LoadingSpinner'
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 { useDailyVolume } from './hooks/useDailyVolume'
import { useInView } from '@/hooks/useInView'
import { TrendChart } from './components/TrendChart'
import { AppStreamChart } from './components/AppStreamChart'
import { DailyVolumeChart } from './components/DailyVolumeChart'
import { NamespacePie } from './components/NamespacePie'
import { TopAppsTable } from './components/TopAppsTable'
import { IngressAnalysisSection } from './components/IngressAnalysisSection'
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
/** "全部命名空间"哨兵值:对应后端 {namespace=~".+"},代价高,仅作显式选项 */
const ALL_NAMESPACES = '__all__'
/**
* 日志分析页(admin 专用):SLS 风格仪表盘 — 统计卡 + 趋势折线 + 命名空间饼图 +
* 应用流图 + 应用排行表;数据来自 5 条 LogQL 指标查询(POST /logs/metrics)。
*/
const AnalysisPage = memo(function AnalysisPage() {
const { t } = useTranslation('analysis')
const navigate = useNavigate()
/** 索引接口返回的是字节数,直接 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)
// 默认 15 分钟:分析页一次性要跑 5 条 count_over_time,
// 1 小时窗口在日志量大的命名空间要十几秒;15 分钟既能看出趋势又能快速出结果。
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 15 * 60 * 1000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
/**
* 命名空间筛选。
* 之前这里写死 namespace: '',等价于 {namespace=~".+"} —— 一次分析就扫**全集群**
* (实测约 370 GB/天),5 条 count_over_time 跑 30 秒都出不来,页面永远转圈。
* 现在默认只查业务命名空间,用户可下拉切换;"全部命名空间"作为显式选项并列提示较慢。
*/
const [namespaces, setNamespaces] = useState<string[]>([])
// 初始为空,等策略返回后再定(不要用 localStorage 覆盖策略:
// 策略是服务端下发的权威值,本地记忆会把管理员改过的策略"顶掉")。
const [namespace, setNamespace] = useState<string>('')
const [namespaceReady, setNamespaceReady] = useState(false)
useEffect(() => {
logsApi.sources()
.then(result => {
setSources(result.list)
setSourceId(current =>
result.list.some(source => source.id === current)
? current
: result.list.find(source => source.isDefault)?.id ?? result.list[0]?.id ?? 0,
)
})
.catch(() => setSources([]))
}, [])
// 拉取可用命名空间,并把**查询授权策略里配置的命名空间**作为默认值。
//
// 之前这里硬编码"优先 astralx",等于把环境特征写进代码:
// 换环境(策略配的是 default / 别的 ns)就默认选错,用户还得手动改。
// 现在默认值来源依次为:URL 记忆 → 授权策略 labels.namespace → default → 列表第一个。
useEffect(() => {
if (!sourceId) return
let cancelled = false
Promise.all([
logsApi.labelValues(sourceId, 'namespace'),
// 策略里配的命名空间(后台「查询授权」可改);失败不影响主流程
logsApi.myScope(sourceId).catch(() => null),
])
.then(([list, scope]) => {
if (cancelled) return
const values = (list || []).filter(Boolean).sort()
const scopeNs = scope?.labels?.namespace ?? ''
setNamespaces(values)
setNamespace(current => {
// 优先级:用户当前已选(同一数据源内不跳变)→ 授权策略 → default → 第一个
const candidates = [current, scopeNs, 'default', values[0]]
for (const candidate of candidates) {
if (!candidate) continue
if (candidate === ALL_NAMESPACES) return candidate
if (values.includes(candidate)) return candidate
}
// 策略里配了但 Loki 里暂时没数据时,仍按策略显示(用户能看到"为什么没数据")
if (scopeNs) return scopeNs
return ''
})
})
.catch(() => { if (!cancelled) setNamespaces([]) })
.finally(() => { if (!cancelled) setNamespaceReady(true) })
return () => { cancelled = true }
}, [sourceId])
// 记住用户手选,下次进入沿用(但不会覆盖策略下发的值)
useEffect(() => {
if (namespace) localStorage.setItem('analysisNamespace', namespace)
}, [namespace])
// hook 内部把空串当作 {namespace=~".+"}(全部),这里做哨兵转换
const scopedNamespace = namespace === ALL_NAMESPACES ? '' : namespace
// 等命名空间解析完再发查询:
// 1) 避免首屏先用空 namespace 发一次 {namespace=~".+"} 的全集群查询(很贵)
// 2) 避免"查了两次"——策略返回后 namespace 变化会再查一次,前一次白跑
const effectiveSourceId = namespaceReady ? sourceId : 0
// 分层加载:上面的总量/趋势/占比先出,下面"要读日志内容"的卡片
// 只在滚动到视口附近时才发查询(每天量、应用排行、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,
enabled: dailyView.inView,
})
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!isAdmin) {
return (
<PageContainer title={t('title')}>
<EmptyState icon={BarChart3} title={t('adminOnly.title')} description={t('adminOnly.desc')} />
</PageContainer>
)
}
return (
<PageContainer
title={t('title')}
description={t('subtitle')}
action={
<Button variant="outline" onClick={refresh} disabled={!sourceId}>
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <RefreshCw className="mr-1 size-4" />}
{t('filter.refresh')}
</Button>
}
>
{/* 筛选栏:数据源 / 时间范围(全命名空间聚合) */}
<div className="flex flex-wrap items-center gap-2">
<Select
value={sourceId ? String(sourceId) : undefined}
onValueChange={value => setSourceId(Number(value))}
disabled={sources.length === 0}
>
<SelectTrigger className="h-9 min-w-40" aria-label={t('filter.source')}>
<SelectValue placeholder={t('filter.sourceEmpty')} />
</SelectTrigger>
<SelectContent>
{sources.map(source => (
<SelectItem key={source.id} value={String(source.id)}>{source.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={namespace || undefined}
onValueChange={value => {
setNamespace(value)
try { localStorage.setItem('analysisNamespace', value) } catch { /* 隐私模式忽略 */ }
}}
>
<SelectTrigger className="h-9 min-w-44" aria-label={t('filter.namespace')}>
<SelectValue placeholder={t('filter.namespaceEmpty')} />
</SelectTrigger>
<SelectContent>
{namespaces.map(item => <SelectItem key={item} value={item}>{item}</SelectItem>)}
<SelectItem value={ALL_NAMESPACES}>{t('filter.namespaceAll')}</SelectItem>
</SelectContent>
</Select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
</div>
{!namespaceReady && sourceId ? (
/* 命名空间列表未就绪先等待:否则会先用空串发起一次"全集群"查询,非常慢 */
<LoadingSpinner className="h-64" />
) : sources.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t('emptySource.title')}
description={t('emptySource.desc')}
action={
<Button variant="outline" onClick={() => navigate('/logs')}>
{t('emptySource.goLogs')}
</Button>
}
/>
) : error ? (
<ErrorState message={error} onRetry={refresh} />
) : loading && !data ? (
<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.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.apps')} value={data.stats.appCount} icon={Boxes} iconColor="text-primary" iconBg="bg-primary/10" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('trend.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('trend.subtitle')}</p>
</CardHeader>
<CardContent><TrendChart trend={data.trend} /></CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('pie.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('pie.subtitle')}</p>
</CardHeader>
<CardContent><NamespacePie shares={data.namespaceShares} /></CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('stream.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}</p>
</CardHeader>
<CardContent><AppStreamChart apps={data.apps} appTimes={data.appTimes} /></CardContent>
</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>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{t('daily.fixedBadge')}</span>
</div>
<p className="text-xs text-muted-foreground">{t('daily.subtitle')}</p>
</CardHeader>
<CardContent>
{daily.error ? (
<ErrorState message={daily.error} onRetry={daily.refresh} />
) : daily.loading && !daily.data ? (
<LoadingSpinner className="h-40" />
) : (
<DailyVolumeChart days={daily.data?.days ?? []} apps={daily.data?.apps ?? []} />
)}
</CardContent>
</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>
{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>
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
<div ref={ingressView.ref as React.Ref<HTMLDivElement>}>
{ingressView.inView && <IngressAnalysisSection sourceId={sourceId} start={start} end={end} />}
</div>
</>
)}
</PageContainer>
)
})
export default AnalysisPage