mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat(monitoring): add node/tunnel metrics, service monitors, and health checks
- Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods - Implement metrics ingestion service with per-minute bucket aggregation - Add health checker for node connectivity monitoring - Wire node metrics from WebSocket SystemInfo messages - Add tunnel metrics ingestion from flow upload endpoint - Create monitoring REST API endpoints for nodes, tunnels, services - Implement service monitor CRUD and execution (TCP/ICMP checks) - Add MonitorPermission for non-admin access control - Create frontend monitor page with node/tunnel/service views - Add tunnel metrics ingestion from agent flow reports - Include schema migration for tunnel_metric unique index - Fix tunnel entry port conflict validation to use transaction Entire-Checkpoint: 030821a7c8e3
This commit is contained in:
@@ -16,17 +16,21 @@ import (
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/health"
|
||||
"go-backend/internal/http/middleware"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/metrics"
|
||||
"go-backend/internal/security"
|
||||
"go-backend/internal/store/repo"
|
||||
"go-backend/internal/ws"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
repo *repo.Repository
|
||||
jwtSecret string
|
||||
wsServer *ws.Server
|
||||
metrics *metrics.IngestionService
|
||||
healthCheck *health.Checker
|
||||
|
||||
captchaMu sync.Mutex
|
||||
captchaTokens map[string]int64
|
||||
@@ -83,10 +87,31 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
repo: repo,
|
||||
jwtSecret: jwtSecret,
|
||||
wsServer: ws.NewServer(repo, jwtSecret),
|
||||
metrics: metrics.NewIngestionService(repo),
|
||||
healthCheck: nil,
|
||||
captchaTokens: make(map[string]int64),
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||
metricInfo := metrics.SystemInfo{
|
||||
Uptime: info.Uptime,
|
||||
BytesReceived: info.BytesReceived,
|
||||
BytesTransmitted: info.BytesTransmitted,
|
||||
CPUUsage: info.CPUUsage,
|
||||
MemoryUsage: info.MemoryUsage,
|
||||
DiskUsage: info.DiskUsage,
|
||||
Load1: info.Load1,
|
||||
Load5: info.Load5,
|
||||
Load15: info.Load15,
|
||||
TCPConns: info.TCPConns,
|
||||
UDPConns: info.UDPConns,
|
||||
NetInSpeed: info.NetInSpeed,
|
||||
NetOutSpeed: info.NetOutSpeed,
|
||||
}
|
||||
h.metrics.RecordNodeMetric(nodeID, metricInfo)
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -195,6 +220,23 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement)
|
||||
mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement)
|
||||
|
||||
mux.HandleFunc("/api/v1/monitor/access", h.monitorAccessHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
|
||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||
mux.HandleFunc("/api/v1/monitor/services/update", h.monitorServiceUpdate)
|
||||
mux.HandleFunc("/api/v1/monitor/services/delete", h.monitorServiceDelete)
|
||||
mux.HandleFunc("/api/v1/monitor/services/run", h.monitorServiceRun)
|
||||
mux.HandleFunc("/api/v1/monitor/services/latest-results", h.monitorServiceLatestResultsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/limits", h.monitorServiceLimitsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/", h.monitorServiceResultsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/list", h.monitorPermissionList)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/assign", h.monitorPermissionAssign)
|
||||
mux.HandleFunc("/api/v1/monitor/permission/remove", h.monitorPermissionRemove)
|
||||
|
||||
mux.HandleFunc("/flow/test", h.flowTest)
|
||||
mux.HandleFunc("/flow/config", h.flowConfig)
|
||||
mux.HandleFunc("/flow/upload", h.flowUpload)
|
||||
@@ -723,6 +765,8 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if err == nil && strings.TrimSpace(raw) != "" {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs)
|
||||
for _, item := range items {
|
||||
h.processFlowItem(node.ID, item)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(3)
|
||||
h.jobsWG.Add(5)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
go h.runDailyMaintenanceLoop(ctx)
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
go h.runMetricsIngestion(ctx)
|
||||
go h.runHealthChecks(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -47,6 +49,20 @@ func (h *Handler) StopBackgroundJobs() {
|
||||
h.jobsWG.Wait()
|
||||
}
|
||||
|
||||
func (h *Handler) runMetricsIngestion(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.metrics != nil {
|
||||
h.metrics.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.healthCheck != nil {
|
||||
h.healthCheck.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/monitoring"
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMetricsRangeMs = int64(60 * 60 * 1000) // 1h
|
||||
maxMetricsRangeMs = int64(24 * 60 * 60 * 1000) // 24h
|
||||
)
|
||||
|
||||
func (h *Handler) resolveServiceMonitorLimits() monitoring.ServiceMonitorLimits {
|
||||
defaults := monitoring.DefaultServiceMonitorLimits()
|
||||
if h == nil || h.repo == nil {
|
||||
return defaults
|
||||
}
|
||||
cfg, err := h.repo.GetConfigsByNames([]string{
|
||||
monitoring.ConfigServiceMonitorCheckerScanIntervalSec,
|
||||
monitoring.ConfigServiceMonitorWorkerLimit,
|
||||
monitoring.ConfigServiceMonitorMinIntervalSec,
|
||||
monitoring.ConfigServiceMonitorDefaultIntervalSec,
|
||||
monitoring.ConfigServiceMonitorMinTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorDefaultTimeoutSec,
|
||||
monitoring.ConfigServiceMonitorMaxTimeoutSec,
|
||||
})
|
||||
if err != nil {
|
||||
return defaults
|
||||
}
|
||||
return monitoring.ServiceMonitorLimitsFromConfigMap(cfg)
|
||||
}
|
||||
|
||||
func (h *Handler) monitorNodeMetricsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/nodes/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
if strings.HasSuffix(rest, "/metrics/latest") {
|
||||
h.handleNodeMetricsLatest(w, r, strings.TrimSuffix(rest, "/metrics/latest"))
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(rest, "/metrics") {
|
||||
h.handleNodeMetrics(w, r, strings.TrimSuffix(rest, "/metrics"))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
}
|
||||
|
||||
type monitorNodeListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := h.repo.ListMonitorNodes()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]monitorNodeListItem, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
updated := int64(0)
|
||||
if n.UpdatedTime.Valid {
|
||||
updated = n.UpdatedTime.Int64
|
||||
}
|
||||
items = append(items, monitorNodeListItem{
|
||||
ID: n.ID,
|
||||
Inx: n.Inx,
|
||||
Name: n.Name,
|
||||
Status: n.Status,
|
||||
UpdatedTime: updated,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
type monitorTunnelListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Inx int `json:"inx"`
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnels, err := h.repo.ListMonitorTunnels()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]monitorTunnelListItem, 0, len(tunnels))
|
||||
for _, t := range tunnels {
|
||||
items = append(items, monitorTunnelListItem{
|
||||
ID: t.ID,
|
||||
Inx: t.Inx,
|
||||
Name: t.Name,
|
||||
Status: t.Status,
|
||||
UpdatedTime: t.UpdatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) handleNodeMetrics(w http.ResponseWriter, r *http.Request, nodeIDStr string) {
|
||||
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
|
||||
if err != nil || nodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := h.repo.GetNodeMetrics(nodeID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metrics))
|
||||
}
|
||||
|
||||
func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request, nodeIDStr string) {
|
||||
nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64)
|
||||
if err != nil || nodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的节点ID"))
|
||||
return
|
||||
}
|
||||
|
||||
metric, err := h.repo.GetLatestNodeMetric(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if metric == nil {
|
||||
response.WriteJSON(w, response.OK(nil))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metric))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
metrics, err := h.repo.GetTunnelMetricsAggregated(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(metrics))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
monitors, err := h.repo.ListServiceMonitors()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(monitors))
|
||||
}
|
||||
|
||||
type createServiceMonitorRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
NodeID int64 `json:"nodeId"`
|
||||
Enabled *int `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req createServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("名称不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if monitorType != "tcp" && monitorType != "icmp" {
|
||||
response.WriteJSON(w, response.ErrDefault("类型必须是 tcp 或 icmp"))
|
||||
return
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("目标地址不能为空"))
|
||||
return
|
||||
}
|
||||
|
||||
limits := h.resolveServiceMonitorLimits()
|
||||
|
||||
intervalSec := req.IntervalSec
|
||||
if intervalSec <= 0 {
|
||||
intervalSec = limits.DefaultIntervalSec
|
||||
}
|
||||
if intervalSec < limits.MinIntervalSec {
|
||||
intervalSec = limits.MinIntervalSec
|
||||
}
|
||||
|
||||
timeoutSec := req.TimeoutSec
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = limits.DefaultTimeoutSec
|
||||
}
|
||||
if timeoutSec < limits.MinTimeoutSec {
|
||||
timeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if timeoutSec > limits.MaxTimeoutSec {
|
||||
timeoutSec = limits.MaxTimeoutSec
|
||||
}
|
||||
|
||||
enabled := 1
|
||||
if req.Enabled != nil {
|
||||
if *req.Enabled == 0 || *req.Enabled == 1 {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if req.NodeID > 0 {
|
||||
n, err := h.repo.GetNodeByID(req.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if n == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
}
|
||||
}
|
||||
m := &model.ServiceMonitor{
|
||||
Name: name,
|
||||
Type: monitorType,
|
||||
Target: target,
|
||||
IntervalSec: intervalSec,
|
||||
TimeoutSec: timeoutSec,
|
||||
NodeID: req.NodeID,
|
||||
Enabled: enabled,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if m.Type == "icmp" && m.NodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
|
||||
return
|
||||
}
|
||||
// enabled is already normalized above.
|
||||
|
||||
if err := h.repo.CreateServiceMonitor(m); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(m))
|
||||
}
|
||||
|
||||
type updateServiceMonitorRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
TimeoutSec int `json:"timeoutSec"`
|
||||
NodeID *int64 `json:"nodeId"`
|
||||
Enabled *int `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req updateServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.repo.GetServiceMonitor(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name != "" {
|
||||
existing.Name = name
|
||||
}
|
||||
|
||||
monitorType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
if monitorType == "tcp" || monitorType == "icmp" {
|
||||
existing.Type = monitorType
|
||||
}
|
||||
|
||||
target := strings.TrimSpace(req.Target)
|
||||
if target != "" {
|
||||
existing.Target = target
|
||||
}
|
||||
|
||||
limits := h.resolveServiceMonitorLimits()
|
||||
|
||||
if req.IntervalSec > 0 {
|
||||
intervalSec := req.IntervalSec
|
||||
if intervalSec < limits.MinIntervalSec {
|
||||
intervalSec = limits.MinIntervalSec
|
||||
}
|
||||
existing.IntervalSec = intervalSec
|
||||
}
|
||||
if req.TimeoutSec > 0 {
|
||||
timeoutSec := req.TimeoutSec
|
||||
if timeoutSec < limits.MinTimeoutSec {
|
||||
timeoutSec = limits.MinTimeoutSec
|
||||
}
|
||||
if timeoutSec > limits.MaxTimeoutSec {
|
||||
timeoutSec = limits.MaxTimeoutSec
|
||||
}
|
||||
existing.TimeoutSec = timeoutSec
|
||||
}
|
||||
|
||||
if req.NodeID != nil {
|
||||
existing.NodeID = *req.NodeID
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
if *req.Enabled == 0 || *req.Enabled == 1 {
|
||||
existing.Enabled = *req.Enabled
|
||||
}
|
||||
}
|
||||
|
||||
existing.UpdatedTime = time.Now().UnixMilli()
|
||||
if existing.Type == "icmp" && existing.NodeID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点"))
|
||||
return
|
||||
}
|
||||
if existing.NodeID > 0 {
|
||||
n, err := h.repo.GetNodeByID(existing.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if n == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("节点不存在"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.repo.UpdateServiceMonitor(existing); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(existing))
|
||||
}
|
||||
|
||||
type deleteServiceMonitorRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteServiceMonitor(req.ID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
if h.healthCheck == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控服务不可用"))
|
||||
return
|
||||
}
|
||||
|
||||
var req deleteServiceMonitorRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.repo.GetServiceMonitor(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if m == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("监控不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.healthCheck.RunOnce(m)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if err := h.repo.InsertServiceMonitorResult(res); err != nil {
|
||||
log.Printf("monitoring write failed op=service_monitor_result.manual_insert monitor_id=%d err=%v", res.MonitorID, err)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
monitorIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/services/", "/results")
|
||||
monitorID, err := strconv.ParseInt(monitorIDStr, 10, 64)
|
||||
if err != nil || monitorID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的监控ID"))
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
|
||||
limit = v
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.repo.GetServiceMonitorResults(monitorID, limit)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorServiceLimitsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(h.resolveServiceMonitorLimits()))
|
||||
}
|
||||
|
||||
func extractPathParam(path, prefix, suffix string) string {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
if suffix != "" {
|
||||
rest = strings.TrimSuffix(rest, suffix)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
type monitorAccessData struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// monitorAccessHandler is a lightweight capability check for frontend navigation.
|
||||
// It does NOT replace authorization on the actual monitoring endpoints.
|
||||
func (h *Handler) monitorAccessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return
|
||||
}
|
||||
if roleID == 0 {
|
||||
response.WriteJSON(w, response.OK(monitorAccessData{Allowed: true}))
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := h.repo.HasMonitorPermission(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
data := monitorAccessData{Allowed: allowed}
|
||||
if !allowed {
|
||||
data.Reason = "need_admin_grant"
|
||||
}
|
||||
response.WriteJSON(w, response.OK(data))
|
||||
}
|
||||
|
||||
func (h *Handler) ensureAdminAccess(w http.ResponseWriter, r *http.Request) bool {
|
||||
_, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return false
|
||||
}
|
||||
if roleID != 0 {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) ensureMonitoringAccess(w http.ResponseWriter, r *http.Request) bool {
|
||||
userID, roleID, err := userRoleFromRequest(r)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
|
||||
return false
|
||||
}
|
||||
if roleID == 0 {
|
||||
return true
|
||||
}
|
||||
allowed, err := h.repo.HasMonitorPermission(userID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return false
|
||||
}
|
||||
if !allowed {
|
||||
response.WriteJSON(w, response.Err(403, "权限不足:当前账户非管理员,且未被授予监控权限。请联系管理员在用户管理中授权监控权限。"))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.repo.ListMonitorPermissions()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
type monitorPermissionMutationRequest struct {
|
||||
UserID int64 `json:"userId"`
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionAssign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req monitorPermissionMutationRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.repo.GetUserByID(req.UserID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("用户不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.InsertMonitorPermission(req.UserID, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) monitorPermissionRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureAdminAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
var req monitorPermissionMutationRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if req.UserID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的用户ID"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.DeleteMonitorPermission(req.UserID); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
@@ -809,7 +809,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID)
|
||||
}
|
||||
}
|
||||
if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
|
||||
if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -997,8 +997,22 @@ func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
|
||||
if h == nil || h.repo == nil || tunnelID <= 0 {
|
||||
func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecord, port int, currentForwardID int64) error {
|
||||
if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if occupied {
|
||||
return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error {
|
||||
if h == nil || h.repo == nil || tx == nil || tunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1007,7 +1021,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
forwards, err := h.listForwardsByTunnel(tunnelID)
|
||||
forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID)
|
||||
if err != nil || len(forwards) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -1017,7 +1031,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
oldPorts, portsErr := h.listForwardPorts(f.ID)
|
||||
oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID)
|
||||
if portsErr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1027,14 +1041,11 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64,
|
||||
}
|
||||
|
||||
for _, nodeID := range addedNodeIDs {
|
||||
node, nodeErr := h.getNodeRecord(nodeID)
|
||||
node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID)
|
||||
if nodeErr != nil {
|
||||
continue
|
||||
}
|
||||
if err := validateLocalNodePort(node, port); err != nil {
|
||||
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
|
||||
}
|
||||
if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil {
|
||||
if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil {
|
||||
return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
type tunnelTrafficDelta struct {
|
||||
bytesIn int64
|
||||
bytesOut int64
|
||||
}
|
||||
|
||||
func unixMilliBucketMinute(nowMs int64) int64 {
|
||||
if nowMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
const minuteMs = int64(time.Minute / time.Millisecond)
|
||||
return nowMs - (nowMs % minuteMs)
|
||||
}
|
||||
|
||||
func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) {
|
||||
if h == nil || h.repo == nil {
|
||||
return
|
||||
}
|
||||
if nodeID <= 0 || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
bucketTs := unixMilliBucketMinute(nowMs)
|
||||
if bucketTs <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||
for _, item := range items {
|
||||
name := strings.TrimSpace(item.N)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if item.D == 0 && item.U == 0 {
|
||||
continue
|
||||
}
|
||||
d := forwardDeltas[forwardID]
|
||||
d.bytesIn += item.D
|
||||
d.bytesOut += item.U
|
||||
forwardDeltas[forwardID] = d
|
||||
}
|
||||
if len(forwardDeltas) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forwardIDs := make([]int64, 0, len(forwardDeltas))
|
||||
for id := range forwardDeltas {
|
||||
forwardIDs = append(forwardIDs, id)
|
||||
}
|
||||
|
||||
forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs)
|
||||
if err != nil {
|
||||
log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err)
|
||||
return
|
||||
}
|
||||
if len(forwardTunnelMap) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelAgg := make(map[int64]tunnelTrafficDelta)
|
||||
for forwardID, delta := range forwardDeltas {
|
||||
tunnelID := forwardTunnelMap[forwardID]
|
||||
if tunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
a := tunnelAgg[tunnelID]
|
||||
a.bytesIn += delta.bytesIn
|
||||
a.bytesOut += delta.bytesOut
|
||||
tunnelAgg[tunnelID] = a
|
||||
}
|
||||
if len(tunnelAgg) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg))
|
||||
for tunnelID, delta := range tunnelAgg {
|
||||
if delta.bytesIn == 0 && delta.bytesOut == 0 {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, &model.TunnelMetric{
|
||||
TunnelID: tunnelID,
|
||||
NodeID: nodeID,
|
||||
Timestamp: bucketTs,
|
||||
BytesIn: delta.bytesIn,
|
||||
BytesOut: delta.bytesOut,
|
||||
Connections: 0,
|
||||
Errors: 0,
|
||||
AvgLatencyMs: 0,
|
||||
})
|
||||
}
|
||||
if len(metrics) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
||||
log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,10 @@ func shouldSkip(path string) bool {
|
||||
}
|
||||
|
||||
func requiresAdmin(path string) bool {
|
||||
if strings.HasPrefix(path, "/api/v1/monitor/permission/") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/v1/group/") {
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user