diff --git a/frontend/src/i18n/domains.ts b/frontend/src/i18n/domains.ts index e5c2ca9..9fe491c 100644 --- a/frontend/src/i18n/domains.ts +++ b/frontend/src/i18n/domains.ts @@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis' import enAnalysis from './locales/en/analysis' import zhLogs from './locales/zh/logs' import enLogs from './locales/en/logs' +import zhMonitor from './locales/zh/monitor' +import enMonitor from './locales/en/monitor' import zhSettings from './locales/zh/settings' import enSettings from './locales/en/settings' import zhAudit from './locales/zh/audit' @@ -31,6 +33,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [ ['en', 'analysis', enAnalysis], ['zh', 'logs', zhLogs], ['en', 'logs', enLogs], + ['zh', 'monitor', zhMonitor], + ['en', 'monitor', enMonitor], ['zh', 'settings', zhSettings], ['en', 'settings', enSettings], ['zh', 'audit', zhAudit], diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 42538c1..75d5a0e 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -17,6 +17,7 @@ export default { logs: 'Log Query', alerts: 'Alerts', analysis: 'Log Analysis', + monitor: 'Monitor', settings: 'General', ldap: 'LDAP', registry: 'Image Registry', diff --git a/frontend/src/i18n/locales/en/monitor.ts b/frontend/src/i18n/locales/en/monitor.ts new file mode 100644 index 0000000..537fc33 --- /dev/null +++ b/frontend/src/i18n/locales/en/monitor.ts @@ -0,0 +1,42 @@ +// Platform self-monitoring — registered via i18n/domains.ts +export default { + title: 'Platform Monitor', + subtitle: 'Runtime metrics of the platform itself: requests, DB saves, alert engine and runtime (raw Prometheus metrics at /api/v1/metrics)', + adminOnly: { + title: 'Admin only', + desc: 'Runtime metrics are available to administrators.', + }, + refresh: 'Refresh', + loadFailed: 'Failed to load metrics', + empty: 'No data yet', + stats: { + requests: 'HTTP requests', + errors5xx: '5xx errors', + dbSize: 'Database size', + goroutines: 'Goroutines', + }, + topPaths: { + title: 'Top routes by requests', + subtitle: 'Cumulative requests grouped by route template', + path: 'Route', + count: 'Requests', + }, + breakdown: { + title: 'Breakdown', + subtitle: 'Status codes / alert evaluations / Loki queries / DB saves', + byStatus: 'HTTP status codes', + alerts: 'Alert rule evaluations', + loki: 'Queries against Loki', + db: 'DB saves', + dbSaves: 'Saves', + dbFailures: 'Save failures', + }, + result: { + success: 'Success', + error: 'Failed', + fired: 'Fired', + skipped: 'No match', + cooldown: 'Cooldown suppressed', + }, + buildInfo: 'Version {{version}} · Go {{go}}', +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 919c83b..17f542b 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -17,6 +17,7 @@ export default { logs: '日志查询', alerts: '日志告警', analysis: '日志分析', + monitor: '平台监控', settings: '通用配置', ldap: 'LDAP 接入', registry: '默认镜像源', diff --git a/frontend/src/i18n/locales/zh/monitor.ts b/frontend/src/i18n/locales/zh/monitor.ts new file mode 100644 index 0000000..29511ca --- /dev/null +++ b/frontend/src/i18n/locales/zh/monitor.ts @@ -0,0 +1,42 @@ +// 平台自监控页 — 经 i18n/domains.ts 注册 +export default { + title: '平台监控', + subtitle: '平台自身运行指标:请求、数据落盘、告警引擎与运行时(原始 Prometheus 指标见 /api/v1/metrics)', + adminOnly: { + title: '仅管理员可访问', + desc: '平台运行指标面向管理员开放。', + }, + refresh: '刷新', + loadFailed: '加载监控数据失败', + empty: '暂无数据', + stats: { + requests: 'HTTP 请求总数', + errors5xx: '5xx 错误数', + dbSize: '数据库大小', + goroutines: 'Goroutines', + }, + topPaths: { + title: '请求量 Top 路由', + subtitle: '按路由模板聚合的累计请求数', + path: '路由', + count: '请求数', + }, + breakdown: { + title: '分布明细', + subtitle: '状态码 / 告警评估 / Loki 查询 / 数据落盘', + byStatus: 'HTTP 状态码', + alerts: '告警规则评估', + loki: '对 Loki 的查询', + db: '数据落盘', + dbSaves: '落盘次数', + dbFailures: '落盘失败', + }, + result: { + success: '成功', + error: '失败', + fired: '已触发', + skipped: '未命中', + cooldown: '冷却抑制', + }, + buildInfo: '版本 {{version}} · Go {{go}}', +} diff --git a/frontend/src/pages/monitor/index.tsx b/frontend/src/pages/monitor/index.tsx new file mode 100644 index 0000000..4505501 --- /dev/null +++ b/frontend/src/pages/monitor/index.tsx @@ -0,0 +1,164 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import '@/i18n/domains' +import { Activity, Database, Gauge, Loader2, RefreshCw } 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 { http } from '@/api/http' +import { getUser } from '@/lib/auth-storage' + +interface PathCount { path: string; count: number } +interface Summary { + http: { total: number; byStatus: Record; inFlight: number; topPaths: PathCount[] } + db: { sizeBytes: number; saves: number; saveFailures: number; records: Record } + alerts: { evaluations: Record } + loki: { queries: Record } + runtime: { goroutines: number; goVersion: string; version: string } +} + +const fmtBytes = (n: number) => { + if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(2)} GB` + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB` + if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB` + return `${n} B` +} + +/** 平台自监控(admin):聚合 /metrics 为可读快照 — 请求/DB/告警引擎/运行时 */ +const MonitorPage = memo(function MonitorPage() { + const { t } = useTranslation('monitor') + const isAdmin = getUser()?.role === 'admin' + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const versionRef = useRef(0) + + const load = useCallback(() => { + const v = ++versionRef.current + setLoading(true) + setError('') + http.get('/system/metrics-summary') + .then(result => { if (v === versionRef.current) setData(result) }) + .catch(err => { if (v === versionRef.current) setError(err instanceof Error ? err.message : t('loadFailed')) }) + .finally(() => { if (v === versionRef.current) setLoading(false) }) + }, [t]) + + useEffect(() => { + if (isAdmin) load() + }, [isAdmin, load]) + + if (!isAdmin) { + return ( + + + + ) + } + + const statusEntries = data ? Object.entries(data.http.byStatus).sort(([a], [b]) => a.localeCompare(b)) : [] + const err5xx = statusEntries.filter(([code]) => code.startsWith('5')).reduce((acc, [, v]) => acc + v, 0) + + return ( + + {loading ? : } + {t('refresh')} + + } + > + {error ? ( + + ) : loading && !data ? ( + + ) : !data ? null : ( + <> +
+ + 0 ? 'text-destructive' : 'text-muted-foreground'} iconBg={err5xx > 0 ? 'bg-destructive/10' : 'bg-muted'} /> + + +
+ +
+ + + {t('topPaths.title')} +

