Files
LokiStack/frontend/src/pages/analysis/index.tsx
T
opsx 2da0a2bece feat(analysis): Ingress 访问分析卡 — 自动加载、无 ingress 自动折叠
- 新增常驻「Ingress 访问分析」卡:自动探测 ingress-nginx/ingress 命名空间并聚合访问日志
  (PV/UV、4xx/5xx、慢请求、接口排行、异常/慢请求 Top10、请求明细),随数据源与时间范围刷新
- 从历史恢复 ingress 洞察表与聚合工具到 analysis 域(ingress-access-log 解析已在 lib)
- 折叠交互:未检测到 ingress-nginx 时自动收起并显示徽章;手动切换记入 localStorage 优先生效
2026-10-28 22:43:57 +08:00

189 lines
8.3 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, TriangleAlert } 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 { 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)
/**
* 日志分析页(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)
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
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([]))
}, [])
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace: '' })
// 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区
const daily = useDailyVolume({ sourceId, namespace: '', appLabel })
// 非 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={loading || !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>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
</div>
{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 : (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label={t('stats.total')} value={data.stats.total.toLocaleString()} 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.warnings')} value={data.stats.warnCount.toLocaleString()} icon={TriangleAlert} iconColor="text-warning" iconBg="bg-warning/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>
<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>
<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>
<TopAppsTable
apps={data.apps.slice(0, 10)}
total={data.stats.total}
appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/>
</CardContent>
</Card>
<IngressAnalysisSection sourceId={sourceId} start={start} end={end} />
</>
)}
</PageContainer>
)
})
export default AnalysisPage