=== 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
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 { 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
|