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([]) 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([]) // 初始为空,等策略返回后再定(不要用 localStorage 覆盖策略: // 策略是服务端下发的权威值,本地记忆会把管理员改过的策略"顶掉")。 const [namespace, setNamespace] = useState('') 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() const rankingView = useInView() const ingressView = useInView() 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 ( ) } return ( {loading ? : } {t('filter.refresh')} } > {/* 筛选栏:数据源 / 时间范围(全命名空间聚合) */}
{ setStart(nextStart); setEnd(nextEnd) }} />
{!namespaceReady && sourceId ? ( /* 命名空间列表未就绪先等待:否则会先用空串发起一次"全集群"查询,非常慢 */ ) : sources.length === 0 ? ( navigate('/logs')}> {t('emptySource.goLogs')} } /> ) : error ? ( ) : loading && !data ? ( ) : !data || !sourceId ? null : ( <> {/* 不再显示"告警行数"卡片:为了把它从 3 条重查询降到 2 条, 已放弃全局 warn 统计。显示一个恒为 0 的数字比不显示更误导。 */}
{t('trend.title')}

{t('trend.subtitle')}

{t('pie.title')}

{t('pie.subtitle')}

{t('stream.title')}

{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}

}>
{t('daily.title')} {t('daily.fixedBadge')}

{t('daily.subtitle')}

{daily.error ? ( ) : daily.loading && !daily.data ? ( ) : ( )}
}> {t('table.title')}

{t('table.subtitle')}

{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
{loading && !data.apps.length ? ( // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。

{t('table.computing')}

) : ( sum + app.total, 0)} appLabel={appLabel} onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)} /> )}
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
}> {ingressView.inView && }
)}
) }) export default AnalysisPage