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

- 自动备份:每日一份(按最新备份是否在 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
+12 -1
View File
@@ -23,6 +23,7 @@ import (
"github.com/fonu/fonu/internal/discovery"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/logstore"
"github.com/fonu/fonu/internal/metrics"
"github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/nginx"
"github.com/fonu/fonu/internal/notify"
@@ -93,6 +94,8 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
backupSvc := backup.New(cfg.DataDir)
// 指标历史:与巡检同生命周期,采样跟随巡检轮次
metricsStore := metrics.NewStore(conn)
discoverySvc := discovery.New()
// 新增:多服务端 FRP 模块(接管原网关模式)
frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
@@ -114,6 +117,7 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Notify: notifySvc,
ChinaCIDR: chinaCIDRSvc,
Backup: backupSvc,
Metrics: metricsStore,
Discovery: discoverySvc,
FRPMulti: frpMultiSvc,
Traffic: trafficCollector,
@@ -140,7 +144,9 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
chinaHours = 24
}
// 巡检间隔比其它任务短:「连续 N 次不通」需要足够采样次数才能判定
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger})
monitorChecker := monitor.New(settingsStore, notifySvc, logger, monitorSource{frp: frpMultiSvc, logger: logger, traffic: trafficCollector}, healthRecorder{frp: frpMultiSvc}, metricsStore)
// 自动备份:启动时会先跑一轮,靠「最新备份是否在 24h 内」避免频繁重启重复备份
backupRunner := backup.NewAutoRunner(backupSvc, settingsStore, notifySvc, logger)
sched := scheduler.New(
scheduler.Job{
Name: "ddns",
@@ -162,6 +168,11 @@ func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error)
Interval: 2 * time.Minute,
Run: monitorChecker.Run,
},
scheduler.Job{
Name: "backup",
Interval: backup.AutoInterval,
Run: backupRunner.Run,
},
)
sched.Start(ctx)
trafficCollector.Start(ctx)
+63 -3
View File
@@ -7,13 +7,15 @@ import (
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/monitor"
"github.com/fonu/fonu/internal/traffic"
)
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
// 怎么从 frp / agent 取是这里的事,两边因此互不依赖。
type monitorSource struct {
frp *frp.Service
logger *slog.Logger
frp *frp.Service
logger *slog.Logger
traffic *traffic.Collector
}
func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error) {
@@ -30,7 +32,15 @@ func (s monitorSource) Routes(ctx context.Context) ([]monitor.RouteStatus, error
continue
}
for _, item := range health {
routes = append(routes, monitor.RouteStatus{Domain: item.Domain, TunnelOK: item.TunnelOK, Detail: item.TunnelDetail})
routes = append(routes, monitor.RouteStatus{
ServerID: server.ID,
Domain: item.Domain,
TunnelOK: item.TunnelOK,
DNSOK: item.DNSOK,
ServiceOK: item.ServiceOK,
Known: item.TunnelKnown,
Detail: item.TunnelDetail,
})
}
}
return routes, nil
@@ -98,6 +108,19 @@ func (s monitorSource) Drift(ctx context.Context) ([]monitor.DriftStatus, error)
return out, nil
}
// Traffic 返回若干域名的当前上下行速率:流量采集器内部按 host:port 累计,这里按域名合并。
func (s monitorSource) Traffic(ctx context.Context, domains []string) (map[string]monitor.TrafficRate, error) {
if s.traffic == nil {
return nil, nil
}
snapshot := s.traffic.SnapshotByHost(domains)
rates := make(map[string]monitor.TrafficRate, len(snapshot))
for host, item := range snapshot {
rates[host] = monitor.TrafficRate{Upload: item.UploadRate, Download: item.DownloadRate}
}
return rates, nil
}
func (s monitorSource) enabledServers(ctx context.Context) ([]frp.Server, error) {
servers, err := s.frp.ListServers(ctx)
if err != nil {
@@ -128,3 +151,40 @@ func driftKindText(kind string) string {
return kind
}
}
// healthRecorder 把 monitor 的健康结论翻成 frp 侧样本并落库(
// 两侧的状态字面量由 healthStateValue 显式翻译,改动一侧会在这里编译期报错)。
type healthRecorder struct {
frp *frp.Service
}
func (r healthRecorder) RecordRouteHealth(ctx context.Context, samples []monitor.HealthSample) error {
converted := make([]frp.RouteHealthSample, 0, len(samples))
for _, sample := range samples {
converted = append(converted, frp.RouteHealthSample{
ServerID: sample.ServerID,
Domain: sample.Domain,
State: healthStateValue(sample.State),
Reason: sample.Reason,
Detail: sample.Detail,
})
}
_, err := r.frp.RecordRouteHealth(ctx, converted)
return err
}
func (r healthRecorder) PruneRouteHealth(ctx context.Context) error {
_, err := r.frp.PruneRouteHealth(ctx)
return err
}
func healthStateValue(state string) string {
switch state {
case monitor.HealthUp:
return frp.RouteHealthUp
case monitor.HealthDown:
return frp.RouteHealthDown
default:
return frp.RouteHealthUnknown
}
}
+24
View File
@@ -0,0 +1,24 @@
package app
import (
"testing"
"github.com/fonu/fonu/internal/frp"
"github.com/fonu/fonu/internal/monitor"
)
// monitor 与 frp 两侧的状态字面量必须一致,落库前由 healthStateValue 翻译一次;
// 这个测试把翻译钉住,避免以后改一侧的常量而另一侧悄悄写错值。
func TestHealthStateValue(t *testing.T) {
cases := map[string]string{
monitor.HealthUp: frp.RouteHealthUp,
monitor.HealthDown: frp.RouteHealthDown,
monitor.HealthUnknown: frp.RouteHealthUnknown,
"其他": frp.RouteHealthUnknown,
}
for input, want := range cases {
if got := healthStateValue(input); got != want {
t.Fatalf("%q 应翻译为 %q,实际 %q", input, want, got)
}
}
}