feat(monitor): 平台自监控页(/monitor,管理员)
- 后端 /api/v1/system/metrics-summary(admin):从 Prometheus registry 聚合 HTTP 请求/状态码/Top 路由、DB 落盘与大小、告警评估、Loki 查询、runtime 快照 - 前端统计卡 + Top 路由表 + 分布明细(状态码/告警/Loki/落盘),中英文案齐全 - 菜单位于「日志分析」与「日志告警」之间
This commit is contained in:
1 parent
3ea7f2dcc6
commit
2bd77b57b4
7 files changed
+257
No files matched your search
@@ -15,6 +15,8 @@ import zhAnalysis from './locales/zh/analysis'
|
|||||||
import enAnalysis from './locales/en/analysis'
|
import enAnalysis from './locales/en/analysis'
|
||||||
import zhLogs from './locales/zh/logs'
|
import zhLogs from './locales/zh/logs'
|
||||||
import enLogs from './locales/en/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 zhSettings from './locales/zh/settings'
|
||||||
import enSettings from './locales/en/settings'
|
import enSettings from './locales/en/settings'
|
||||||
import zhAudit from './locales/zh/audit'
|
import zhAudit from './locales/zh/audit'
|
||||||
@@ -31,6 +33,8 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
|||||||
['en', 'analysis', enAnalysis],
|
['en', 'analysis', enAnalysis],
|
||||||
['zh', 'logs', zhLogs],
|
['zh', 'logs', zhLogs],
|
||||||
['en', 'logs', enLogs],
|
['en', 'logs', enLogs],
|
||||||
|
['zh', 'monitor', zhMonitor],
|
||||||
|
['en', 'monitor', enMonitor],
|
||||||
['zh', 'settings', zhSettings],
|
['zh', 'settings', zhSettings],
|
||||||
['en', 'settings', enSettings],
|
['en', 'settings', enSettings],
|
||||||
['zh', 'audit', zhAudit],
|
['zh', 'audit', zhAudit],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export default {
|
|||||||
logs: 'Log Query',
|
logs: 'Log Query',
|
||||||
alerts: 'Alerts',
|
alerts: 'Alerts',
|
||||||
analysis: 'Log Analysis',
|
analysis: 'Log Analysis',
|
||||||
|
monitor: 'Monitor',
|
||||||
settings: 'General',
|
settings: 'General',
|
||||||
ldap: 'LDAP',
|
ldap: 'LDAP',
|
||||||
registry: 'Image Registry',
|
registry: 'Image Registry',
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export default {
|
|||||||
logs: '日志查询',
|
logs: '日志查询',
|
||||||
alerts: '日志告警',
|
alerts: '日志告警',
|
||||||
analysis: '日志分析',
|
analysis: '日志分析',
|
||||||
|
monitor: '平台监控',
|
||||||
settings: '通用配置',
|
settings: '通用配置',
|
||||||
ldap: 'LDAP 接入',
|
ldap: 'LDAP 接入',
|
||||||
registry: '默认镜像源',
|
registry: '默认镜像源',
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
}
|
||||||
@@ -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<string, number>; inFlight: number; topPaths: PathCount[] }
|
||||||
|
db: { sizeBytes: number; saves: number; saveFailures: number; records: Record<string, number> }
|
||||||
|
alerts: { evaluations: Record<string, number> }
|
||||||
|
loki: { queries: Record<string, number> }
|
||||||
|
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<Summary | null>(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<Summary>('/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 (
|
||||||
|
<PageContainer title={t('title')}>
|
||||||
|
<EmptyState icon={Activity} title={t('adminOnly.title')} description={t('adminOnly.desc')} />
|
||||||
|
</PageContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageContainer
|
||||||
|
title={t('title')}
|
||||||
|
description={t('subtitle')}
|
||||||
|
action={
|
||||||
|
<Button variant="outline" onClick={load} disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <RefreshCw className="mr-1 size-4" />}
|
||||||
|
{t('refresh')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<ErrorState message={error} onRetry={load} />
|
||||||
|
) : loading && !data ? (
|
||||||
|
<LoadingSpinner className="h-64" />
|
||||||
|
) : !data ? null : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard label={t('stats.requests')} value={data.http.total.toLocaleString()} icon={Activity} iconColor="text-info" iconBg="bg-info/10" />
|
||||||
|
<StatCard label={t('stats.errors5xx')} value={err5xx.toLocaleString()} icon={Gauge} iconColor={err5xx > 0 ? 'text-destructive' : 'text-muted-foreground'} iconBg={err5xx > 0 ? 'bg-destructive/10' : 'bg-muted'} />
|
||||||
|
<StatCard label={t('stats.dbSize')} value={fmtBytes(data.db.sizeBytes)} icon={Database} iconColor="text-primary" iconBg="bg-primary/10" />
|
||||||
|
<StatCard label={t('stats.goroutines')} value={data.runtime.goroutines} icon={Gauge} iconColor="text-warning" iconBg="bg-warning/10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{t('topPaths.title')}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('topPaths.subtitle')}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{data.http.topPaths.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('empty')}</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="text-muted-foreground">
|
||||||
|
<tr><th className="border-b px-3 py-2 font-medium">{t('topPaths.path')}</th><th className="border-b px-3 py-2 font-medium text-right">{t('topPaths.count')}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.http.topPaths.map(item => (
|
||||||
|
<tr key={item.path} className="hover:bg-muted/40">
|
||||||
|
<td className="px-3 py-2 font-mono">{item.path}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{item.count.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{t('breakdown.title')}</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('breakdown.subtitle')}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 pt-0 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">{t('breakdown.byStatus')}</p>
|
||||||
|
{statusEntries.length === 0 ? <p className="text-muted-foreground">{t('empty')}</p> : statusEntries.map(([code, count]) => (
|
||||||
|
<div key={code} className="flex items-center justify-between py-0.5">
|
||||||
|
<span className={`font-mono ${code.startsWith('5') ? 'text-destructive' : code.startsWith('4') ? 'text-warning' : 'text-success'}`}>{code}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">{t('breakdown.alerts')}</p>
|
||||||
|
{Object.entries(data.alerts.evaluations).map(([result, count]) => (
|
||||||
|
<div key={result} className="flex items-center justify-between py-0.5">
|
||||||
|
<span className="text-muted-foreground">{t(`result.${result}`, result)}</span>
|
||||||
|
<span className="tabular-nums">{count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">{t('breakdown.loki')}</p>
|
||||||
|
{Object.entries(data.loki.queries).map(([result, count]) => (
|
||||||
|
<div key={result} className="flex items-center justify-between py-0.5">
|
||||||
|
<span className="text-muted-foreground">{t(`result.${result}`, result)}</span>
|
||||||
|
<span className="tabular-nums">{count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">{t('breakdown.db')}</p>
|
||||||
|
<div className="flex items-center justify-between py-0.5"><span className="text-muted-foreground">{t('breakdown.dbSaves')}</span><span className="tabular-nums">{data.db.saves.toLocaleString()}</span></div>
|
||||||
|
<div className="flex items-center justify-between py-0.5"><span className={data.db.saveFailures > 0 ? 'text-destructive' : 'text-muted-foreground'}>{t('breakdown.dbFailures')}</span><span className="tabular-nums">{data.db.saveFailures.toLocaleString()}</span></div>
|
||||||
|
</div>
|
||||||
|
<p className="border-t pt-3 text-muted-foreground">{t('buildInfo', { version: data.runtime.version || '-', go: data.runtime.goVersion || '-' })}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default MonitorPage
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ScrollText,
|
ScrollText,
|
||||||
BellRing,
|
BellRing,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Activity,
|
||||||
History,
|
History,
|
||||||
Boxes,
|
Boxes,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -38,6 +39,7 @@ const LogsPage = lazy(() => import('@/pages/logs'));
|
|||||||
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
||||||
const UsersPage = lazy(() => import('@/pages/users'));
|
const UsersPage = lazy(() => import('@/pages/users'));
|
||||||
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
||||||
|
const MonitorPage = lazy(() => import('@/pages/monitor'));
|
||||||
const AuditPage = lazy(() => import('@/pages/audit'));
|
const AuditPage = lazy(() => import('@/pages/audit'));
|
||||||
|
|
||||||
export const routes: AppRoute[] = [
|
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: '/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: '/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 } },
|
||||||
|
|
||||||
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
// ── 告警:独立入口(要盯的,不能被折叠) ──
|
||||||
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
// 告警仅管理员:普通用户按「查询授权」只看指定的日志(菜单只剩日志查询)。
|
||||||
|
|||||||
Reference in new issue
Block a user