接入运维真实 metrics:负载、请求错误率与服务探测
- 新增 GET /admin/ops/metrics 与进程内请求统计中间件 - 运维页展示 CPU/内存/磁盘、24h 曲线与 WS/DB/存储状态 - 去掉未使用的消息队列占位项
This commit is contained in:
+280
-245
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm, Pagination,
|
||||
} from 'antd'
|
||||
@@ -8,20 +8,12 @@ import {
|
||||
NotificationOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
|
||||
type Announcement, type OperationLog,
|
||||
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, getOpsMetrics, updateAnnouncement,
|
||||
type Announcement, type OperationLog, type OpsMetrics, type OpsServiceStatus,
|
||||
} from '@/services/api'
|
||||
|
||||
type SvcStatus = 'normal' | 'warning' | 'error'
|
||||
|
||||
const services: { name: string; status: SvcStatus }[] = [
|
||||
{ name: 'API服务', status: 'normal' },
|
||||
{ name: 'WebSocket', status: 'normal' },
|
||||
{ name: '数据库', status: 'normal' },
|
||||
{ name: '消息队列', status: 'normal' },
|
||||
{ name: 'CDN', status: 'normal' },
|
||||
]
|
||||
|
||||
const actionMeta: Record<string, { text: string; bg: string; color: string }> = {
|
||||
create_tenant: { text: '开通租户', bg: '#dbeafe', color: '#2563eb' },
|
||||
suspend_tenant: { text: '暂停租户', bg: '#fef2f2', color: '#dc2626' },
|
||||
@@ -48,27 +40,14 @@ function formatRelative(d: Date) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** 近 24 小时示意请求量(工作时段高峰) */
|
||||
function genApiTrend(seed: number) {
|
||||
const bars: { hour: number; value: number }[] = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const work = h >= 8 && h <= 18
|
||||
const base = work ? 55 + Math.sin((h - 8) / 10 * Math.PI) * 40 : 8 + (h % 5) * 2
|
||||
const noise = ((seed * 17 + h * 31) % 15) - 7
|
||||
bars.push({ hour: h, value: Math.max(5, Math.round(base + noise)) })
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
/** 近 24 小时示意错误率 0–5% */
|
||||
function genErrorRate(seed: number) {
|
||||
const points: number[] = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const spike = h === 9 || h === 10 ? 2.2 : 0
|
||||
const v = 0.4 + spike + ((seed + h * 3) % 10) / 20
|
||||
points.push(Math.min(4.8, Math.max(0.1, Number(v.toFixed(2)))))
|
||||
}
|
||||
return points
|
||||
function formatUptime(sec: number) {
|
||||
if (!sec || sec < 0) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}天 ${h}小时`
|
||||
if (h > 0) return `${h}小时 ${m}分`
|
||||
return `${m} 分钟`
|
||||
}
|
||||
|
||||
function loadColor(p: number) {
|
||||
@@ -77,6 +56,16 @@ function loadColor(p: number) {
|
||||
return { text: '#16a34a', bar: '#16a34a' }
|
||||
}
|
||||
|
||||
function statusMeta(status: string): { label: string; color: string; bg: string; dot: string } {
|
||||
if (status === 'error') {
|
||||
return { label: '异常', color: '#dc2626', bg: '#fef2f2', dot: '#dc2626' }
|
||||
}
|
||||
if (status === 'warning') {
|
||||
return { label: '告警', color: '#d97706', bg: '#fffbeb', dot: '#d97706' }
|
||||
}
|
||||
return { label: '正常', color: '#16a34a', bg: '#f0fdf4', dot: '#16a34a' }
|
||||
}
|
||||
|
||||
const Ops = () => {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
@@ -85,25 +74,39 @@ const Ops = () => {
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([])
|
||||
const [loadingLogs, setLoadingLogs] = useState(false)
|
||||
const [loadingAnn, setLoadingAnn] = useState(false)
|
||||
const [loadingMetrics, setLoadingMetrics] = useState(false)
|
||||
const [metrics, setMetrics] = useState<OpsMetrics | null>(null)
|
||||
const [metricsError, setMetricsError] = useState('')
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Announcement | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [refreshedAt, setRefreshedAt] = useState(new Date())
|
||||
const [seed, setSeed] = useState(0)
|
||||
const [loads, setLoads] = useState({ cpu: 23, mem: 61, disk: 45 })
|
||||
|
||||
const apiTrend = useMemo(() => genApiTrend(seed), [seed])
|
||||
const errorRates = useMemo(() => genErrorRate(seed), [seed])
|
||||
const apiTrend = useMemo(() => {
|
||||
const series = metrics?.api?.requests_last_24h || []
|
||||
const hours = metrics?.api?.hours || []
|
||||
return series.map((value, i) => ({
|
||||
hour: hours[i] ?? i,
|
||||
value: Number(value) || 0,
|
||||
}))
|
||||
}, [metrics])
|
||||
|
||||
const errorRates = useMemo(
|
||||
() => (metrics?.api?.error_rate_last_24h || []).map(v => Number(v) || 0),
|
||||
[metrics],
|
||||
)
|
||||
|
||||
const maxApi = Math.max(...apiTrend.map(b => b.value), 1)
|
||||
const maxErr = 5
|
||||
const maxErr = Math.max(5, ...errorRates, 0.1)
|
||||
|
||||
const errorPolyline = useMemo(() => {
|
||||
const w = 200
|
||||
const h = 100
|
||||
if (errorRates.length === 0) return ''
|
||||
return errorRates
|
||||
.map((v, i) => {
|
||||
const x = (i / 23) * w
|
||||
const x = (i / Math.max(errorRates.length - 1, 1)) * w
|
||||
const y = h - (v / maxErr) * (h - 8) - 4
|
||||
return `${x},${y}`
|
||||
})
|
||||
@@ -113,6 +116,7 @@ const Ops = () => {
|
||||
const errorArea = useMemo(() => {
|
||||
const w = 200
|
||||
const h = 100
|
||||
if (!errorPolyline) return ''
|
||||
return `${errorPolyline} ${w},${h} 0,${h}`
|
||||
}, [errorPolyline])
|
||||
|
||||
@@ -141,10 +145,31 @@ const Ops = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadMetricsData = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoadingMetrics(true)
|
||||
try {
|
||||
const res = await getOpsMetrics()
|
||||
setMetrics(res.data)
|
||||
setMetricsError('')
|
||||
setRefreshedAt(new Date())
|
||||
} catch (e) {
|
||||
setMetricsError(e instanceof Error ? e.message : '加载 metrics 失败')
|
||||
} finally {
|
||||
if (!silent) setLoadingMetrics(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs(1)
|
||||
loadAnnouncements()
|
||||
}, [])
|
||||
void loadMetricsData()
|
||||
}, [loadMetricsData])
|
||||
|
||||
// 30s 自动刷新 metrics
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => { void loadMetricsData(true) }, 30000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loadMetricsData])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs(logPage)
|
||||
@@ -205,74 +230,125 @@ const Ops = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAll = () => {
|
||||
const s = seed + 1
|
||||
setSeed(s)
|
||||
setLoads({
|
||||
cpu: 18 + ((s * 11) % 40),
|
||||
mem: 40 + ((s * 7) % 35),
|
||||
disk: 35 + ((s * 5) % 30),
|
||||
})
|
||||
setRefreshedAt(new Date())
|
||||
loadLogs(logPage)
|
||||
loadAnnouncements()
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([loadMetricsData(), loadLogs(logPage), loadAnnouncements()])
|
||||
message.success('已刷新')
|
||||
}
|
||||
|
||||
const services: OpsServiceStatus[] = metrics?.services || []
|
||||
const load = metrics?.load
|
||||
const overall = statusMeta(metrics?.overall || 'normal')
|
||||
|
||||
const loadCards = [
|
||||
{ label: 'CPU', percent: loads.cpu, sub: '8核 / 使用中' },
|
||||
{ label: '内存', percent: loads.mem, sub: '16GB / 使用中' },
|
||||
{ label: '磁盘', percent: loads.disk, sub: '500GB / 使用中' },
|
||||
{
|
||||
label: 'CPU',
|
||||
percent: Math.round(load?.cpu_percent ?? 0),
|
||||
sub: load ? `${load.cpu_cores} 核 · 使用中` : '—',
|
||||
},
|
||||
{
|
||||
label: '内存',
|
||||
percent: Math.round(load?.mem_percent ?? 0),
|
||||
sub: load
|
||||
? `${load.mem_used_mb} / ${load.mem_total_mb} MB · Go堆 ${load.go_heap_mb}MB`
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
label: '磁盘',
|
||||
percent: Math.round(load?.disk_percent ?? 0),
|
||||
sub: load
|
||||
? `${load.disk_used_gb.toFixed(1)} / ${load.disk_total_gb.toFixed(1)} GB`
|
||||
: '—',
|
||||
},
|
||||
]
|
||||
|
||||
const peakErr = errorRates.length ? Math.max(...errorRates) : 0
|
||||
const errThresholdY = 100 - (2 / maxErr) * (100 - 8) - 4 // map 2% to viewBox y
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 页头:对齐设计 — 标题区 + 状态 */}
|
||||
<div className="shrink-0 px-6 pt-5 pb-4 bg-white border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-neutral-900 m-0 tracking-tight">系统运维</h1>
|
||||
<p className="text-sm text-neutral-500 m-0 mt-1">
|
||||
监控平台运行状态、查看操作日志、管理平台公告
|
||||
<span className="text-neutral-400"> · 指标来自当前 API 实例</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs bg-[#f0fdf4] text-[#16a34a] border border-[#bbf7d0]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16a34a]" />
|
||||
系统正常运行
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs border"
|
||||
style={{ background: overall.bg, color: overall.color, borderColor: `${overall.dot}33` }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: overall.dot }} />
|
||||
{metrics
|
||||
? (metrics.overall === 'normal' ? '系统正常运行' : metrics.overall === 'warning' ? '存在告警' : '存在异常')
|
||||
: '加载中…'}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">
|
||||
最后更新:{formatRelative(refreshedAt)}
|
||||
{metrics ? ` · 运行 ${formatUptime(metrics.uptime_sec)}` : ''}
|
||||
</span>
|
||||
<Button icon={<ReloadOutlined />} size="small" className="!h-8" onClick={refreshAll}>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={loadingMetrics} />}
|
||||
size="small"
|
||||
className="!h-8"
|
||||
onClick={() => { void refreshAll() }}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{metricsError && (
|
||||
<div className="mt-2 text-xs text-red-500">{metricsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
|
||||
{/* 1. 服务状态 + 系统负载 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<CloudServerOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">服务状态</span>
|
||||
{metrics?.websocket != null && (
|
||||
<span className="text-[11px] text-neutral-400 ml-2">
|
||||
WS 连接 {metrics.websocket.connections}
|
||||
(坐席 {metrics.websocket.agents} / 访客 {metrics.websocket.visitors})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 mb-5">
|
||||
{services.map(s => (
|
||||
<div key={s.name} className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-[#16a34a]" />
|
||||
<span className="text-sm text-neutral-700">{s.name}</span>
|
||||
<span className="text-xs text-[#16a34a]">正常</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loadingMetrics && !metrics ? (
|
||||
<div className="py-6 flex justify-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-3 mb-5">
|
||||
{(services.length ? services : [
|
||||
{ name: 'API服务', status: 'normal' as SvcStatus },
|
||||
]).map(s => {
|
||||
const m = statusMeta(s.status)
|
||||
return (
|
||||
<div key={s.name} className="flex items-center gap-2 min-w-[140px]" title={s.detail || ''}>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: m.dot }} />
|
||||
<span className="text-sm text-neutral-700">{s.name}</span>
|
||||
<span className="text-xs" style={{ color: m.color }}>{m.label}</span>
|
||||
{s.detail && (
|
||||
<span className="text-[11px] text-neutral-400 max-w-[180px] truncate">{s.detail}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<span className="font-semibold text-[15px] text-neutral-800">系统负载</span>
|
||||
<span className="text-[11px] text-neutral-400 ml-1">开发环境示意</span>
|
||||
<span className="text-[11px] text-neutral-400 ml-1">本机实时 · gopsutil</span>
|
||||
{metrics?.api && (
|
||||
<span className="text-[11px] text-neutral-400 ml-auto">
|
||||
QPS {metrics.api.qps.toFixed(2)} · 错误率 {metrics.api.error_rate_now.toFixed(2)}%
|
||||
· 在途 {metrics.api.in_flight}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{loadCards.map(item => {
|
||||
@@ -291,7 +367,7 @@ const Ops = () => {
|
||||
<div className="h-2 rounded-full bg-neutral-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${item.percent}%`, backgroundColor: c.bar }}
|
||||
style={{ width: `${Math.min(100, item.percent)}%`, backgroundColor: c.bar }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1.5">{item.sub}</p>
|
||||
@@ -301,7 +377,6 @@ const Ops = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 2. API 趋势 + 错误率 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -309,42 +384,48 @@ const Ops = () => {
|
||||
<CloudServerOutlined className="text-[#2563eb] shrink-0" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">API请求量趋势</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">近24小时 · 示意</span>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">
|
||||
近24小时 · 共 {metrics?.api?.window_requests ?? 0} 次
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<div className="absolute left-0 top-0 bottom-6 flex flex-col justify-between w-9 text-[10px] text-neutral-400">
|
||||
<span>高</span>
|
||||
<span>中</span>
|
||||
<span>{maxApi}</span>
|
||||
<span>{Math.round(maxApi / 2)}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
<div
|
||||
className="absolute left-10 right-0 top-0 bottom-6 flex items-end gap-0.5 border-b border-neutral-200"
|
||||
>
|
||||
{apiTrend.map(b => (
|
||||
{apiTrend.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-neutral-400">暂无数据</div>
|
||||
) : apiTrend.map((b, idx) => (
|
||||
<div
|
||||
key={b.hour}
|
||||
key={`${b.hour}-${idx}`}
|
||||
className="flex-1 flex flex-col justify-end h-full min-w-0"
|
||||
title={`${String(b.hour).padStart(2, '0')}:00 · 相对量 ${b.value}`}
|
||||
title={`${String(b.hour).padStart(2, '0')}:00 · ${b.value} 次`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-sm transition-all"
|
||||
style={{
|
||||
height: `${Math.max(4, (b.value / maxApi) * 100)}%`,
|
||||
height: `${Math.max(b.value > 0 ? 4 : 0, (b.value / maxApi) * 100)}%`,
|
||||
backgroundColor: b.value > maxApi * 0.55 ? '#2563eb' : '#93c5fd',
|
||||
minHeight: 2,
|
||||
minHeight: b.value > 0 ? 2 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-10 right-0 flex justify-between text-[10px] text-neutral-400 h-5 items-end">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
<span>−23h</span>
|
||||
<span>−12h</span>
|
||||
<span>现在</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1">
|
||||
自进程启动累计请求 {metrics?.api?.total_requests ?? 0} · 错误 {metrics?.api?.total_errors ?? 0}
|
||||
(5xx {metrics?.api?.total_5xx ?? 0})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
@@ -353,12 +434,14 @@ const Ops = () => {
|
||||
<AlertOutlined className="text-[#d97706] shrink-0" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">错误率监控</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">近24小时 · 示意</span>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">
|
||||
近24小时 · 当前 {metrics?.api?.error_rate_now?.toFixed(2) ?? '0.00'}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative" style={{ height: 160 }}>
|
||||
<div className="absolute left-0 top-0 bottom-6 flex flex-col justify-between w-9 text-[10px] text-neutral-400">
|
||||
<span>5%</span>
|
||||
<span>2.5%</span>
|
||||
<span>{maxErr.toFixed(1)}%</span>
|
||||
<span>{(maxErr / 2).toFixed(1)}%</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<svg
|
||||
@@ -367,214 +450,166 @@ const Ops = () => {
|
||||
viewBox="0 0 200 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* 阈值线 2% ≈ y=60 */}
|
||||
<line
|
||||
x1="0" y1="60" x2="200" y2="60"
|
||||
x1="0" y1={errThresholdY} x2="200" y2={errThresholdY}
|
||||
stroke="#d97706" strokeWidth="0.6" strokeDasharray="3,3" opacity="0.55"
|
||||
/>
|
||||
<polygon points={errorArea} fill="#dc2626" opacity="0.08" />
|
||||
<polyline
|
||||
points={errorPolyline}
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{errorArea && <polygon points={errorArea} fill="#dc2626" opacity="0.08" />}
|
||||
{errorPolyline && (
|
||||
<polyline
|
||||
points={errorPolyline}
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="absolute bottom-0 left-10 right-0 flex justify-between text-[10px] text-neutral-400 h-5 items-end">
|
||||
<span>00</span>
|
||||
<span>06</span>
|
||||
<span>12</span>
|
||||
<span>18</span>
|
||||
<span>23</span>
|
||||
<span>−23h</span>
|
||||
<span>−12h</span>
|
||||
<span>现在</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1">
|
||||
虚线为 2% 告警阈值 · 当前峰值约 {Math.max(...errorRates).toFixed(1)}%
|
||||
虚线为 2% 告警阈值 · 近24h 峰值 {peakErr.toFixed(2)}% · status≥400 计为错误
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3. 操作日志(全宽表格) */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-neutral-200 gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<FileTextOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">操作日志</span>
|
||||
<span className="text-xs text-neutral-400 ml-1">共 {logTotal} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center h-8 px-2.5 rounded-lg bg-neutral-100 border border-neutral-200">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索日志..."
|
||||
{/* 操作日志 + 公告(原有真实接口) */}
|
||||
<section className="grid grid-cols-1 xl:grid-cols-5 gap-5">
|
||||
<div className="xl:col-span-3 rounded-xl bg-white border border-neutral-200 p-5 shadow-sm min-h-[320px] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileTextOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">操作日志</span>
|
||||
</div>
|
||||
<Input.Search
|
||||
allowClear
|
||||
size="small"
|
||||
placeholder="搜索操作/详情"
|
||||
className="!w-48"
|
||||
value={logSearch}
|
||||
onChange={e => setLogSearch(e.target.value)}
|
||||
className="bg-transparent border-0 outline-none text-sm text-neutral-800 w-36 placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{loadingLogs ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Empty className="py-12" description="暂无操作日志" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<table className="w-full min-w-[720px] border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-neutral-50 text-[11px] font-semibold text-neutral-500">
|
||||
<th className="text-left px-5 py-2.5 border-b border-neutral-200 w-40">时间</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-28">操作</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">详情</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-32">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="flex-1 min-h-0">
|
||||
{loadingLogs ? (
|
||||
<div className="py-12 flex justify-center"><Spin /></div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<Empty description="暂无日志" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredLogs.map(log => {
|
||||
const meta = actionMeta[log.action] || {
|
||||
text: log.action, bg: '#f1f5f9', color: '#64748b',
|
||||
}
|
||||
const meta = actionMeta[log.action] || { text: log.action, bg: '#f1f5f9', color: '#64748b' }
|
||||
return (
|
||||
<tr key={log.id} className="hover:bg-neutral-50">
|
||||
<td className="px-5 py-3 border-b border-neutral-100 text-xs text-neutral-500 whitespace-nowrap tabular-nums">
|
||||
{formatTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100">
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium"
|
||||
style={{ backgroundColor: meta.bg, color: meta.color }}
|
||||
>
|
||||
{meta.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-sm text-neutral-700 max-w-md truncate">
|
||||
{log.detail || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-400 font-mono">
|
||||
{log.ip || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start gap-3 px-3 py-2.5 rounded-lg border border-neutral-100 bg-neutral-50/80"
|
||||
>
|
||||
<span
|
||||
className="shrink-0 text-[11px] px-2 py-0.5 rounded-md font-medium"
|
||||
style={{ background: meta.bg, color: meta.color }}
|
||||
>
|
||||
{meta.text}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-neutral-700 m-0 leading-snug break-words">{log.detail || '—'}</p>
|
||||
<p className="text-[11px] text-neutral-400 m-0 mt-1">
|
||||
操作者 #{log.operator_id}
|
||||
{log.ip ? ` · ${log.ip}` : ''}
|
||||
{' · '}
|
||||
{formatTime(log.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{logTotal > 10 && (
|
||||
<div className="px-5 py-3 border-t border-neutral-100 flex justify-end">
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Pagination
|
||||
size="small"
|
||||
current={logPage}
|
||||
total={logTotal}
|
||||
pageSize={10}
|
||||
onChange={p => setLogPage(p)}
|
||||
showSizeChanger={false}
|
||||
onChange={p => setLogPage(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 4. 平台公告 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<NotificationOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">平台公告</span>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !rounded-lg"
|
||||
onClick={openCreate}
|
||||
>
|
||||
发布公告
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loadingAnn ? (
|
||||
<div className="py-12 text-center"><Spin /></div>
|
||||
) : announcements.length === 0 ? (
|
||||
<Empty description="暂无公告" image={Empty.PRESENTED_IMAGE_SIMPLE}>
|
||||
<Button type="primary" onClick={openCreate}>写第一条公告</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{announcements.map(a => (
|
||||
<article
|
||||
key={a.id}
|
||||
className="rounded-xl border border-neutral-200 bg-neutral-50/40 p-4 flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 m-0 leading-snug line-clamp-2">
|
||||
{a.title}
|
||||
</h3>
|
||||
<span
|
||||
className={`shrink-0 text-[11px] font-medium px-2 py-0.5 rounded-full ${
|
||||
a.status === 'published'
|
||||
? 'bg-emerald-50 text-emerald-700'
|
||||
: 'bg-neutral-100 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{a.status === 'published' ? '已发布' : '草稿'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-neutral-500 m-0 line-clamp-2 flex-1 leading-relaxed">
|
||||
{a.content || '—'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-neutral-100">
|
||||
<span className="text-[11px] text-neutral-400">{formatTime(a.created_at)}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(a)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
<EditOutlined className="text-xs" />
|
||||
</button>
|
||||
<Popconfirm title="确认删除该公告?" onConfirm={() => handleDelete(a.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
<DeleteOutlined className="text-xs" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
|
||||
<div className="xl:col-span-2 rounded-xl bg-white border border-neutral-200 p-5 shadow-sm min-h-[320px] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NotificationOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">平台公告</span>
|
||||
</div>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{loadingAnn ? (
|
||||
<div className="py-12 flex justify-center"><Spin /></div>
|
||||
) : announcements.length === 0 ? (
|
||||
<Empty description="暂无公告" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{announcements.map(a => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="rounded-lg border border-neutral-100 bg-neutral-50/80 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-neutral-800 truncate">{a.title}</div>
|
||||
<div className="text-xs text-neutral-500 mt-0.5 line-clamp-2">{a.content}</div>
|
||||
<div className="text-[11px] text-neutral-400 mt-1">
|
||||
{a.status === 'published' ? '已发布' : '草稿'} · {formatTime(a.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(a)} />
|
||||
<Popconfirm title="确认删除该公告?" onConfirm={() => handleDelete(a.id)}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑公告' : '发布公告'}
|
||||
title={editing ? '编辑公告' : '新建公告'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} requiredMark={false} className="mt-1">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100 }]}>
|
||||
<Input maxLength={100} size="large" placeholder="公告标题" />
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-2">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input maxLength={100} placeholder="公告标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<Input.TextArea rows={5} maxLength={2000} showCount placeholder="公告正文" />
|
||||
<Input.TextArea rows={4} maxLength={2000} placeholder="公告正文" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select
|
||||
size="large"
|
||||
options={[
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'published', label: '立即发布' },
|
||||
{ value: 'published', label: '已发布' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -90,6 +90,55 @@ export interface AdminStats {
|
||||
recent_logs?: OperationLog[]
|
||||
}
|
||||
|
||||
/** 平台运维实时指标(当前 API 实例) */
|
||||
export interface OpsServiceStatus {
|
||||
name: string
|
||||
status: 'normal' | 'warning' | 'error' | string
|
||||
detail?: string
|
||||
latency_ms?: number
|
||||
connections?: number
|
||||
}
|
||||
|
||||
export interface OpsMetrics {
|
||||
overall: 'normal' | 'warning' | 'error' | string
|
||||
updated_at: string
|
||||
instance: string
|
||||
uptime_sec: number
|
||||
services: OpsServiceStatus[]
|
||||
load: {
|
||||
cpu_percent: number
|
||||
mem_percent: number
|
||||
disk_percent: number
|
||||
cpu_cores: number
|
||||
mem_total_mb: number
|
||||
mem_used_mb: number
|
||||
disk_total_gb: number
|
||||
disk_used_gb: number
|
||||
go_goroutines: number
|
||||
go_heap_mb: number
|
||||
}
|
||||
api: {
|
||||
requests_last_24h: number[]
|
||||
error_rate_last_24h: number[]
|
||||
hours: number[]
|
||||
qps: number
|
||||
error_rate_now: number
|
||||
window_requests: number
|
||||
window_errors: number
|
||||
total_requests: number
|
||||
total_errors: number
|
||||
total_5xx: number
|
||||
in_flight: number
|
||||
}
|
||||
websocket: {
|
||||
connections: number
|
||||
agents: number
|
||||
visitors: number
|
||||
}
|
||||
}
|
||||
|
||||
export const getOpsMetrics = () => get<OpsMetrics>('/admin/ops/metrics')
|
||||
|
||||
export interface StatisticsKpis {
|
||||
total_sessions: number
|
||||
avg_response_time: number
|
||||
|
||||
Reference in New Issue
Block a user