接入运维真实 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
+3
View File
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"kefu-sys/server/internal/config"
"kefu-sys/server/internal/handler"
"kefu-sys/server/internal/metrics"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
"kefu-sys/server/internal/storage"
@@ -43,6 +44,8 @@ func main() {
gin.SetMode(cfg.Server.Mode)
r := gin.Default()
// 全站请求统计(运维监控 24h 曲线)
r.Use(metrics.Middleware())
// CORS
r.Use(func(c *gin.Context) {
+8
View File
@@ -9,6 +9,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
github.com/minio/minio-go/v7 v7.2.1
github.com/shirou/gopsutil/v4 v4.25.1
golang.org/x/crypto v0.51.0
gorm.io/driver/postgres v1.5.9
gorm.io/driver/sqlite v1.5.7
@@ -22,8 +23,10 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
@@ -41,6 +44,7 @@ require (
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
@@ -49,11 +53,15 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect
+22
View File
@@ -18,12 +18,16 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -36,6 +40,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -72,6 +77,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
@@ -93,10 +100,14 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -111,10 +122,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
@@ -133,7 +150,11 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -141,6 +162,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+7 -2
View File
@@ -10,11 +10,16 @@ import (
"golang.org/x/crypto/bcrypt"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
"kefu-sys/server/internal/storage"
)
type AdminHandler struct{}
type AdminHandler struct {
store storage.ObjectStorage
}
func NewAdminHandler() *AdminHandler { return &AdminHandler{} }
func NewAdminHandler(store storage.ObjectStorage) *AdminHandler {
return &AdminHandler{store: store}
}
func (h *AdminHandler) Stats(c *gin.Context) {
var tenantTotal, activeTotal, suspendedTotal, expiringTotal, newThisMonth int64
+230
View File
@@ -0,0 +1,230 @@
package handler
import (
"context"
"fmt"
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/mem"
"kefu-sys/server/internal/metrics"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
"kefu-sys/server/internal/storage"
"kefu-sys/server/internal/ws"
)
// OpsMetrics 平台运维实时指标(当前 API 实例视角)。
// GET /api/admin/ops/metrics
func (h *AdminHandler) OpsMetrics(c *gin.Context) {
now := time.Now()
// —— 系统负载 ——
load := collectSystemLoad()
// —— 请求统计 ——
reqSeries, errSeries, hourLabels, winReq, winErr := metrics.Default.Snapshot24h()
totalReq, totalErr, total5xx, startedAt := metrics.Default.Totals()
var errNow float64
if winReq > 0 {
errNow = float64(winErr) / float64(winReq) * 100
}
var qps float64
uptimeSec := now.Sub(startedAt).Seconds()
if uptimeSec > 0 {
qps = float64(totalReq) / uptimeSec
}
// —— 服务探测 ——
services := collectServiceHealth(h.store)
// —— 整体状态 ——
overall := "normal"
for _, s := range services {
if st, _ := s["status"].(string); st == "error" {
overall = "error"
break
}
if st, _ := s["status"].(string); st == "warning" && overall == "normal" {
overall = "warning"
}
}
if load.CPUPercent >= 90 || load.MemPercent >= 90 || load.DiskPercent >= 95 {
if overall == "normal" {
overall = "warning"
}
}
if errNow >= 5 {
overall = "error"
} else if errNow >= 2 && overall == "normal" {
overall = "warning"
}
wsTotal, wsAgents, wsVisitors := ws.DefaultHub.Stats()
middleware.JSON(c, gin.H{
"overall": overall,
"updated_at": now.Format(time.RFC3339),
"instance": "api", // 当前进程实例
"uptime_sec": int64(uptimeSec),
"services": services,
"load": gin.H{
"cpu_percent": load.CPUPercent,
"mem_percent": load.MemPercent,
"disk_percent": load.DiskPercent,
"cpu_cores": load.CPUCores,
"mem_total_mb": load.MemTotalMB,
"mem_used_mb": load.MemUsedMB,
"disk_total_gb": load.DiskTotalGB,
"disk_used_gb": load.DiskUsedGB,
"go_goroutines": runtime.NumGoroutine(),
"go_heap_mb": load.GoHeapMB,
},
"api": gin.H{
"requests_last_24h": reqSeries,
"error_rate_last_24h": errSeries,
"hours": hourLabels,
"qps": round2(qps),
"error_rate_now": round2(errNow),
"window_requests": winReq,
"window_errors": winErr,
"total_requests": totalReq,
"total_errors": totalErr,
"total_5xx": total5xx,
"in_flight": metrics.Default.InFlight(),
},
"websocket": gin.H{
"connections": wsTotal,
"agents": wsAgents,
"visitors": wsVisitors,
},
})
}
type systemLoad struct {
CPUPercent float64
MemPercent float64
DiskPercent float64
CPUCores int
MemTotalMB uint64
MemUsedMB uint64
DiskTotalGB float64
DiskUsedGB float64
GoHeapMB float64
}
func collectSystemLoad() systemLoad {
out := systemLoad{
CPUCores: runtime.NumCPU(),
}
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
out.GoHeapMB = round2(float64(ms.HeapAlloc) / 1024 / 1024)
if percents, err := cpu.Percent(120*time.Millisecond, false); err == nil && len(percents) > 0 {
out.CPUPercent = round2(percents[0])
}
if vm, err := mem.VirtualMemory(); err == nil {
out.MemPercent = round2(vm.UsedPercent)
out.MemTotalMB = vm.Total / 1024 / 1024
out.MemUsedMB = vm.Used / 1024 / 1024
}
if du, err := disk.Usage("/"); err == nil {
out.DiskPercent = round2(du.UsedPercent)
out.DiskTotalGB = round2(float64(du.Total) / 1024 / 1024 / 1024)
out.DiskUsedGB = round2(float64(du.Used) / 1024 / 1024 / 1024)
}
return out
}
func collectServiceHealth(store storage.ObjectStorage) []gin.H {
services := make([]gin.H, 0, 5)
// API:本进程存活
services = append(services, gin.H{
"name": "API服务",
"status": "normal",
"detail": "进程运行中",
"latency_ms": 0,
})
// WebSocket Hub
wsTotal, wsAgents, wsVisitors := ws.DefaultHub.Stats()
services = append(services, gin.H{
"name": "WebSocket",
"status": "normal",
"detail": fmt.Sprintf("连接 %d(坐席 %d / 访客 %d", wsTotal, wsAgents, wsVisitors),
"connections": wsTotal,
})
// 数据库
dbStatus, dbDetail, dbLatency := probeDatabase()
services = append(services, gin.H{
"name": "数据库",
"status": dbStatus,
"detail": dbDetail,
"latency_ms": dbLatency,
})
// 对象存储
stStatus, stDetail, stLatency := probeStorage(store)
services = append(services, gin.H{
"name": "对象存储",
"status": stStatus,
"detail": stDetail,
"latency_ms": stLatency,
})
return services
}
func probeDatabase() (status, detail string, latencyMs int64) {
if model.DB == nil {
return "error", "数据库未初始化", 0
}
sqlDB, err := model.DB.DB()
if err != nil {
return "error", "获取连接失败: " + err.Error(), 0
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
if err := sqlDB.PingContext(ctx); err != nil {
return "error", "Ping 失败: " + err.Error(), time.Since(start).Milliseconds()
}
lat := time.Since(start).Milliseconds()
status = "normal"
detail = fmt.Sprintf("Ping 正常 · %dms", lat)
if lat >= 200 {
status = "warning"
detail = fmt.Sprintf("Ping 偏慢 · %dms", lat)
}
return status, detail, lat
}
func probeStorage(store storage.ObjectStorage) (status, detail string, latencyMs int64) {
if store == nil {
return "warning", "未配置对象存储", 0
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
start := time.Now()
if err := store.HealthCheck(ctx); err != nil {
return "error", "不可达: " + err.Error(), time.Since(start).Milliseconds()
}
lat := time.Since(start).Milliseconds()
status = "normal"
detail = fmt.Sprintf("Bucket 可达 · %dms", lat)
if lat >= 500 {
status = "warning"
detail = fmt.Sprintf("探测偏慢 · %dms", lat)
}
return status, detail, lat
}
func round2(v float64) float64 {
return float64(int(v*100+0.5)) / 100
}
+2 -1
View File
@@ -13,7 +13,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
customer := NewCustomerHandler()
knowledge := NewKnowledgeHandler()
stats := NewStatisticsHandler()
admin := NewAdminHandler()
admin := NewAdminHandler(store)
channel := NewChannelHandler()
settings := NewSettingsHandler()
staff := NewStaffHandler()
@@ -116,6 +116,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
adminGroup.Use(middleware.PlatformRequired())
{
adminGroup.GET("/stats", admin.Stats)
adminGroup.GET("/ops/metrics", admin.OpsMetrics)
adminGroup.GET("/tenants", admin.ListTenants)
adminGroup.GET("/tenants/:id", admin.GetTenant)
adminGroup.POST("/tenants", admin.CreateTenant)
+140
View File
@@ -0,0 +1,140 @@
package metrics
import (
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
)
// Collector 进程内请求统计:近 24 小时按整点小时桶累加。
type Collector struct {
mu sync.Mutex
byHour map[int64]*hourBucket
// 自进程启动以来的累计
totalReq atomic.Int64
totalErr atomic.Int64 // HTTP status >= 400
total5xx atomic.Int64
inFlight atomic.Int64
startedAt time.Time
}
type hourBucket struct {
requests int64
errors int64
status5 int64
}
// Default 全局采集器
var Default = NewCollector()
func NewCollector() *Collector {
return &Collector{
byHour: make(map[int64]*hourBucket),
startedAt: time.Now(),
}
}
func hourKey(t time.Time) int64 {
return t.Unix() / 3600
}
func (c *Collector) Record(status int) {
c.totalReq.Add(1)
isErr := status >= 400
is5 := status >= 500
if isErr {
c.totalErr.Add(1)
}
if is5 {
c.total5xx.Add(1)
}
h := hourKey(time.Now())
c.mu.Lock()
b := c.byHour[h]
if b == nil {
b = &hourBucket{}
c.byHour[h] = b
c.pruneLocked(h)
}
b.requests++
if isErr {
b.errors++
}
if is5 {
b.status5++
}
c.mu.Unlock()
}
func (c *Collector) pruneLocked(nowHour int64) {
cutoff := nowHour - 25
for k := range c.byHour {
if k < cutoff {
delete(c.byHour, k)
}
}
}
// Snapshot24h 返回近 24 个整点小时(含当前小时)的请求量与错误率(0–100)。
// 下标 0 为 23 小时前,23 为当前小时;hours 为当地小时 0–23。
func (c *Collector) Snapshot24h() (requests []int64, errorRates []float64, hours []int, totalReq, totalErr int64) {
now := time.Now()
nowH := hourKey(now)
loc := now.Location()
requests = make([]int64, 24)
errorRates = make([]float64, 24)
hours = make([]int, 24)
c.mu.Lock()
defer c.mu.Unlock()
for i := 0; i < 24; i++ {
hKey := nowH - int64(23-i)
t := time.Unix(hKey*3600, 0).In(loc)
hours[i] = t.Hour()
b := c.byHour[hKey]
var r, e int64
if b != nil {
r, e = b.requests, b.errors
}
requests[i] = r
if r > 0 {
errorRates[i] = float64(e) / float64(r) * 100
} else {
errorRates[i] = 0
}
totalReq += r
totalErr += e
}
return
}
func (c *Collector) Totals() (req, err4, err5 int64, startedAt time.Time) {
return c.totalReq.Load(), c.totalErr.Load(), c.total5xx.Load(), c.startedAt
}
func (c *Collector) InFlight() int64 {
return c.inFlight.Load()
}
// Middleware 记录每个 HTTP 请求的状态码(跳过 WebSocket 升级失败后的统计由 status 决定)。
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 健康检查可不计入业务错误曲线,但仍记请求量更直观;这里全部计入
Default.inFlight.Add(1)
c.Next()
Default.inFlight.Add(-1)
// WebSocket 升级成功后 status 可能是 101
status := c.Writer.Status()
if status == 0 {
status = 200
}
Default.Record(status)
}
}
+32
View File
@@ -0,0 +1,32 @@
package metrics
import (
"testing"
)
func TestRecordAndSnapshot(t *testing.T) {
c := NewCollector()
c.Record(200)
c.Record(200)
c.Record(500)
c.Record(404)
reqs, rates, hours, totalReq, totalErr := c.Snapshot24h()
if len(reqs) != 24 || len(rates) != 24 || len(hours) != 24 {
t.Fatalf("snapshot 长度应为 24: req=%d rates=%d hours=%d", len(reqs), len(rates), len(hours))
}
if totalReq != 4 || totalErr != 2 {
t.Fatalf("窗口统计错误: totalReq=%d totalErr=%d", totalReq, totalErr)
}
// 当前小时桶应有 4 请求
if reqs[23] != 4 {
t.Fatalf("当前小时请求量 = %d, 期望 4", reqs[23])
}
if rates[23] < 49 || rates[23] > 51 {
t.Fatalf("当前小时错误率 = %v, 期望约 50", rates[23])
}
tr, te, t5, _ := c.Totals()
if tr != 4 || te != 2 || t5 != 1 {
t.Fatalf("累计统计错误: req=%d err=%d 5xx=%d", tr, te, t5)
}
}
+2
View File
@@ -44,6 +44,8 @@ func (m *MemoryStorage) PublicBase() string { return m.base }
func (m *MemoryStorage) EnsureBucket(context.Context) error { return nil }
func (m *MemoryStorage) HealthCheck(context.Context) error { return nil }
func (m *MemoryStorage) Get(key string) ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
+5
View File
@@ -40,6 +40,11 @@ func NewS3(cfg config.StorageConfig) (*S3Storage, error) {
return &S3Storage{client: client, bucket: cfg.Bucket, base: base}, nil
}
func (s *S3Storage) HealthCheck(ctx context.Context) error {
_, err := s.client.BucketExists(ctx, s.bucket)
return err
}
func (s *S3Storage) EnsureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
+2
View File
@@ -17,6 +17,8 @@ type ObjectStorage interface {
PublicBase() string
// EnsureBucket 开发环境自动建桶
EnsureBucket(ctx context.Context) error
// HealthCheck 探测存储是否可达(运维监控用)
HealthCheck(ctx context.Context) error
}
// PutResult 上传结果
+15
View File
@@ -83,6 +83,21 @@ func (h *Hub) Run() {
}
}
// Stats 返回当前连接统计(总连接 / 坐席 / 访客)。
func (h *Hub) Stats() (total, agents, visitors int) {
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.clients {
total++
if client.Kind == "visitor" {
visitors++
} else {
agents++
}
}
return
}
func (h *Hub) send(client *Client, message []byte) {
select {
case client.Send <- message:
+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