- 新增常驻「Ingress 访问分析」卡:自动探测 ingress-nginx/ingress 命名空间并聚合访问日志 (PV/UV、4xx/5xx、慢请求、接口排行、异常/慢请求 Top10、请求明细),随数据源与时间范围刷新 - 从历史恢复 ingress 洞察表与聚合工具到 analysis 域(ingress-access-log 解析已在 lib) - 折叠交互:未检测到 ingress-nginx 时自动收起并显示徽章;手动切换记入 localStorage 优先生效
189 lines
8.3 KiB
TypeScript
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
|