fix(frontend): 补齐 analysis 构建依赖与共享组件搬迁

补 commit hooks/图表组件、路由与 i18n domains、logs metrics API;
StatCard/TimeRangePicker/ingress-access-log 抽到共享路径,干净 checkout 可编过。
This commit is contained in:
Codex committed 2026-10-28 22:00:30 +08:00
1 parent f993d5b420
commit 11d0fff764
22 files changed
+552 -313

No files matched your search

@@ -0,0 +1,38 @@
import { memo, useMemo } from 'react'
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { useTranslation } from 'react-i18next'
import type { AppRow } from '../hooks/useAnalysisMetrics'
import { chartColors } from './chart-shared'
import { EmptyChart } from './EmptyChart'
const TOP_N = 10
/** 应用日志量流图:Top N 应用按时间堆叠面积(即流图/stream graph) */
export const AppStreamChart = memo(function AppStreamChart({ apps, appTimes }: { apps: AppRow[]; appTimes: string[] }) {
const { t } = useTranslation('analysis')
// 未打 app 标签的序列用占位 key(recharts 的 dataKey 不能是空串),图例显示词典文案
const topApps = useMemo(() => apps
.filter(app => app.total > 0)
.slice(0, TOP_N)
.map(app => ({ key: app.app || '__untagged__', label: app.app || t('table.untagged'), sparkline: app.sparkline })), [apps, t])
const data = useMemo(() => appTimes.map((time, index) => ({
time,
...Object.fromEntries(topApps.map(app => [app.key, app.sparkline[index] ?? 0])),
})), [appTimes, topApps])
if (topApps.length === 0 || appTimes.length === 0) return <EmptyChart message={t('stream.empty')} />
return <div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={48} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{topApps.map((app, index) => {
const color = chartColors[index % chartColors.length]
return <Area key={app.key} type="monotone" dataKey={app.key} name={app.label} stackId="logs" stroke={color} fill={color} fillOpacity={0.65} />
})}
</AreaChart>
</ResponsiveContainer>
</div>
})
@@ -0,0 +1,37 @@
import { memo, useMemo } from 'react'
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { useTranslation } from 'react-i18next'
import type { DailyAppRow } from '../hooks/useDailyVolume'
import { chartColors } from './chart-shared'
import { EmptyChart } from './EmptyChart'
/** 应用每日日志量堆叠柱状图:x 轴日期,系列为 7 天总量 Top 10 应用 */
export const DailyVolumeChart = memo(function DailyVolumeChart({ days, apps }: {
days: string[]
apps: DailyAppRow[]
}) {
const { t } = useTranslation('analysis')
// 未打 app 标签的序列用占位 key(recharts dataKey 不能是空串),图例显示词典文案
const topApps = useMemo(() => apps
.map(app => ({ key: app.app || '__untagged__', label: app.app || t('table.untagged'), totals: app.totals })), [apps, t])
const data = useMemo(() => days.map((day, index) => ({
day,
...Object.fromEntries(topApps.map(app => [app.key, app.totals[index] ?? 0])),
})), [days, topApps])
if (topApps.length === 0 || days.length === 0) return <EmptyChart message={t('daily.empty')} />
return <div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="day" tick={{ fontSize: 11 }} minTickGap={20} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={56} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{topApps.map((app, index) => {
const color = chartColors[index % chartColors.length]
return <Bar key={app.key} dataKey={app.key} name={app.label} stackId="daily" fill={color} />
})}
</BarChart>
</ResponsiveContainer>
</div>
})
@@ -0,0 +1,3 @@
export function EmptyChart({ message }: { message: string }) {
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
}
@@ -0,0 +1,41 @@
import { memo, useMemo } from 'react'
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
import { useTranslation } from 'react-i18next'
import { chartColors, compactNumber } from './chart-shared'
import { EmptyChart } from './EmptyChart'
const TOP_SLICES = 8
/** 命名空间分布环形饼图 + 侧边图例(超过 8 个归并「其他」) */
export const NamespacePie = memo(function NamespacePie({ shares }: { shares: Array<{ name: string; count: number }> }) {
const { t } = useTranslation('analysis')
const pieData = useMemo(() => {
const sorted = [...shares].sort((a, b) => b.count - a.count)
const top = sorted.slice(0, TOP_SLICES).map(item => ({ name: item.name, count: item.count }))
const rest = sorted.slice(TOP_SLICES).reduce((sum, item) => sum + item.count, 0)
if (rest > 0) top.push({ name: t('pie.other'), count: rest })
return top
}, [shares, t])
if (pieData.length === 0) return <EmptyChart message={t('pie.empty')} />
return <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_200px]">
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={pieData} dataKey="count" nameKey="name" outerRadius="78%" innerRadius="45%">
{pieData.map((item, index) => <Cell key={item.name} fill={chartColors[index % chartColors.length]} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col justify-center gap-2 text-xs">
{pieData.map((item, index) => (
<div key={item.name} className="flex items-center gap-2">
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: chartColors[index % chartColors.length] }} />
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
<strong className="tabular-nums">{compactNumber(item.count)}</strong>
</div>
))}
</div>
</div>
})
@@ -0,0 +1,62 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import type { AppRow } from '../hooks/useAnalysisMetrics'
/** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */
function Sparkline({ values }: { values: number[] }) {
const max = Math.max(...values, 1)
const width = 88
const height = 26
const points = values
.map((value, index) => `${(index / Math.max(values.length - 1, 1)) * width},${height - (value / max) * (height - 4) - 2}`)
.join(' ')
return <svg width={width} height={height} className="text-primary" aria-hidden>
<polyline points={points} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" />
</svg>
}
/** 应用日志量排行表:哪个 deployment 日志量最大、错误最多一目了然 */
export const TopAppsTable = memo(function TopAppsTable({
apps, total, appLabel, onViewLogs,
}: {
apps: AppRow[]
total: number
appLabel: string
onViewLogs: (query: string) => void
}) {
const { t } = useTranslation('analysis')
if (apps.length === 0) {
return <p className="py-8 text-center text-sm text-muted-foreground">{t('table.empty')}</p>
}
const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel })
return <div className="max-h-[26rem] overflow-auto rounded-lg border">
<table className="w-full text-left text-xs">
<thead className="sticky top-0 bg-muted text-muted-foreground">
<tr>
{[t('table.rank'), appHeader, t('table.namespace'), t('table.volume'), t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions')].map(title => (
<th key={title} className="whitespace-nowrap border-b px-3 py-2 font-medium">{title}</th>
))}
</tr>
</thead>
<tbody className="divide-y">
{apps.map((row, index) => {
const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}`
return <tr key={`${row.app}-${row.namespace}`} className="hover:bg-muted/40">
<td className="px-3 py-2 tabular-nums text-muted-foreground">{index + 1}</td>
<td className="max-w-56 truncate px-3 py-2 font-medium" title={row.app || undefined}>{row.app || t('table.untagged')}</td>
<td className="max-w-40 truncate px-3 py-2 text-muted-foreground" title={row.namespaces.join(', ')}>{row.namespace || '—'}</td>
<td className="px-3 py-2 tabular-nums">{row.total.toLocaleString()}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'}</td>
<td className={`px-3 py-2 tabular-nums ${row.errorCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{row.errorCount.toLocaleString()}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}</td>
<td className="px-3 py-2"><Sparkline values={row.sparkline} /></td>
<td className="px-3 py-2">
<Button type="button" variant="outline" size="sm" onClick={() => onViewLogs(selector)}>{t('table.viewLogs')}</Button>
</td>
</tr>
})}
</tbody>
</table>
</div>
})
@@ -0,0 +1,32 @@
import { memo } from 'react'
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { useTranslation } from 'react-i18next'
import { EmptyChart } from './EmptyChart'
interface TrendPoint { time: string; total: number; error: number; warn: number }
/** 日志量趋势折线图:总量 / 错误 / 告警 三条线 */
export const TrendChart = memo(function TrendChart({ trend }: { trend: TrendPoint[] }) {
const { t } = useTranslation('analysis')
const hasData = trend.some(point => point.total > 0 || point.error > 0 || point.warn > 0)
if (!hasData) return <EmptyChart message={t('trend.empty')} />
const series = [
{ key: 'total', name: t('trend.seriesTotal'), color: 'hsl(var(--info))' },
{ key: 'error', name: t('trend.seriesError'), color: 'hsl(var(--destructive))' },
{ key: 'warn', name: t('trend.seriesWarn'), color: 'hsl(var(--warning))' },
] as const
return <div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trend} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} />
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={48} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{series.map(item => (
<Line key={item.key} type="monotone" dataKey={item.key} name={item.name} stroke={item.color} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
})
@@ -0,0 +1,17 @@
// 分析页图表共享常量(与 LogResultViews 一致的语义 token 配色)
export const chartColors = [
'hsl(var(--info))',
'hsl(var(--success))',
'hsl(var(--warning))',
'hsl(var(--primary))',
'hsl(var(--destructive))',
'hsl(var(--muted-foreground))',
]
/** 数字缩写:1.2万 / 3.4k(表格与图例用,避免长数字撑爆列宽) */
export function compactNumber(value: number): string {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}亿`
if (value >= 10000) return `${(value / 10000).toFixed(1)}万`
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`
return String(value)
}