{t('topPaths.subtitle')}

+
+ + {data.http.topPaths.length === 0 ? ( +

{t('empty')}

+ ) : ( + + + + + + {data.http.topPaths.map(item => ( + + + + + ))} + +
{t('topPaths.path')}{t('topPaths.count')}
{item.path}{item.count.toLocaleString()}
+ )} +
+
+ + + + {t('breakdown.title')} +

{t('breakdown.subtitle')}

+
+ +
+

{t('breakdown.byStatus')}

+ {statusEntries.length === 0 ?

{t('empty')}

: statusEntries.map(([code, count]) => ( +
+ {code} + {count.toLocaleString()} +
+ ))} +
+
+

{t('breakdown.alerts')}

+ {Object.entries(data.alerts.evaluations).map(([result, count]) => ( +
+ {t(`result.${result}`, result)} + {count.toLocaleString()} +
+ ))} +
+
+

{t('breakdown.loki')}

+ {Object.entries(data.loki.queries).map(([result, count]) => ( +
+ {t(`result.${result}`, result)} + {count.toLocaleString()} +
+ ))} +
+
+

{t('breakdown.db')}

+
{t('breakdown.dbSaves')}{data.db.saves.toLocaleString()}
+
0 ? 'text-destructive' : 'text-muted-foreground'}>{t('breakdown.dbFailures')}{data.db.saveFailures.toLocaleString()}
+
+

{t('buildInfo', { version: data.runtime.version || '-', go: data.runtime.goVersion || '-' })}

+
+
+
+ + )} +
+ ) +}) + +export default MonitorPage diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index 8233d87..f5cc1bb 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -15,6 +15,7 @@ import { ScrollText, BellRing, BarChart3, + Activity, History, Boxes, ShieldCheck, @@ -38,6 +39,7 @@ const LogsPage = lazy(() => import('@/pages/logs')); const AlertsPage = lazy(() => import('@/pages/alerts')); const UsersPage = lazy(() => import('@/pages/users')); const AnalysisPage = lazy(() => import('@/pages/analysis')); +const MonitorPage = lazy(() => import('@/pages/monitor')); const AuditPage = lazy(() => import('@/pages/audit')); export const routes: AppRoute[] = [ @@ -70,6 +72,7 @@ export const routes: AppRoute[] = [ // ── 日志查询 / 日志分析:高频入口,保持独立菜单项(不折叠)── { path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 5 } }, { path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 6, adminOnly: true } }, + { path: '/monitor', component: MonitorPage, auth: true, meta: { title: 'menu.monitor', icon: Activity, order: 6.5, adminOnly: true } }, // ── 告警:独立入口(要盯的,不能被折叠) ── // 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。