代理批量关联租户接口、关于页运行时与资源指标
CI / test (push) Successful in 31s
Release / release (push) Successful in 57s

This commit is contained in:
2026-07-13 12:23:18 +08:00
parent c7cc5616ed
commit 81d4650f3d
13 changed files with 434 additions and 8 deletions
+55 -5
View File
@@ -1,8 +1,11 @@
package api
import (
"math"
"net/http"
"os"
"runtime"
"time"
"github.com/gin-gonic/gin"
)
@@ -12,20 +15,67 @@ import (
var (
buildVersion = "dev"
buildTime = ""
// startedAt 为进程启动时刻,运行时长与 CPU 均值的计算基准
startedAt = time.Now()
// aboutDB 由 main 启动时注入,存储指标据此定位数据库文件
aboutDB struct{ driver, path string }
)
// SetAboutRuntime 注入数据库形态,「关于」页存储指标展示用;启动时调用一次。
func SetAboutRuntime(dbDriver, dbPath string) {
aboutDB.driver, aboutDB.path = dbDriver, dbPath
}
// about 返回构建与运行环境信息,「设置 · 关于」页展示。
//
// @Summary 返回构建运行环境信息,「设置 · 关于」页展示
// @Summary 返回构建运行时长与资源占用信息,「设置 · 关于」页展示
// @Tags 设置
// @Success 200 {object} map[string]any
// @Security BearerAuth
// @Router /api/v1/about [get]
func about(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": buildVersion,
"buildTime": buildTime,
"goVersion": runtime.Version(),
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"version": buildVersion,
"buildTime": buildTime,
"goVersion": runtime.Version(),
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"startedAt": startedAt.UTC().Format(time.RFC3339),
"uptimeSeconds": int64(time.Since(startedAt).Seconds()),
"resources": resourcesSnapshot(),
})
}
// resourcesSnapshot 采集进程资源占用:CPU 自启动均值、内存、goroutine 与存储。
func resourcesSnapshot() gin.H {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
pct := 0.0
if up := time.Since(startedAt).Seconds(); up > 0 {
// 多核并行时均值可超 100%,口径为「占单核百分比」
pct = processCPUTime().Seconds() / up * 100
}
return gin.H{
"cpuAvgPercent": math.Round(pct*100) / 100,
"numCpu": runtime.NumCPU(),
"goroutines": runtime.NumGoroutine(),
"memHeapBytes": ms.HeapAlloc,
"memSysBytes": ms.Sys,
"dbEngine": aboutDB.driver,
"dbBytes": dbFileBytes(aboutDB.driver, aboutDB.path),
}
}
// dbFileBytes 统计数据库磁盘占用:sqlite 累加主文件与 -wal / -shm,
// 外部数据库(mysql / postgres)不可从本机度量,返回 -1 表示不可用。
func dbFileBytes(driver, path string) int64 {
if driver != "sqlite" || path == "" {
return -1
}
var total int64
for _, p := range []string{path, path + "-wal", path + "-shm"} {
if fi, err := os.Stat(p); err == nil {
total += fi.Size()
}
}
return total
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !unix
package api
import "time"
// processCPUTime 非 unix 平台无 getrusage,CPU 均值退化为 0。
func processCPUTime() time.Duration { return 0 }
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"os"
"path/filepath"
"testing"
)
func TestDBFileBytes(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "app.db")
mustWrite := func(p string, n int) {
t.Helper()
if err := os.WriteFile(p, make([]byte, n), 0o600); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
mustWrite(dbPath, 100)
mustWrite(dbPath+"-wal", 30)
cases := []struct {
name string
driver string
path string
want int64
}{
{name: "sqlite 主文件加 wal", driver: "sqlite", path: dbPath, want: 130},
{name: "sqlite 文件不存在计 0", driver: "sqlite", path: filepath.Join(dir, "none.db"), want: 0},
{name: "外部数据库不可度量", driver: "postgres", path: "", want: -1},
{name: "路径为空不可度量", driver: "sqlite", path: "", want: -1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := dbFileBytes(tc.driver, tc.path); got != tc.want {
t.Fatalf("dbFileBytes(%q, %q) = %d, want %d", tc.driver, tc.path, got, tc.want)
}
})
}
}
func TestResourcesSnapshot(t *testing.T) {
SetAboutRuntime("sqlite", filepath.Join(t.TempDir(), "absent.db"))
got := resourcesSnapshot()
if got["numCpu"].(int) < 1 || got["goroutines"].(int) < 1 {
t.Fatalf("numCpu / goroutines 应为正数: %+v", got)
}
if got["memHeapBytes"].(uint64) == 0 || got["memSysBytes"].(uint64) == 0 {
t.Fatalf("内存指标应为正数: %+v", got)
}
if got["cpuAvgPercent"].(float64) < 0 {
t.Fatalf("cpuAvgPercent 不应为负: %+v", got)
}
if got["dbEngine"].(string) != "sqlite" || got["dbBytes"].(int64) != 0 {
t.Fatalf("存储指标不符: %+v", got)
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build unix
package api
import (
"syscall"
"time"
)
// processCPUTime 返回进程自启动累计的 CPU 时间(用户态 + 内核态);
// 取不到时返回 0,「关于」页 CPU 均值显示为 0 而非报错。
func processCPUTime() time.Duration {
var ru syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
return 0
}
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
}
+29
View File
@@ -144,6 +144,35 @@ func (h *proxyHandler) probe(c *gin.Context) {
c.JSON(http.StatusOK, view)
}
// setTenants 设置代理关联的租户全集(代理侧批量关联 / 解除)。
//
// @Summary 设置代理关联的租户全集
// @Tags 设置
// @Param id path int true "代理 ID"
// @Param body body object true "请求体 {\"ociConfigIds\": [1,2]}"
// @Success 200 {object} map[string]any "usedBy"
// @Security BearerAuth
// @Router /api/v1/proxies/{id}/tenants [put]
func (h *proxyHandler) setTenants(c *gin.Context) {
id, ok := pathID(c)
if !ok {
return
}
var req struct {
OciConfigIDs []uint `json:"ociConfigIds"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
used, err := h.svc.SetTenants(c.Request.Context(), id, req.OciConfigIDs)
if err != nil {
h.respond(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"usedBy": used})
}
// respond 把代理业务错误映射到语义状态码。
func (h *proxyHandler) respond(c *gin.Context, err error) {
switch {
+1
View File
@@ -35,4 +35,5 @@ func registerSettings(secured *gin.RouterGroup, settings *service.SettingService
secured.PUT("/proxies/:id", px.update)
secured.DELETE("/proxies/:id", px.remove)
secured.POST("/proxies/:id/probe", px.probe)
secured.PUT("/proxies/:id/tenants", px.setTenants)
}