=== 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 次
317 lines
15 KiB
TypeScript
317 lines
15 KiB
TypeScript
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
|