feat: 自动备份与保留策略、健康持续化与状态页、指标历史与趋势图,并修隧道判定误报与仪表盘布局(v1.2.11)

- 自动备份:每日一份(按最新备份是否在 24h 内判定,重启不重复)、保留份数可配、列表/下载/恢复/删除,恢复前先校验归档;顺手修 writeArchive 逐个检查 tar/gzip/文件 Close()(审查报告 §二)

- 健康持续化:只存状态变更(up/down/unknown)并推导可用率与不可用区间;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率

- 隧道判定修复:RouteHealthCandidate 新增 tunnel_known,frps 管理接口不可用时不再把「判不出来」当成「不通」(此前会误报「隧道连续不通」);汇总与徽标同样区分「无法判定」

- 指标历史:metrics_samples 纵向点表(本机 CPU/内存/磁盘 + 每规则上下行,30 天保留、查询按桶平均、不做降采样表);仪表盘趋势卡支持 1h/24h/7d

- 仪表盘布局:运行状态并入顶部统计区(运行时长补进「服务」卡);性能并入系统资源那一行,整行 4 列

- 测试:backup/metrics/frp/api/app 新增用例;gofmt、go build/vet/test、vue-tsc、vite build 全绿

- agent 无改动,AgentVersion 保持 0.12.2
This commit is contained in:
2026-09-25 13:05:58 +08:00
parent c8264bf280
commit 74d731d0ac
46 changed files with 2967 additions and 175 deletions
+244 -21
View File
@@ -14,18 +14,49 @@ import (
"sync"
"time"
"github.com/fonu/fonu/internal/metrics"
"github.com/fonu/fonu/internal/notify"
"github.com/fonu/fonu/internal/settings"
"github.com/fonu/fonu/internal/sysinfo"
)
// RouteStatus 是巡检关心的隧道状态。
// RouteStatus 是巡检关心的隧道与健康状态。
type RouteStatus struct {
ServerID int64
Domain string
TunnelOK bool
// Known 表示这次的隧道状态是判得出来的(frps 管理接口可用)。
// 判不出来时既不能计入连续失败,也不能当成恢复。
Known bool
TunnelOK bool
DNSOK bool
ServiceOK bool
Detail string
// 健康结论取值,与 frp 侧的 RouteHealthUp/Down/Unknown 保持一致,
// 由 app 侧适配时显式翻译一次。
}
const (
HealthUp = "up"
HealthDown = "down"
HealthUnknown = "unknown"
)
// HealthSample 是一条规则本轮的健康结论,交给落库层(monitor 不关心它写哪张表)。
type HealthSample struct {
ServerID int64
Domain string
State string
Reason string
Detail string
}
// HealthRecorder 持久化健康结论,由 frp 服务实现。
type HealthRecorder interface {
RecordRouteHealth(ctx context.Context, samples []HealthSample) error
PruneRouteHealth(ctx context.Context) error
}
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
type ServerStatus struct {
ID int64
@@ -52,12 +83,25 @@ type DriftStatus struct {
Items []string
}
// Source 提供巡检需要的外部状态;由 app 侧把 frp 服务适配过来,monitor 因此不依赖 frp 包。
// TrafficRate 某个域名的当前速率(字节/秒)
type TrafficRate struct {
Upload float64
Download float64
}
// Source 提供巡检需要的外部状态;由 app 侧把 frp 服务与流量采集器适配过来。
type Source interface {
Routes(ctx context.Context) ([]RouteStatus, error)
Servers(ctx context.Context) ([]ServerStatus, error)
Agents(ctx context.Context) ([]AgentStatus, error)
Drift(ctx context.Context) ([]DriftStatus, error)
Traffic(ctx context.Context, domains []string) (map[string]TrafficRate, error)
}
// MetricsRecorder 落库指标历史;*metrics.Store 直接实现它。
type MetricsRecorder interface {
Record(ctx context.Context, samples []metrics.Sample) error
Prune(ctx context.Context) (int64, error)
}
// Checker 每轮都重新读设置项:改阈值或开关不需要重启。
@@ -66,16 +110,20 @@ type Checker struct {
notify *notify.Service
logger *slog.Logger
source Source
health HealthRecorder
metrics MetricsRecorder
mu sync.Mutex
cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time
failures map[string]int
driftAlerted map[int64]bool
driftNotified map[int64]time.Time
mu sync.Mutex
cpuPrev sysinfo.CPUSample
resourceAlerts map[string]time.Time
failures map[string]int
driftAlerted map[int64]bool
driftNotified map[int64]time.Time
lastHealthPrune time.Time
lastMetricsPrune time.Time
}
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source) *Checker {
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source, health HealthRecorder, metricsRecorder MetricsRecorder) *Checker {
if logger == nil {
logger = slog.Default()
}
@@ -84,6 +132,8 @@ func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.
notify: notifySvc,
logger: logger,
source: source,
health: health,
metrics: metricsRecorder,
resourceAlerts: map[string]time.Time{},
failures: map[string]int{},
driftAlerted: map[int64]bool{},
@@ -96,27 +146,86 @@ func (c *Checker) Run(ctx context.Context) {
if c == nil || c.settings == nil {
return
}
c.checkResources(ctx)
c.runHostChecks(ctx)
c.checkRoutes(ctx)
c.checkServers(ctx)
c.checkAgents(ctx)
c.checkDrift(ctx)
}
func (c *Checker) checkResources(ctx context.Context) {
// 开关关闭时连采集都省掉(关掉的功能不该有后台开销)
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold); !enabled {
// runHostChecks 一次采样同时喂给「资源阈值告警」与「指标历史」。
// CPU 占用率靠两次采样的差值得出,采两次会互相干扰,所以采样点只留这一个。
func (c *Checker) runHostChecks(ctx context.Context) {
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold)
// 指标历史默认开启:没配置过时按开处理(存了 "0"/"false" 才关)
recordEnabled := c.metricsHistoryEnabled(ctx)
// 两件事都关着就连采样都省掉(关掉的功能不该有后台开销)
if !alertEnabled && !recordEnabled {
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()
if alertEnabled {
c.checkResourceLimits(ctx, stats)
}
if recordEnabled {
c.recordHostMetrics(ctx, stats)
}
}
// metricsHistoryEnabled 读指标历史开关:未配置按开启处理(只有显式存了 0/false 才关)
func (c *Checker) metricsHistoryEnabled(ctx context.Context) bool {
if c.metrics == nil {
return false
}
raw, err := c.settings.Get(ctx, settings.KeyMetricsHistoryEnabled)
if err != nil {
return true
}
return raw != "0" && !strings.EqualFold(raw, "false")
}
// recordHostMetrics 记本机序列;采样间隔就是巡检轮次(2 分钟),行量很小
func (c *Checker) recordHostMetrics(ctx context.Context, stats sysinfo.Stats) {
samples := []metrics.Sample{
{Scope: metrics.ScopeHost, Metric: metrics.MetricCPU, Value: stats.CPUPercent},
{Scope: metrics.ScopeHost, Metric: metrics.MetricMemUsed, Value: float64(stats.MemUsedBytes)},
{Scope: metrics.ScopeHost, Metric: metrics.MetricMemTotal, Value: float64(stats.MemTotalBytes)},
{Scope: metrics.ScopeHost, Metric: metrics.MetricDiskUsed, Value: float64(stats.DiskUsedBytes)},
{Scope: metrics.ScopeHost, Metric: metrics.MetricDiskTotal, Value: float64(stats.DiskTotalBytes)},
}
if err := c.metrics.Record(ctx, samples); err != nil {
c.logger.Warn("记录指标历史失败", "module", "MONITOR", "error", err.Error())
}
c.pruneMetricsIfDue(ctx)
}
// pruneMetricsIfDue 每天清理一次超期样本
func (c *Checker) pruneMetricsIfDue(ctx context.Context) {
c.mu.Lock()
due := c.lastMetricsPrune.IsZero() || time.Since(c.lastMetricsPrune) >= 24*time.Hour
if due {
c.lastMetricsPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if _, err := c.metrics.Prune(ctx); err != nil {
c.logger.Warn("清理指标历史失败", "module", "MONITOR", "error", err.Error())
}
}
// checkResourceLimits 只做阈值判定:采样已由 runHostChecks 统一完成
func (c *Checker) checkResourceLimits(ctx context.Context, stats sysinfo.Stats) {
cpuLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyCPUThreshold)
memoryLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyMemoryThreshold)
diskLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyDiskThreshold)
limits := []struct {
metric string
label string
@@ -164,11 +273,14 @@ func (c *Checker) checkRoutes(ctx context.Context) {
if c.source == nil {
return
}
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown); !enabled {
return
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
if c.health == nil {
recordHealth = false
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
recordMetrics := c.metricsHistoryEnabled(ctx)
// 三件事都关着就别去探测:RouteHealth 是这一轮里最贵的调用
if !recordHealth && !alertEnabled && !recordMetrics {
return
}
@@ -177,10 +289,48 @@ func (c *Checker) checkRoutes(ctx context.Context) {
c.logger.Warn("巡检隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
// 落库先于告警:判不出来的那一档也要记(unknown 与 down 必须区分)
if recordHealth {
samples := make([]HealthSample, 0, len(routes))
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
state, reason := healthStateOf(route)
samples = append(samples, HealthSample{
ServerID: route.ServerID,
Domain: route.Domain,
State: state,
Reason: reason,
Detail: route.Detail,
})
}
if err := c.health.RecordRouteHealth(ctx, samples); err != nil {
c.logger.Warn("记录规则健康状态失败", "module", "MONITOR", "error", err.Error())
}
c.pruneHealthIfDue(ctx)
}
// 流量序列用同一轮的规则列表落库,不再单独探测一轮
if recordMetrics {
c.recordRouteTraffic(ctx, routes)
}
if !alertEnabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
if !route.Known {
continue
}
_, event := c.track("route:"+route.Domain, route.TunnelOK, limit)
switch event {
case failureEventDown:
@@ -193,6 +343,79 @@ func (c *Checker) checkRoutes(ctx context.Context) {
}
}
// healthStateOf 把一段状态翻成与 frp 侧一致的结论 + 失败环节
func healthStateOf(route RouteStatus) (string, string) {
if !route.Known {
return HealthUnknown, "dashboard"
}
switch {
case !route.DNSOK:
return HealthDown, "dns"
case !route.TunnelOK:
return HealthDown, "tunnel"
case !route.ServiceOK:
return HealthDown, "service"
default:
return HealthUp, ""
}
}
// recordRouteTraffic 记每条规则的上下行速率(scope 按服务端与域名区分)
func (c *Checker) recordRouteTraffic(ctx context.Context, routes []RouteStatus) {
if c.source == nil || c.metrics == nil {
return
}
domains := make([]string, 0, len(routes))
for _, route := range routes {
if domain := strings.TrimSpace(route.Domain); domain != "" {
domains = append(domains, domain)
}
}
if len(domains) == 0 {
return
}
rates, err := c.source.Traffic(ctx, domains)
if err != nil {
c.logger.Warn("读取流量快照失败", "module", "MONITOR", "error", err.Error())
return
}
samples := make([]metrics.Sample, 0, len(routes)*2)
for _, route := range routes {
domain := strings.ToLower(strings.TrimSpace(route.Domain))
rate, ok := rates[domain]
if !ok {
continue
}
scope := fmt.Sprintf("route:%d:%s", route.ServerID, domain)
samples = append(samples,
metrics.Sample{Scope: scope, Metric: metrics.MetricTrafficIn, Value: rate.Upload},
metrics.Sample{Scope: scope, Metric: metrics.MetricTrafficOut, Value: rate.Download},
)
}
if len(samples) == 0 {
return
}
if err := c.metrics.Record(ctx, samples); err != nil {
c.logger.Warn("记录流量历史失败", "module", "MONITOR", "error", err.Error())
}
}
// pruneHealthIfDue 每天清理一次超期记录:不在每轮都删,省一次写事务
func (c *Checker) pruneHealthIfDue(ctx context.Context) {
c.mu.Lock()
due := c.lastHealthPrune.IsZero() || time.Since(c.lastHealthPrune) >= 24*time.Hour
if due {
c.lastHealthPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if err := c.health.PruneRouteHealth(ctx); err != nil {
c.logger.Warn("清理健康记录失败", "module", "MONITOR", "error", err.Error())
}
}
// checkServers 公网服务端掉线:进程没在跑,或隧道客户端连不上 frps。
// 只对「用户要它运行」的服务端判定,手动停掉的不报。
func (c *Checker) checkServers(ctx context.Context) {