接入运维真实 metrics:负载、请求错误率与服务探测

- 新增 GET /admin/ops/metrics 与进程内请求统计中间件
- 运维页展示 CPU/内存/磁盘、24h 曲线与 WS/DB/存储状态
- 去掉未使用的消息队列占位项
This commit is contained in:
yml2213
2026-07-15 15:01:18 +08:00
parent a1336c630b
commit 9251815695
14 changed files with 797 additions and 248 deletions
+280 -245
View File
@@ -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 小时示意错误率 05% */
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)}% · status400
</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>
+49
View File
@@ -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