Files
LokiStack/frontend/src/pages/monitor/index.tsx
T
opsx 2bd77b57b4 feat(monitor): 平台自监控页(/monitor,管理员)
- 后端 /api/v1/system/metrics-summary(admin):从 Prometheus registry 聚合
  HTTP 请求/状态码/Top 路由、DB 落盘与大小、告警评估、Loki 查询、runtime 快照
- 前端统计卡 + Top 路由表 + 分布明细(状态码/告警/Loki/落盘),中英文案齐全
- 菜单位于「日志分析」与「日志告警」之间
2026-10-29 14:04:25 +08:00

165 lines
8.3 KiB
TypeScript

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