fix(frontend): logs/analysis P0 — 职责拆分与布局收口
砍 analysis 明细可视化改「去日志查询」;无数据源 EmptyState;日图标固定近7天。 logs 去掉负 margin、页头常显、筛选/查询分层;level 色阶;Label 保持默认收起。
This commit is contained in:
1 parent
4f75ef8578
commit
f993d5b420
5 files changed
+431
-110
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user