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

234 lines
6.9 KiB
Go

package frp
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// 健康状态取值。unknown 表示「判不出来」(frps 管理接口未配或不可用),
// 不能当成 down —— 这正是 tunnel_known 存在的理由。
const (
RouteHealthUp = "up"
RouteHealthDown = "down"
RouteHealthUnknown = "unknown"
// RouteHealthRetention 状态变更记录的保留期
RouteHealthRetention = 30 * 24 * time.Hour
)
// RouteHealthSample 是一轮巡检得到的一条规则健康结论
type RouteHealthSample struct {
ServerID int64
Domain string
State string
Reason string
Detail string
}
// RouteHealthEvent 一条状态变更记录
type RouteHealthEvent struct {
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
At string `json:"at"`
}
// RouteHealthIncident 一段不可用区间;To 为空表示到窗口结束仍在持续
type RouteHealthIncident struct {
From string `json:"from"`
To string `json:"to,omitempty"`
Reason string `json:"reason,omitempty"`
Detail string `json:"detail,omitempty"`
Minutes int `json:"minutes"`
}
// RouteUptimeStat 一条规则在窗口内的可用率与不可用区间
type RouteUptimeStat struct {
ServerID int64 `json:"server_id"`
Domain string `json:"domain"`
State string `json:"state"`
Uptime float64 `json:"uptime_pct"`
DownMinutes int `json:"down_minutes"`
UnknownMinutes int `json:"unknown_minutes"`
CoveredMinutes int `json:"covered_minutes"`
Incidents []RouteHealthIncident `json:"incidents"`
}
// RecordRouteHealth 只写「状态变更」:与上一条同状态就不写,避免表被每 2 分钟的采样撑大。
// 返回写入条数。
func (s *Service) RecordRouteHealth(ctx context.Context, samples []RouteHealthSample) (int, error) {
if len(samples) == 0 {
return 0, nil
}
current, err := s.store.latestRouteHealthStates(ctx)
if err != nil {
return 0, err
}
now := time.Now().UTC().Format(time.RFC3339)
written := 0
for _, sample := range samples {
domain := strings.ToLower(strings.TrimSpace(sample.Domain))
if domain == "" || sample.State == "" {
continue
}
key := routeHealthKey(sample.ServerID, domain)
if state, ok := current[key]; ok && state == sample.State {
continue
}
if _, err := s.store.db.ExecContext(ctx,
`INSERT INTO route_health_events(server_id, domain, state, reason, detail, at) VALUES (?, ?, ?, ?, ?, ?)`,
sample.ServerID, domain, sample.State, sample.Reason, sample.Detail, now); err != nil {
return written, err
}
written++
}
return written, nil
}
// RouteUptime 返回窗口内每条规则的可用率与不可用区间
func (s *Service) RouteUptime(ctx context.Context, window time.Duration) ([]RouteUptimeStat, error) {
if window <= 0 {
window = 24 * time.Hour
}
now := time.Now().UTC()
since := now.Add(-window)
events, err := s.store.routeHealthEventsSince(ctx, since)
if err != nil {
return nil, err
}
return ComputeRouteUptime(events, since, now), nil
}
// PruneRouteHealth 清理超过保留期的状态变更
func (s *Service) PruneRouteHealth(ctx context.Context) (int64, error) {
return s.store.pruneRouteHealthEvents(ctx, time.Now().UTC().Add(-RouteHealthRetention))
}
func routeHealthKey(serverID int64, domain string) string {
return fmt.Sprintf("%d|%s", serverID, domain)
}
// ComputeRouteUptime 由状态变更序列推导 [from, to] 窗口内的可用率。
//
// 覆盖时长只算「有记录可依」的部分:窗口开始前有记录时,其状态延续到窗口开始;
// 窗口内才第一次出现的规则,从它第一条记录开始算。窗口内完全没有记录的规则不出现在结果里。
// 可用率按 up/(up+down) 计,unknown 不计入分母(判不出来不能算不可用),但单独给出时长。
func ComputeRouteUptime(events []RouteHealthEvent, from, to time.Time) []RouteUptimeStat {
type point struct {
at time.Time
serverID int64
domain string
state string
reason, detail string
}
byRule := map[string][]point{}
order := make([]string, 0, len(events))
for _, event := range events {
at, err := time.Parse(time.RFC3339, event.At)
if err != nil {
continue
}
key := routeHealthKey(event.ServerID, event.Domain)
if _, ok := byRule[key]; !ok {
order = append(order, key)
}
byRule[key] = append(byRule[key], point{
at: at, serverID: event.ServerID, domain: event.Domain,
state: event.State, reason: event.Reason, detail: event.Detail,
})
}
sort.Strings(order)
stats := make([]RouteUptimeStat, 0, len(order))
for _, key := range order {
points := byRule[key]
sort.Slice(points, func(i, j int) bool { return points[i].at.Before(points[j].at) })
// 窗口起点的生效状态:取 from 之前最后一条;没有则从第一条记录开始覆盖
start := from
index := 0
state := ""
reason, detail := "", ""
for index < len(points) && !points[index].at.After(from) {
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
if state == "" {
if index >= len(points) {
continue
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat := RouteUptimeStat{
ServerID: points[0].serverID,
Domain: points[0].domain,
State: state,
Incidents: make([]RouteHealthIncident, 0, 2),
}
upMinutes := 0
// stat.State 要是「窗口末尾的状态」,不是窗口开始时的状态
lastState := state
for {
end := to
ongoing := true
if index < len(points) {
end = points[index].at
ongoing = false
}
minutes := int(end.Sub(start).Minutes())
if minutes < 0 {
minutes = 0
}
switch state {
case RouteHealthUp:
upMinutes += minutes
case RouteHealthDown:
stat.DownMinutes += minutes
incident := RouteHealthIncident{
From: start.UTC().Format(time.RFC3339),
Reason: reason,
Detail: detail,
Minutes: minutes,
}
if !ongoing {
incident.To = end.UTC().Format(time.RFC3339)
}
stat.Incidents = append(stat.Incidents, incident)
default:
stat.UnknownMinutes += minutes
}
stat.CoveredMinutes += minutes
lastState = state
if index >= len(points) {
break
}
start = points[index].at
state, reason, detail = points[index].state, points[index].reason, points[index].detail
index++
}
stat.State = lastState
observed := upMinutes + stat.DownMinutes
if observed > 0 {
stat.Uptime = float64(upMinutes) / float64(observed) * 100
} else {
// 窗口内既没有 up 也没有 down(只有 unknown)时不编造可用率
stat.Uptime = 100
if stat.UnknownMinutes > 0 {
stat.Uptime = 0
}
}
stats = append(stats, stat)
}
return stats
}