diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index 163bcd2..3dab5fd 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -14,6 +14,11 @@ export interface LogEntry { timestamp: string; timestampNs: string; level: strin export interface HistogramBucket { timestamp: string; count: number } export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string } export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string } +/** 指标序列采样点(日志分析页) */ +export interface MetricPoint { timestamp: string; value: number } +/** 一条按标签分组的指标序列(如 sum by (app)(...) 中每个 app 一条) */ +export interface MetricSeries { labels: Record; points: MetricPoint[] } +export interface MetricQuery { sourceId: number; query: string; start: number; end: number } export const logsApi = { sources: () => http.get>('/logs/sources'), createSource: (source: Partial & { basicPass?: string; bearerToken?: string }) => http.post('/logs/sources', source), @@ -23,6 +28,8 @@ export const logsApi = { query: (data: LogQuery) => http.post('/logs/query', data, { timeout: 125000 }), context: (data: { sourceId: number; timestampNs: string; labels: Record }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data), histogram: (data: LogQuery) => http.post>>('/logs/histogram', data, { timeout: 125000 }), + /** LogQL 指标查询(admin):query 中的 [$step] 占位符由后端按时间跨度替换 */ + metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post('/logs/metrics', data, { timeout: 125000, ...opts }), labels: (sourceId: number, start?: number, end?: number) => http.get(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`), labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`), /** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */ diff --git a/frontend/src/pages/dashboard/components/StatCard.tsx b/frontend/src/components/StatCard.tsx similarity index 100% rename from frontend/src/pages/dashboard/components/StatCard.tsx rename to frontend/src/components/StatCard.tsx diff --git a/frontend/src/pages/logs/components/TimeRangePicker.tsx b/frontend/src/components/TimeRangePicker.tsx similarity index 89% rename from frontend/src/pages/logs/components/TimeRangePicker.tsx rename to frontend/src/components/TimeRangePicker.tsx index 4a795b3..cd138a7 100644 --- a/frontend/src/pages/logs/components/TimeRangePicker.tsx +++ b/frontend/src/components/TimeRangePicker.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { getUser } from '@/lib/auth-storage' -// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数 +// 共享时间范围选择器(原 logs 页私有,日志分析页复用后晋升;词典用 common.range) const presets = [ { unit: 'minutes', count: 5, ms: 300000 }, { unit: 'minutes', count: 15, ms: 900000 }, @@ -25,12 +25,11 @@ function readHistory(): SavedRange[] { } export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) { - const { t } = useTranslation('logs') + const { t } = useTranslation('common') const [open, setOpen] = useState(false) const [draftStart, setDraftStart] = useState(start) const [draftEnd, setDraftEnd] = useState(end) const [history, setHistory] = useState(readHistory) - const [selection, setSelection] = useState({ start, end, ms: 300000 as number | null }) const root = useRef(null) useEffect(() => { @@ -39,9 +38,8 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end: return () => document.removeEventListener('pointerdown', close) }, []) - function choose(nextStart: string, nextEnd: string, nextPreset: number | null = null) { + function choose(nextStart: string, nextEnd: string) { if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return - setSelection({ start: nextStart, end: nextEnd, ms: nextPreset }) onChange(nextStart, nextEnd) setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false) const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5) @@ -52,7 +50,9 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end: const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes' ? t('range.lastMinutes', { count: item.count }) : t('range.lastHours', { count: item.count }) - const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined + // 高亮预设按 start/end 实际跨度推导:外部传入的初始范围(如分析页默认 1 小时)也能正确标注, + // 不再依赖内部记录的 ms(旧逻辑初始误标「最近 5 分钟」) + const activePreset = presets.find(item => item.ms === new Date(end).getTime() - new Date(start).getTime()) const label = activePreset ? presetLabel(activePreset) : t('range.custom') const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}` @@ -62,7 +62,7 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end: {open &&

{t('range.title')}

-
{presets.map(item => )}
+
{presets.map(item => )}
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 5efd6df..db85a09 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -14,3 +14,5 @@ export { default as PasswordInput } from './PasswordInput'; export { default as StatusBadge, StatusIcon } from './StatusBadge'; export { getStatusColorClass } from './status-config'; export { default as ErrorBoundary } from './ErrorBoundary'; +export { TimeRangePicker } from './TimeRangePicker'; +export { StatCard } from './StatCard'; diff --git a/frontend/src/i18n/domains.ts b/frontend/src/i18n/domains.ts index 650f265..5f5d657 100644 --- a/frontend/src/i18n/domains.ts +++ b/frontend/src/i18n/domains.ts @@ -1,4 +1,4 @@ -// 页面域命名空间注册 — dashboard / users / alerts 三域词典在此挂载。 +// 页面域命名空间注册 — dashboard / users / alerts / analysis / logs 等域词典在此挂载。 // 约束:index.ts 只注册 common(由他人维护,不做改动);域词典通过本模块以 // addResourceBundle 挂载,由各域页面入口副作用 import 引入(页面为懒加载 chunk, // chunk 求值先于组件渲染,因此 useTranslation('') 时资源必然已就绪)。 @@ -11,6 +11,10 @@ import zhUsers from './locales/zh/users' import enUsers from './locales/en/users' import zhAlerts from './locales/zh/alerts' import enAlerts from './locales/en/alerts' +import zhAnalysis from './locales/zh/analysis' +import enAnalysis from './locales/en/analysis' +import zhLogs from './locales/zh/logs' +import enLogs from './locales/en/logs' const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [ ['zh', 'dashboard', zhDashboard], @@ -19,6 +23,10 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [ ['en', 'users', enUsers], ['zh', 'alerts', zhAlerts], ['en', 'alerts', enAlerts], + ['zh', 'analysis', zhAnalysis], + ['en', 'analysis', enAnalysis], + ['zh', 'logs', zhLogs], + ['en', 'logs', enLogs], ] for (const [lng, ns, resources] of DOMAIN_BUNDLES) { diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index f9781b7..b8a4dd0 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -54,19 +54,7 @@ export default { subtitle: 'Top apps by calendar day for the last 7 days — not controlled by the range filter above', empty: 'No app-dimension data in the last 7 days', }, - detail: { - title: 'Log detail visualization', - subtitle: 'Enter LogQL or use a preset to analyze up to 1000 sampled lines as table / line / stream / pie charts; ingress-nginx access logs are auto-detected for domain / status / path / upstream dimensions', - inputAria: 'LogQL detail query', - run: 'Run', - presetAll: 'All logs', - presetIngress: 'Ingress access', - presetError: 'Error logs', - viewAria: 'Visualization views', - view: { table: 'Table', line: 'Line', stream: 'Stream', pie: 'Pie' }, - hint: 'Enter a LogQL query or click a preset to visualize', - queryFailed: 'Query failed', - }, + table: { title: 'App volume ranking', subtitle: 'Top 10 apps by volume; click "View logs" to jump to the query page', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 0afefa6..c60e785 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -54,19 +54,7 @@ export default { subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响', empty: '近 7 天没有应用维度的数据', }, - detail: { - title: '日志明细可视化', - subtitle: '输入 LogQL 或点预设,以表格 / 折线图 / 流图 / 饼图分析最多 1000 条样本;ingress-nginx 访问日志自动识别域名 / 状态码 / 路径 / 上游维度', - inputAria: 'LogQL 明细查询', - run: '查询', - presetAll: '全部日志', - presetIngress: 'Ingress 访问', - presetError: '错误日志', - viewAria: '可视化视图', - view: { table: '表格', line: '折线图', stream: '流图', pie: '饼图' }, - hint: '输入 LogQL 或点击预设查询后展示可视化', - queryFailed: '查询失败', - }, + table: { title: '应用日志量排行', subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页', diff --git a/frontend/src/pages/logs/ingress-access-log.ts b/frontend/src/lib/ingress-access-log.ts similarity index 100% rename from frontend/src/pages/logs/ingress-access-log.ts rename to frontend/src/lib/ingress-access-log.ts diff --git a/frontend/src/pages/analysis/components/AppStreamChart.tsx b/frontend/src/pages/analysis/components/AppStreamChart.tsx new file mode 100644 index 0000000..2bce9ec --- /dev/null +++ b/frontend/src/pages/analysis/components/AppStreamChart.tsx @@ -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 + return
+ + + + + + + + {topApps.map((app, index) => { + const color = chartColors[index % chartColors.length] + return + })} + + +
+}) diff --git a/frontend/src/pages/analysis/components/DailyVolumeChart.tsx b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx new file mode 100644 index 0000000..1e82965 --- /dev/null +++ b/frontend/src/pages/analysis/components/DailyVolumeChart.tsx @@ -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 + return
+ + + + + + + + {topApps.map((app, index) => { + const color = chartColors[index % chartColors.length] + return + })} + + +
+}) diff --git a/frontend/src/pages/analysis/components/EmptyChart.tsx b/frontend/src/pages/analysis/components/EmptyChart.tsx new file mode 100644 index 0000000..04d03c8 --- /dev/null +++ b/frontend/src/pages/analysis/components/EmptyChart.tsx @@ -0,0 +1,3 @@ +export function EmptyChart({ message }: { message: string }) { + return
{message}
+} diff --git a/frontend/src/pages/analysis/components/NamespacePie.tsx b/frontend/src/pages/analysis/components/NamespacePie.tsx new file mode 100644 index 0000000..24055c2 --- /dev/null +++ b/frontend/src/pages/analysis/components/NamespacePie.tsx @@ -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 + return
+
+ + + + {pieData.map((item, index) => )} + + + + +
+
+ {pieData.map((item, index) => ( +
+ + {item.name} + {compactNumber(item.count)} +
+ ))} +
+
+}) diff --git a/frontend/src/pages/analysis/components/TopAppsTable.tsx b/frontend/src/pages/analysis/components/TopAppsTable.tsx new file mode 100644 index 0000000..e12434f --- /dev/null +++ b/frontend/src/pages/analysis/components/TopAppsTable.tsx @@ -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 + + +} + +/** 应用日志量排行表:哪个 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

{t('table.empty')}

+ } + const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel }) + return
+ + + + {[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 => ( + + ))} + + + + {apps.map((row, index) => { + const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}` + return + + + + + + + + + + + })} + +
{title}
{index + 1}{row.app || t('table.untagged')}{row.namespace || '—'}{row.total.toLocaleString()}{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'} 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{row.errorCount.toLocaleString()}{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'} + +
+
+}) diff --git a/frontend/src/pages/analysis/components/TrendChart.tsx b/frontend/src/pages/analysis/components/TrendChart.tsx new file mode 100644 index 0000000..df47c61 --- /dev/null +++ b/frontend/src/pages/analysis/components/TrendChart.tsx @@ -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 + 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
+ + + + + + + + {series.map(item => ( + + ))} + + +
+}) diff --git a/frontend/src/pages/analysis/components/chart-shared.ts b/frontend/src/pages/analysis/components/chart-shared.ts new file mode 100644 index 0000000..7f437e1 --- /dev/null +++ b/frontend/src/pages/analysis/components/chart-shared.ts @@ -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) +} diff --git a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts new file mode 100644 index 0000000..120d415 --- /dev/null +++ b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts @@ -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() + 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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [refreshTick, setRefreshTick] = useState(0) + const abortRef = useRef(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 }>() + 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() } + 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() + 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 } +} diff --git a/frontend/src/pages/analysis/hooks/useDailyVolume.ts b/frontend/src/pages/analysis/hooks/useDailyVolume.ts new file mode 100644 index 0000000..b1ffbcd --- /dev/null +++ b/frontend/src/pages/analysis/hooks/useDailyVolume.ts @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [refreshTick, setRefreshTick] = useState(0) + const abortRef = useRef(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 }>()) + 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() } + entry.total += point.value + entry.perNs.set(nsLabel, (entry.perNs.get(nsLabel) ?? 0) + point.value) + bucket.set(app, entry) + } + } + const merged = new Map }>() + perDay.forEach((bucket, index) => { + for (const [app, entry] of bucket) { + const row = merged.get(app) ?? { totals: days.map(() => 0), total: 0, perNs: new Map() } + 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 } +} diff --git a/frontend/src/pages/dashboard/index.tsx b/frontend/src/pages/dashboard/index.tsx index 6445752..f893ca6 100644 --- a/frontend/src/pages/dashboard/index.tsx +++ b/frontend/src/pages/dashboard/index.tsx @@ -10,7 +10,7 @@ import { PageContainer } from '@/components/PageContainer' import { LoadingSpinner } from '@/components/LoadingSpinner' import { ErrorState } from '@/components/ErrorState' import { useDashboardStats } from './hooks/useDashboardStats' -import { StatCard } from './components/StatCard' +import { StatCard } from '@/components/StatCard' import { RecentDeploymentsCard } from './components/RecentDeploymentsCard' import { ResourceListCard } from './components/ResourceListCard' import type { DashboardStats } from '@/types' diff --git a/frontend/src/pages/logs/components/IngressInsightsTable.tsx b/frontend/src/pages/logs/components/IngressInsightsTable.tsx deleted file mode 100644 index 811e1d6..0000000 --- a/frontend/src/pages/logs/components/IngressInsightsTable.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import type { IngressSample, IngressRouteStats } from '../ingress-insights' -import { summarizeIngress } from '../ingress-insights' - -type Mode = 'traffic' | 'errors' | 'slow' | 'detail' -type ErrorClass = 'all' | '5xx' | '4xx' -const modeIds: Mode[] = ['traffic', 'errors', 'slow', 'detail'] -const modeLabelKeys: Record = { - traffic: 'ingress.modeTraffic', - errors: 'ingress.modeErrors', - slow: 'ingress.modeSlow', - detail: 'ingress.modeDetail', -} -const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap' -const cell = 'px-3 py-2 tabular-nums' -const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s` -const routeKey = (route: IngressRouteStats) => `${route.domain}\u0000${route.path}` - -function Route({ route }: { route: IngressRouteStats }) { - return <>{route.domain} - {route.path} -} - -export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) { - const { t } = useTranslation('logs') - const [mode, setMode] = useState('traffic') - const [errorClass, setErrorClass] = useState('all') - const [slowThreshold, setSlowThreshold] = useState(1) - const summary = useMemo(() => summarizeIngress(samples, slowThreshold), [samples, slowThreshold]) - const traffic = useMemo(() => [...summary.routes].sort((a, b) => b.pv - a.pv || b.error5xx - a.error5xx).slice(0, 10), [summary.routes]) - const errors = useMemo(() => [...summary.routes] - .filter(route => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx) - .sort((a, b) => { - const count = (route: IngressRouteStats) => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx - return count(b) - count(a) || b.error5xx - a.error5xx - }).slice(0, 10), [summary.routes, errorClass]) - const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals) - .sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes]) - const modes = useMemo(() => modeIds.map(id => ({ id, label: t(modeLabelKeys[id]) })), [t]) - - return
-

{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}

-
- {[ - [t('ingress.statPv'), summary.pv], [t('ingress.statUv'), summary.uv], [t('ingress.statErrors'), summary.errors], [t('ingress.statSlow', { threshold: slowThreshold }), summary.slow], - ].map(([label, value]) =>
{label}
{value}
)} -
-
-
- {modes.map(item => )} -
- {mode === 'errors' && } - {mode === 'slow' && } -
- {mode === 'slow' &&

{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}

} - {mode === 'traffic' &&

{t('ingress.trafficHint')}

} -
- {mode === 'traffic' && {[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => )}{traffic.map(route => )}
{title}
{route.pv}{route.uv}{(route.success / route.pv * 100).toFixed(1)}%{route.error4xx}{route.error5xx}
} - {mode === 'errors' && {[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => )}{errors.map(route => )}
{title}
{route.error4xx}{route.error5xx}{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%{route.statusCodes}
} - {mode === 'slow' && {[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => )}{slow.map(route => )}
{title}
{route.slow}{formatSeconds(route.maxSeconds)}{formatSeconds(route.averageSeconds)}{route.timeoutSignals}
} - {mode === 'detail' && {[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => )}{samples.map(({ access, timestamp }, index) => )}
{title}
{new Date(timestamp).toLocaleString()}{access.domain || '—'}{access.method} {access.path}{access.clientIp || '—'}{access.status}{access.requestTime ? `${access.requestTime} s` : '—'}
} - {((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) &&

{t('ingress.empty')}

} -
-
-} diff --git a/frontend/src/pages/logs/components/LogResultViews.tsx b/frontend/src/pages/logs/components/LogResultViews.tsx deleted file mode 100644 index d0d8651..0000000 --- a/frontend/src/pages/logs/components/LogResultViews.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useMemo, useState } from 'react' -import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' -import { useTranslation } from 'react-i18next' -import type { LogEntry } from '@/api/logs' -import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log' -import { IngressInsightsTable } from './IngressInsightsTable' - -export type ResultView = 'raw' | 'table' | 'line' | 'stream' | 'pie' -type Bucket = { timestamp: string; count: number } -const colors = ['hsl(var(--info))', 'hsl(var(--success))', 'hsl(var(--warning))', 'hsl(var(--primary))', 'hsl(var(--destructive))', 'hsl(var(--muted-foreground))'] - -function EmptyChart({ message }: { message: string }) { - return
{message}
-} - -export function LogResultViews({ view, entries, buckets, start, end, domainFiltered = false }: { - view: Exclude - entries: LogEntry[] - buckets: Bucket[] - start: string - end: string - domainFiltered?: boolean -}) { - const { t } = useTranslation('logs') - // 聚合占位名(无域名/其他域名等):同时用作数据列名与图例展示,统一从词典取,保证语言切换后聚合与展示一致 - const noDomain = t('views.noDomain') - const otherDomain = t('views.otherDomain') - const unknownName = t('views.unknown') - const untaggedName = t('views.untagged') - const otherName = t('views.other') - const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain') - const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain') - const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain') - const parsedRows = useMemo(() => entries.map(entry => parseIngressAccessLog(entry.message)), [entries]) - const ingressCount = useMemo(() => parsedRows.filter(Boolean).length, [parsedRows]) - const ingressSamples = useMemo(() => entries.flatMap((entry, index) => { - const access = parsedRows[index] - return access ? [{ access, timestamp: entry.timestamp }] : [] - }), [entries, parsedRows]) - const streamDomains = useMemo(() => { - const counts = new Map() - for (const row of parsedRows) if (row) { - const domain = row.domain || noDomain - counts.set(domain, (counts.get(domain) ?? 0) + 1) - } - return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain) - }, [parsedRows, noDomain]) - const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy - const lineData = useMemo(() => { - if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count })) - const from = new Date(start).getTime(), to = new Date(end).getTime() - if (!(from < to)) return [] - const steps = 16 - const counts: Record[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: 0 })) - for (const [rowIndex, entry] of entries.entries()) { - const index = Math.floor((new Date(entry.timestamp).getTime() - from) / (to - from) * steps) - if (index < 0 || index >= steps) continue - if (lineBy === 'domain' && ingressCount) { - const row = parsedRows[rowIndex] - if (!row) continue - const domain = row.domain || noDomain - const key = streamDomains.includes(domain) ? domain : otherDomain - counts[index][key] = Number(counts[index][key] ?? 0) + 1 - } else counts[index].count = Number(counts[index].count) + 1 - } - return counts - }, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains, noDomain, otherDomain]) - const streamData = useMemo(() => { - const from = new Date(start).getTime(), to = new Date(end).getTime() - if (!(from < to)) return [] - const steps = 16 - const rows: Record[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), error: 0, warn: 0, info: 0, other: 0, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 })) - for (const [rowIndex, entry] of entries.entries()) { - const time = new Date(entry.timestamp).getTime() - const index = Math.floor((time - from) / (to - from) * steps) - if (index < 0 || index >= steps) continue - const ingress = parsedRows[rowIndex] - if (ingressCount) { - if (ingress) { - const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || noDomain) ? ingress.domain || noDomain : otherDomain) : httpStatusClass(ingress.status) - rows[index][key] = Number(rows[index][key] ?? 0) + 1 - } - continue - } - const level = entry.level.toLowerCase() - const key = level === 'error' || level === 'warn' || level === 'info' ? level : 'other' - rows[index][key] = Number(rows[index][key] ?? 0) + 1 - } - return rows - }, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end, noDomain, otherDomain]) - const pieData = useMemo(() => { - const counts = new Map() - for (const [index, entry] of entries.entries()) { - const ingress = parsedRows[index] - if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue - const name = effectiveGroupBy === 'domain' ? ingress?.domain || noDomain - : effectiveGroupBy === 'status' ? ingress?.status || unknownName - : effectiveGroupBy === 'method' ? ingress?.method || unknownName - : effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/') - : effectiveGroupBy === 'upstream' ? ingress?.upstreamName || untaggedName - : effectiveGroupBy === 'level' ? entry.level || 'unknown' - : entry.labels[effectiveGroupBy] || untaggedName - counts.set(name, (counts.get(name) ?? 0) + 1) - } - const sorted = [...counts].sort((a, b) => b[1] - a[1]) - const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count })) - const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0) - if (other) top.push({ name: otherName, count: other }) - return top - }, [entries, parsedRows, effectiveGroupBy, noDomain, unknownName, untaggedName, otherName]) - - if (view === 'table') return ingressCount > 0 - ? - :
-

{t('views.tableLoaded', { count: entries.length })}

- {entries.length === 0 ? :
{[t('views.thTime'), t('views.thNsPod'), t('views.thLevel'), t('views.thLog')].map(title => )}{entries.map((entry, index) => )}
{title}
{new Date(entry.timestamp).toLocaleString()}{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}{entry.level}{stripLogEnvelope(entry.message)}
} -
- - if (view === 'line') return
-

{(lineBy === 'domain' && ingressCount) || domainFiltered ? t('views.lineByLoaded') : t('views.lineByRange')}

{ingressCount > 0 && }
- {lineData.length === 0 ? :
{lineBy === 'domain' && ingressCount ? [...streamDomains, otherDomain].map((domain, index) => ) : }
} -
- - if (view === 'stream') return
-

{ingressCount ? t('views.streamParsed', { count: ingressCount }) : t('views.streamByLevel', { count: entries.length })}{t('views.streamSuffix')}

{ingressCount > 0 && }
- {entries.length === 0 ? :
{(ingressCount ? streamBy === 'domain' ? [...streamDomains, otherDomain] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => )}
} -
- - return
-

{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? t('views.pieByAccess', { count: ingressCount }) : t('views.pieByLabel', { count: entries.length })}

- {pieData.length === 0 ? :
{pieData.map((item, index) => )}
{pieData.map((item, index) =>
{item.name}{item.count}
)}
} -
-} diff --git a/frontend/src/pages/logs/ingress-insights.ts b/frontend/src/pages/logs/ingress-insights.ts deleted file mode 100644 index 48f2ba6..0000000 --- a/frontend/src/pages/logs/ingress-insights.ts +++ /dev/null @@ -1,78 +0,0 @@ -import i18n from '@/i18n' -import type { IngressAccessLog } from './ingress-access-log' -import { pathWithoutQuery } from './ingress-access-log' - -export interface IngressSample { - access: IngressAccessLog - timestamp: string -} - -export interface IngressRouteStats { - domain: string - path: string - pv: number - uv: number - success: number - error4xx: number - error5xx: number - slow: number - timeoutSignals: number - averageSeconds: number | null - maxSeconds: number | null - statusCodes: string -} - -export function summarizeIngress(samples: IngressSample[], slowThreshold: number): { - routes: IngressRouteStats[] - pv: number - uv: number - errors: number - slow: number - timed: number -} { - const routes = new Map; seconds: number[]; statuses: Map - }>() - const allIps = new Set() - let errors = 0, slow = 0, timed = 0 - for (const { access } of samples) { - const domain = access.domain || i18n.t('ingress.noDomain', { ns: 'logs' }) - const path = pathWithoutQuery(access.path) - const key = `${domain}\u0000${path}` - let route = routes.get(key) - if (!route) { - route = { domain, path, pv: 0, success: 0, error4xx: 0, error5xx: 0, slow: 0, - timeoutSignals: 0, ips: new Set(), seconds: [], statuses: new Map() } - routes.set(key, route) - } - route.pv++ - if (access.clientIp && access.clientIp !== '-') { - route.ips.add(access.clientIp) - allIps.add(access.clientIp) - } - route.statuses.set(access.status, (route.statuses.get(access.status) ?? 0) + 1) - const status = Number(access.status) - if (status >= 200 && status < 400) route.success++ - if (status >= 400 && status < 500) { route.error4xx++; errors++ } - if (status >= 500 && status < 600) { route.error5xx++; errors++ } - if (status === 408 || status === 499 || status === 504) route.timeoutSignals++ - const seconds = Number(access.requestTime) - if (access.requestTime.trim() && Number.isFinite(seconds) && seconds >= 0) { - route.seconds.push(seconds) - timed++ - if (seconds >= slowThreshold) { route.slow++; slow++ } - } - } - return { - routes: [...routes.values()].map(route => ({ - domain: route.domain, path: route.path, pv: route.pv, uv: route.ips.size, - success: route.success, error4xx: route.error4xx, error5xx: route.error5xx, - slow: route.slow, timeoutSignals: route.timeoutSignals, - averageSeconds: route.seconds.length ? route.seconds.reduce((sum, value) => sum + value, 0) / route.seconds.length : null, - maxSeconds: route.seconds.length ? Math.max(...route.seconds) : null, - statusCodes: [...route.statuses].filter(([code]) => Number(code) >= 400).sort((a, b) => Number(a[0]) - Number(b[0])).map(([code, count]) => `${code}×${count}`).join(' · '), - })), - pv: samples.length, uv: allIps.size, errors, slow, timed, - } -} diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index 3659d75..8120573 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -14,6 +14,7 @@ import { Wand2, ScrollText, BellRing, + BarChart3, } from 'lucide-react'; import type { AppRoute, MenuItem, RouteMeta } from '@/types/router'; @@ -30,6 +31,7 @@ const WizardPage = lazy(() => import('@/pages/wizard')); const LogsPage = lazy(() => import('@/pages/logs')); const AlertsPage = lazy(() => import('@/pages/alerts')); const UsersPage = lazy(() => import('@/pages/users')); +const AnalysisPage = lazy(() => import('@/pages/analysis')); export const routes: AppRoute[] = [ { path: '/login', component: Login, auth: false, meta: { title: 'menu.login', hideInMenu: true } }, @@ -41,7 +43,8 @@ export const routes: AppRoute[] = [ { path: '/deployments', component: Deployments, auth: true, meta: { title: 'menu.deployments', icon: Rocket, order: 6, adminOnly: true } }, { path: '/collectors', component: Collectors, auth: true, meta: { title: 'menu.collectors', icon: RadioTower, order: 7, adminOnly: true } }, { path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 8 } }, - { path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 9 } }, + { path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 9, adminOnly: true } }, + { path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 10 } }, { path: '/settings', component: SettingsPage, auth: true, meta: { title: 'menu.settings', icon: Settings, order: 12, adminOnly: true } }, { path: '/users', component: UsersPage, auth: true, meta: { title: 'menu.users', icon: UsersRound, order: 11, adminOnly: true } }, { path: '*', component: NotFound, auth: false, meta: { title: 'menu.notFound', hideInMenu: true } },