接入运维真实 metrics:负载、请求错误率与服务探测
- 新增 GET /admin/ops/metrics 与进程内请求统计中间件 - 运维页展示 CPU/内存/磁盘、24h 曲线与 WS/DB/存储状态 - 去掉未使用的消息队列占位项
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ type ObjectStorage interface {
|
||||
PublicBase() string
|
||||
// EnsureBucket 开发环境自动建桶
|
||||
EnsureBucket(ctx context.Context) error
|
||||
// HealthCheck 探测存储是否可达(运维监控用)
|
||||
HealthCheck(ctx context.Context) error
|
||||
}
|
||||
|
||||
// PutResult 上传结果
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user