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)
}
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { logsApi, type MetricSeries } from '@/api/logs'
/** 表格/流图的一行:一个应用(跨命名空间聚合) */
export interface AppRow {
app: string
namespace: string
namespaces: string[]
total: number
errorCount: number
sparkline: number[]
}
export interface AnalysisData {
trend: Array<{ time: string; total: number; error: number; warn: number }>
namespaceShares: Array<{ name: string; count: number }>
apps: AppRow[]
/** 流图/趋势缩略的时间桶标签(本地时间 HH:mm) */
appTimes: string[]
stats: { total: number; errorCount: number; warnCount: number; appCount: number }
}
export interface AnalysisParams {
sourceId: number
start: string
end: string
namespace: string
}
const ns = (ms: number) => Math.round(ms).toString() + '000000'
const timeLabel = (ts: string) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
function seriesTotal(series: MetricSeries[]): number {
return series.reduce((sum, item) => sum + item.points.reduce((acc, point) => acc + point.value, 0), 0)
}
/** 把单序列指标(total/error/warn)按时间戳合并成趋势行 */
function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] {
const buckets = new Map<string, { total: number; error: number; warn: number }>()
for (const [index, series] of seriesList.entries()) {
const key = index === 0 ? 'total' : index === 1 ? 'error' : 'warn'
for (const item of series) {
for (const point of item.points) {
const bucket = buckets.get(point.timestamp) ?? { total: 0, error: 0, warn: 0 }
bucket[key] += point.value
buckets.set(point.timestamp, bucket)
}
}
}
return [...buckets.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([timestamp, values]) => ({ time: timeLabel(timestamp), ...values }))
}
/**
* 日志分析数据 — 并行 5 条 LogQL 指标查询([$step] 占位符由后端替换),
* AbortController + 版本号令牌防错序(useDashboardStats 同款范式):
* 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。
* app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。
*/
export function useAnalysisMetrics({ sourceId, start, end, namespace }: AnalysisParams) {
const [appLabel, setAppLabel] = useState('app')
const [data, setData] = useState<AnalysisData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [refreshTick, setRefreshTick] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const versionRef = useRef(0)
const labelVersionRef = useRef(0)
const refresh = useCallback(() => setRefreshTick(tick => tick + 1), [])
// app 标签探测:每个数据源只做一次
useEffect(() => {
if (!sourceId) { setAppLabel('app'); return }
const version = ++labelVersionRef.current
logsApi.labels(sourceId).then(labels => {
if (version !== labelVersionRef.current) return
const list = Array.isArray(labels) ? labels : []
setAppLabel(['app', 'service_name', 'container'].find(name => list.includes(name)) ?? 'app')
}).catch(() => { if (version === labelVersionRef.current) setAppLabel('app') })
}, [sourceId])
useEffect(() => {
const from = new Date(start).getTime()
const to = new Date(end).getTime()
if (!sourceId || !(from < to)) { setData(null); return }
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const version = ++versionRef.current
const selector = namespace ? `{namespace="${namespace}"}` : '{namespace=~".+"}'
const range = { sourceId, start: Number(ns(from)), end: Number(ns(to)) }
const fetchMetrics = (query: string) => logsApi.metrics({ ...range, query }, { signal: controller.signal })
setLoading(true)
setError('')
void (async () => {
try {
const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await Promise.all([
fetchMetrics(`sum(count_over_time(${selector}[$step]))`),
fetchMetrics(`sum(count_over_time(${selector} |= "error"[$step]))`),
fetchMetrics(`sum(count_over_time(${selector} |= "warn"[$step]))`),
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`),
])
if (version !== versionRef.current) return
// 应用聚合:统一到最长序列的时间桶上;跨命名空间求和
let times: string[] = []
for (const item of byAppSeries) if (item.points.length > times.length) times = item.points.map(point => point.timestamp)
const timeIndex = new Map(times.map((ts, index) => [ts, index]))
const appMap = new Map<string, { row: AppRow; perNs: Map<string, number> }>()
for (const item of byAppSeries) {
const app = item.labels[appLabel] ?? ''
const nsLabel = item.labels.namespace ?? ''
const entry = appMap.get(app) ?? { row: { app, namespace: nsLabel, namespaces: [], total: 0, errorCount: 0, sparkline: times.map(() => 0) }, perNs: new Map<string, number>() }
for (const point of item.points) {
const index = timeIndex.get(point.timestamp)
if (index !== undefined) entry.row.sparkline[index] += point.value
}
entry.perNs.set(nsLabel, (entry.perNs.get(nsLabel) ?? 0) + item.points.reduce((acc, point) => acc + point.value, 0))
appMap.set(app, entry)
}
for (const item of errByAppSeries) {
const entry = appMap.get(item.labels[appLabel] ?? '')
if (entry) entry.row.errorCount += item.points.reduce((acc, point) => acc + point.value, 0)
}
const apps = [...appMap.values()].map(({ row, perNs }) => ({
...row,
total: row.sparkline.reduce((acc, value) => acc + value, 0),
namespaces: [...perNs.keys()].filter(Boolean),
namespace: [...perNs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? row.namespace,
})).sort((a, b) => b.total - a.total)
// 命名空间分布:各应用的 perNs 二次聚合
const nsCounts = new Map<string, number>()
for (const { perNs } of appMap.values()) {
for (const [nsLabel, count] of perNs) nsCounts.set(nsLabel, (nsCounts.get(nsLabel) ?? 0) + count)
}
const namespaceShares = [...nsCounts.entries()]
.map(([name, count]) => ({ name, count }))
.filter(item => item.name)
.sort((a, b) => b.count - a.count)
setData({
trend: buildTrend(totalSeries, errorSeries, warnSeries),
namespaceShares,
apps,
appTimes: times.map(timeLabel),
stats: {
total: seriesTotal(totalSeries),
errorCount: seriesTotal(errorSeries),
warnCount: seriesTotal(warnSeries),
appCount: apps.length,
},
})
} catch (err) {
if (version !== versionRef.current) return
if ((err as Error).name === 'AbortError') return
setError(err instanceof Error ? err.message : 'metrics failed')
} finally {
if (version === versionRef.current) setLoading(false)
}
})()
return () => { controller.abort() }
}, [sourceId, start, end, namespace, appLabel, refreshTick])
return { data, loading, error, appLabel, refresh }
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { logsApi } from '@/api/logs'
export interface DailyAppRow {
app: string
namespace: string
/** 每个自然日(本地时区)的日志总量,与 days 一一对应 */
totals: number[]
total: number
}
export interface DailyVolumeData {
days: string[]
apps: DailyAppRow[]
}
const DAY_MS = 86400000
const RANGE_DAYS = 7
const ns = (ms: number) => Math.round(ms).toString() + '000000'
const dayLabel = (ts: number) => new Date(ts).toLocaleDateString([], { month: '2-digit', day: '2-digit' })
/**
* 应用每日日志量(近 7 天,Top 10)— 独立于页面时间范围筛选:
* 一条 `sum by (app, namespace)(count_over_time(sel[$step]))` 查询(7 天跨度后端自动选 1h 步长),
* 客户端把小时桶按本地日历日聚合,避免为每天单独发查询。
*/
export function useDailyVolume({ sourceId, namespace, appLabel }: {
sourceId: number
namespace: string
appLabel: string
}) {
const [data, setData] = useState<DailyVolumeData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [refreshTick, setRefreshTick] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const versionRef = useRef(0)
const refresh = useCallback(() => setRefreshTick(tick => tick + 1), [])
useEffect(() => {
if (!sourceId) return
const end = Date.now()
// 从今天往前取整 7 个自然日(首尾两天可能是不完整天)
const today = Math.floor(end / DAY_MS) * DAY_MS
const days = Array.from({ length: RANGE_DAYS }, (_, index) => today - (RANGE_DAYS - 1 - index) * DAY_MS)
const start = days[0]
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const version = ++versionRef.current
const selector = namespace ? `{namespace="${namespace}"}` : '{namespace=~".+"}'
void (async () => {
setLoading(true)
setError('')
try {
const series = await logsApi.metrics(
{ sourceId, start: Number(ns(start)), end: Number(ns(end)), query: `sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))` },
{ signal: controller.signal },
)
if (version !== versionRef.current) return
// 小时桶 → 本地日历日分桶(app × 天 累加,并记录各 app 落在哪个命名空间)
const perDay = days.map(() => new Map<string, { total: number; perNs: Map<string, number> }>())
const dayIndexOf = (timestamp: string) => {
const day = Math.floor(new Date(timestamp).getTime() / DAY_MS) * DAY_MS
return days.indexOf(day)
}
for (const item of series) {
const app = item.labels[appLabel] ?? ''
const nsLabel = item.labels.namespace ?? ''
for (const point of item.points) {
const index = dayIndexOf(point.timestamp)
if (index < 0) continue
const bucket = perDay[index]
const entry = bucket.get(app) ?? { total: 0, perNs: new Map<string, number>() }
entry.total += point.value
entry.perNs.set(nsLabel, (entry.perNs.get(nsLabel) ?? 0) + point.value)
bucket.set(app, entry)
}
}
const merged = new Map<string, { totals: number[]; total: number; perNs: Map<string, number> }>()
perDay.forEach((bucket, index) => {
for (const [app, entry] of bucket) {
const row = merged.get(app) ?? { totals: days.map(() => 0), total: 0, perNs: new Map<string, number>() }
row.totals[index] += entry.total
row.total += entry.total
for (const [nsName, count] of entry.perNs) row.perNs.set(nsName, (row.perNs.get(nsName) ?? 0) + count)
merged.set(app, row)
}
})
const apps: DailyAppRow[] = [...merged.entries()]
.map(([app, row]) => ({
app,
namespace: [...row.perNs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '',
totals: row.totals,
total: row.total,
}))
.filter(row => row.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 10)
setData({ days: days.map(dayLabel), apps })
} catch (err) {
if (version !== versionRef.current) return
if ((err as Error).name === 'AbortError') return
setError(err instanceof Error ? err.message : 'metrics failed')
} finally {
if (version === versionRef.current) setLoading(false)
}
})()
return () => { controller.abort() }
}, [sourceId, namespace, appLabel, refreshTick])
return { data, loading, error, refresh }
}