195 lines
5.6 KiB
Go
195 lines
5.6 KiB
Go
// Package monitor 周期巡检「资源占用」与「隧道连通性」这两类必须持续观察才会出现的问题。
|
|||
|
|
// 事件型通知(DDNS 失败、证书到期)由各自模块在发生时直接发出,不需要巡检。
|
||
|
|
//
|
||
|
|
// 判定在这里,通知只走 notify.Service:开关、阈值、冷却都读设置项,
|
||
|
|
// 所以本包不需要知道通知通道的任何细节。
|
||
|
|
package monitor
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/fonu/fonu/internal/notify"
|
||
|
|
"github.com/fonu/fonu/internal/settings"
|
||
|
|
"github.com/fonu/fonu/internal/sysinfo"
|
||
|
|
)
|
||
|
|
|
||
|
|
// RouteStatus 是巡检关心的隧道状态;由调用方注入(避免 monitor 依赖 frp 包)。
|
||
|
|
type RouteStatus struct {
|
||
|
|
Domain string
|
||
|
|
TunnelOK bool
|
||
|
|
Detail string
|
||
|
|
}
|
||
|
|
|
||
|
|
// Checker 每轮都重新读设置项:改阈值或开关不需要重启。
|
||
|
|
type Checker struct {
|
||
|
|
settings *settings.Store
|
||
|
|
notify *notify.Service
|
||
|
|
logger *slog.Logger
|
||
|
|
routes func(ctx context.Context) ([]RouteStatus, error)
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
cpuPrev sysinfo.CPUSample
|
||
|
|
resourceAlerts map[string]time.Time
|
||
|
|
tunnelFailures map[string]int
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, routes func(context.Context) ([]RouteStatus, error)) *Checker {
|
||
|
|
if logger == nil {
|
||
|
|
logger = slog.Default()
|
||
|
|
}
|
||
|
|
return &Checker{
|
||
|
|
settings: settingsStore,
|
||
|
|
notify: notifySvc,
|
||
|
|
logger: logger,
|
||
|
|
routes: routes,
|
||
|
|
resourceAlerts: map[string]time.Time{},
|
||
|
|
tunnelFailures: map[string]int{},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Run 跑一轮巡检。scheduler 已对任务包了 recover,这里不重复兜底。
|
||
|
|
func (c *Checker) Run(ctx context.Context) {
|
||
|
|
if c == nil || c.settings == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.checkResources(ctx)
|
||
|
|
c.checkTunnels(ctx)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Checker) checkResources(ctx context.Context) {
|
||
|
|
// 开关关闭时连采集都省掉(关掉的功能不该有后台开销)
|
||
|
|
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold); !enabled {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
cpuLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyCPUThreshold)
|
||
|
|
memoryLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyMemoryThreshold)
|
||
|
|
diskLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyDiskThreshold)
|
||
|
|
|
||
|
|
c.mu.Lock()
|
||
|
|
stats, sample := sysinfo.Read(c.cpuPrev)
|
||
|
|
c.cpuPrev = sample
|
||
|
|
c.mu.Unlock()
|
||
|
|
|
||
|
|
limits := []struct {
|
||
|
|
metric string
|
||
|
|
label string
|
||
|
|
limit int
|
||
|
|
value float64
|
||
|
|
}{
|
||
|
|
{"cpu", "CPU", cpuLimit, stats.CPUPercent},
|
||
|
|
{"memory", "内存", memoryLimit, percent(stats.MemUsedBytes, stats.MemTotalBytes)},
|
||
|
|
{"disk", "磁盘", diskLimit, percent(stats.DiskUsedBytes, stats.DiskTotalBytes)},
|
||
|
|
}
|
||
|
|
now := time.Now()
|
||
|
|
for _, item := range limits {
|
||
|
|
// 阈值 <= 0 表示这一项不告警(CPU 首次采样时占用率为 0,也不会误报)
|
||
|
|
if item.limit <= 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if item.value < float64(item.limit) {
|
||
|
|
// 回落到阈值以下就清冷却:再次超标可以立刻提醒
|
||
|
|
c.mu.Lock()
|
||
|
|
delete(c.resourceAlerts, item.metric)
|
||
|
|
c.mu.Unlock()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if !c.allowResourceAlert(item.metric, now) {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
c.notify.Alert(ctx, notify.EventResourceThreshold, "资源占用过高",
|
||
|
|
fmt.Sprintf("%s 使用率 %.1f%%,已超过阈值 %d%%", item.label, item.value, item.limit))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// allowResourceAlert 按静默期限流:持续高占用每 DefaultResourceAlertCooldown 提醒一次。
|
||
|
|
func (c *Checker) allowResourceAlert(metric string, now time.Time) bool {
|
||
|
|
cooldown := time.Duration(notify.DefaultResourceAlertCooldown) * time.Second
|
||
|
|
c.mu.Lock()
|
||
|
|
defer c.mu.Unlock()
|
||
|
|
if last, ok := c.resourceAlerts[metric]; ok && now.Sub(last) < cooldown {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
c.resourceAlerts[metric] = now
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Checker) checkTunnels(ctx context.Context) {
|
||
|
|
if c.routes == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown); !enabled {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
|
||
|
|
if limit <= 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
routes, err := c.routes(ctx)
|
||
|
|
if err != nil {
|
||
|
|
c.logger.Warn("巡检隧道状态失败", "module", "MONITOR", "error", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, route := range routes {
|
||
|
|
if strings.TrimSpace(route.Domain) == "" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
c.mu.Lock()
|
||
|
|
failures, event := tunnelTransition(c.tunnelFailures[route.Domain], route.TunnelOK, limit)
|
||
|
|
c.tunnelFailures[route.Domain] = failures
|
||
|
|
c.mu.Unlock()
|
||
|
|
|
||
|
|
switch event {
|
||
|
|
case tunnelEventDown:
|
||
|
|
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道连续不通",
|
||
|
|
fmt.Sprintf("%s 连续 %d 次巡检未在 frps 上注册:%s", route.Domain, limit, detailOr(route.Detail)))
|
||
|
|
case tunnelEventRecovered:
|
||
|
|
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道已恢复",
|
||
|
|
fmt.Sprintf("%s 已重新在 frps 上注册", route.Domain))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type tunnelEvent string
|
||
|
|
|
||
|
|
const (
|
||
|
|
tunnelEventNone tunnelEvent = ""
|
||
|
|
tunnelEventDown tunnelEvent = "down"
|
||
|
|
tunnelEventRecovered tunnelEvent = "recovered"
|
||
|
|
)
|
||
|
|
|
||
|
|
// tunnelTransition 推进某个域名的连续失败计数,返回新计数与本轮要发的事件。
|
||
|
|
// 只在「刚好达到阈值」与「从失败中恢复」两个时刻发,避免每轮巡检都刷通知。
|
||
|
|
func tunnelTransition(previous int, tunnelOK bool, limit int) (int, tunnelEvent) {
|
||
|
|
if tunnelOK {
|
||
|
|
if previous >= limit {
|
||
|
|
return 0, tunnelEventRecovered
|
||
|
|
}
|
||
|
|
return 0, tunnelEventNone
|
||
|
|
}
|
||
|
|
next := previous + 1
|
||
|
|
if next == limit {
|
||
|
|
return next, tunnelEventDown
|
||
|
|
}
|
||
|
|
return next, tunnelEventNone
|
||
|
|
}
|
||
|
|
|
||
|
|
func percent(used, total uint64) float64 {
|
||
|
|
if total == 0 {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return float64(used) / float64(total) * 100
|
||
|
|
}
|
||
|
|
|
||
|
|
func detailOr(detail string) string {
|
||
|
|
if strings.TrimSpace(detail) == "" {
|
||
|
|
return "未返回具体原因"
|
||
|
|
}
|
||
|
|
return detail
|
||
|
|
}
|