Files
mrlee 74d731d0ac
CI / verify (push) Canceled after 0s
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
2026-09-25 13:05:58 +08:00

131 lines
3.6 KiB
Go

package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestDueForBackup(t *testing.T) {
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
// 还没有备份 → 立刻做一份
if !dueForBackup(time.Time{}, now, AutoInterval) {
t.Fatal("没有备份时应立即备份")
}
// 刚备份过 → 跳过:这正是重启不会重复备份的原因
if dueForBackup(now.Add(-time.Hour), now, AutoInterval) {
t.Fatal("间隔内的备份不应重复执行")
}
// 正好到间隔 → 该做了
if !dueForBackup(now.Add(-AutoInterval), now, AutoInterval) {
t.Fatal("到达间隔应再备份一次")
}
// 超过间隔 → 该做
if !dueForBackup(now.Add(-25*time.Hour), now, AutoInterval) {
t.Fatal("超过间隔应再备份一次")
}
}
func TestExportVerifyRoundTrip(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "fonu.db"), []byte("db"), 0o600); err != nil {
t.Fatal(err)
}
svc := New(dir)
path, err := svc.Export(context.Background())
if err != nil {
t.Fatalf("export: %v", err)
}
entries, err := svc.Verify(context.Background(), path)
if err != nil {
t.Fatalf("verify: %v", err)
}
if entries < 1 {
t.Fatalf("条目数应至少为 1,实际 %d", entries)
}
archives, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(archives) != 1 {
t.Fatalf("应列出 1 份备份,实际 %d", len(archives))
}
if archives[0].Size == 0 || !archiveNamePattern.MatchString(archives[0].Name) {
t.Fatalf("备份条目异常:%+v", archives[0])
}
}
func TestVerifyRejectsGarbage(t *testing.T) {
svc := New(t.TempDir())
path := filepath.Join(svc.backupsDir(), "fonu-backup-20260925-000000-1.tar.gz")
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("not a gzip stream"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := svc.Verify(context.Background(), path); err == nil {
t.Fatal("非 gzip 内容应校验失败")
}
}
func TestListPruneAndPath(t *testing.T) {
svc := New(t.TempDir())
if err := os.MkdirAll(svc.backupsDir(), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"fonu-backup-20260923-100000-1.tar.gz",
"fonu-backup-20260924-100000-2.tar.gz",
"fonu-backup-20260925-100000-3.tar.gz",
} {
if err := os.WriteFile(filepath.Join(svc.backupsDir(), name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
// 不符合命名格式的文件不参与:备份目录里可能混着手工放进来的东西
if err := os.WriteFile(filepath.Join(svc.backupsDir(), "notes.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
archives, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(archives) != 3 {
t.Fatalf("应只列出 3 份备份,实际 %d", len(archives))
}
if archives[0].Name != "fonu-backup-20260925-100000-3.tar.gz" {
t.Fatalf("应按时间倒序,实际首条 %s", archives[0].Name)
}
removed, err := svc.Prune(2)
if err != nil {
t.Fatalf("prune: %v", err)
}
if removed != 1 {
t.Fatalf("应删除 1 份,实际 %d", removed)
}
if archives, _ = svc.List(); len(archives) != 2 {
t.Fatalf("剪枝后应剩 2 份,实际 %d", len(archives))
}
// 名称校验:不能让备份名把读写带出备份目录
for _, name := range []string{"../fonu.db", "notes.txt", "", "fonu-backup-x.tar.gz"} {
if _, err := svc.Path(name); err == nil {
t.Fatalf("非法备份名应拒绝:%q", name)
}
}
if err := svc.Remove("fonu-backup-20260920-100000-9.tar.gz"); err == nil {
t.Fatal("删除不存在的备份应报错")
}
if _, err := svc.Prune(0); err != nil {
t.Fatalf("keep<=0 应直接返回:%v", err)
}
}