fix(frontend): logs/analysis P0 — 职责拆分与布局收口

砍 analysis 明细可视化改「去日志查询」;无数据源 EmptyState;日图标固定近7天。
logs 去掉负 margin、页头常显、筛选/查询分层;level 色阶;Label 保持默认收起。
This commit is contained in:
Codex committed 2026-10-28 21:54:40 +08:00
1 parent 4f75ef8578
commit f993d5b420
5 files changed
+431 -110

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
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 { 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'
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 [namespaces, setNamespaces] = useState<string[]>([])
const [namespace, setNamespace] = useState('')
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([]))
}, [])
// 命名空间候选随数据源变化(切换数据源时在 onChange 里同步清掉已选命名空间)
useEffect(() => {
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'namespace')
.then(list => { if (active) setNamespaces(Array.isArray(list) ? list : []) })
.catch(() => { if (active) setNamespaces([]) })
return () => { active = false }
}, [sourceId])
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
aria-label={t('filter.source')}
className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground"
value={sourceId}
onChange={event => { setSourceId(Number(event.target.value)); setNamespace('') }}
>
{sources.length === 0 && <option value={0}>{t('filter.sourceEmpty')}</option>}
{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}
</select>
<select
aria-label={t('filter.namespace')}
className="h-9 min-w-36 rounded-md border bg-background px-3 text-sm text-foreground"
value={namespace}
onChange={event => setNamespace(event.target.value)}
disabled={!sourceId}
>
<option value="">{t('filter.allNamespaces')}</option>
{namespaces.map(item => <option key={item} value={item}>{item}</option>)}
</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-2">
<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-2">
<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-2">
<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-2">
<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 ? (
<p className="py-6 text-center text-xs text-destructive">{daily.error}</p>
) : daily.loading && !daily.data ? (
<LoadingSpinner className="h-40" />
) : (
<DailyVolumeChart days={daily.data?.days ?? []} apps={daily.data?.apps ?? []} />
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<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>
<Card className="border-dashed">
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<p className="text-sm font-medium">{t('goQuery.title')}</p>
<p className="text-xs text-muted-foreground">{t('goQuery.desc')}</p>
</div>
<Button variant="outline" onClick={() => navigate('/logs')}>
<ScrollText className="mr-1 size-4" />
{t('goQuery.action')}
</Button>
</CardContent>
</Card>
</>
)}
</PageContainer>
)
})
export default AnalysisPage