Files
LokiStack/frontend/src/pages/analysis/index.tsx
T
opsx 37e2f0febc feat(analysis,alerts): 每日量落库看历史趋势、趋势图/饼图可读性、告警三分页
=== 1. 每日日志量必须落库(核心问题:Loki 只留 24 小时)===
实测:请求"近 7 天每日量",Loki 只返回 27 个**小时**桶,全部在最近 24h 内 ——
因为 retention_period=24h,更早的 chunk 已经被删了。
也就是说「固定近 7 天」这张卡片,过去一直是"6 天空白 + 1 天有数据",
用户看着像坏了,实际是数据物理不存在。

方案:平台自己留存历史。
  - models.DailyVolumeSnapshot{Date(本地YYYY-MM-DD), SourceID, Bytes, Entries, Partial}
  - services/daily_volume.go
      CaptureDailyVolume        按天抓 index/stats 落库(幂等覆盖)
      StartDailyVolumeCapture   启动 20s 后回补 + 每 30 分钟检查一次
      GetDailyVolumeHistory     读库返回最近 N 天(今天会实时补抓)
  - 保留 400 天(够看年度趋势),按 source+date 去重
  - 前端可选 7 / 30 / 90 / 365 天,能看多长取决于平台积累多久

为什么用"每 30 分钟检查昨天有没有落库"而不是固定 00:10 定时:
平台可能整晚没运行,固定时点的定时器会直接错过;检查式回补不依赖平台在线时刻。

Partial 标记(关键,否则会误读):
  - 当天未结束 → partial=true
  - 待抓取日期的起点已超出 24h 保留期 → partial=true(Loki 里就没那么多数据)
实测:10-27 32.5GB/2.65亿条 partial=true(保留期外),
      10-28 348GB/28.56亿条 partial=true(当天进行中)

=== 2. 修一个 Go 副本 bug(自己写的,靠实测发现)===
第一版用 `for _, snap := range db.DailyVolumeSnapshots` 后改 snap 字段,
range 拿到的是**副本**,改动不会写回切片 → 同一天永远保持首次抓取的值,
partial 也永不更新(实测发现 10-27 该是 true 却显示 false)。
改为 `for i := range ... { snap := &db.DailyVolumeSnapshots[i] }`。

=== 3. 趋势图可读性 ===
从"裸折线"换成"渐变面积图",并在图上方给出峰值 / 均值:
  - 面积图能一眼看出"这一段整体比上一段高",折线只能靠对齐格子数
  - 峰值/均值避免用户自己对刻度
  - Y 轴与 tooltip 统一 formatBytes,"每桶体积"写进 subtitle
顺带确认:趋势图只画体积(字节),不再混入 error(条数)——单位不同不能同轴。

=== 4. 饼图:命名空间分布 → 应用日志量分布 ===
本环境的命名空间只有 astralx / log 两个,饼图没有信息量;
改成按应用看谁在刷日志(前 8 名 + 其他),才是运维真正要的答案。

=== 5. 告警页拆成三个 Tab ===
「告警规则 / 通知通道 / 发送历史」独立成分页(用 ui/tabs)。
之前通道是折叠块、历史堆在规则下面,规则一多就要滚很久才看得到。
发送历史增加空状态文案,不再是"空页面不知道有没有在发"。

=== 6. 内置规则关联默认通道 + 首次部署 webhook 留空的评估 ===
结论:**webhook 可以留空,但通道必须是禁用状态**。
  - 若种子给的是"已启用 + 空地址"通道,规则一命中就必然投递失败,
    发送历史堆满红色失败,反而让人以为平台坏了
  - 所以首次部署建一个**禁用**的占位通道「默认通知通道(待配置 Webhook)」,
    内置规则全部挂到它;管理员填好 Webhook 再启用即可,不用逐条关联
  - 保存时加校验:启用则必须有非空 Webhook(返回明确原因,不是裸 500)
  - schemaVersion v5→v6:把已有的"无通道"规则补挂到默认通道
  实测:8 条规则全部已关联通道

=== 实测(v11-1028-2252 / 187062644c0f)===
  /logs/daily-volume?days=7  → 2 天数据,partial 标记正确
  规则 8 条全部有 channelIds
  平台 ready、健康 ok
2026-10-28 22:54:12 +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 { compactNumber, formatBytes } from './components/chart-shared'
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()
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 天,不随上方时间范围变化;错误就地展示,不影响主区
// 每日量可看范围:7 / 30 / 90 / 365 天(数据来自平台留存历史)
const [dailyDays, setDailyDays] = useState(7)
const daily = useDailyVolume({
sourceId: effectiveSourceId,
days: dailyDays,
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={`${compactNumber(data.stats.totalEntries)} 条`} 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={`${compactNumber(data.stats.errorCount)} 条`} 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>
{/* 从"命名空间分布"改成"应用分布":命名空间只有 astralx/log 两个,
看了没有信息量;按应用才看得出是谁在刷日志 */}
<NamespacePie
shares={data.apps.map(app => ({ name: app.app || t('table.untagged'), count: app.total }))}
/>
</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-success/10 px-2 py-0.5 text-[11px] font-medium text-success">{t('daily.badge')}</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
points={daily.data?.points ?? []}
days={dailyDays}
onDaysChange={setDailyDays}
/>
)}
</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