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:
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// latestRouteHealthStates 取每条规则最新一条记录的状态,用于「同状态不重复写」判断
|
||||
func (s *Store) latestRouteHealthStates(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT e.server_id, e.domain, e.state
|
||||
FROM route_health_events e
|
||||
JOIN (
|
||||
SELECT server_id, domain, MAX(id) AS id
|
||||
FROM route_health_events
|
||||
GROUP BY server_id, domain
|
||||
) m ON m.id = e.id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
states := map[string]string{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
serverID int64
|
||||
domain string
|
||||
state string
|
||||
)
|
||||
if err := rows.Scan(&serverID, &domain, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states[routeHealthKey(serverID, domain)] = state
|
||||
}
|
||||
return states, rows.Err()
|
||||
}
|
||||
|
||||
// routeHealthEventsSince 取窗口内的变更,外加每条规则在窗口开始前的最后一条
|
||||
// ——否则「窗口开始时是什么状态」就无从得知了。
|
||||
func (s *Store) routeHealthEventsSince(ctx context.Context, since time.Time) ([]RouteHealthEvent, error) {
|
||||
stamp := since.UTC().Format(time.RFC3339)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT server_id, domain, state, COALESCE(reason, ''), COALESCE(detail, ''), at
|
||||
FROM route_health_events WHERE at >= ?
|
||||
UNION ALL
|
||||
SELECT e.server_id, e.domain, e.state, COALESCE(e.reason, ''), COALESCE(e.detail, ''), e.at
|
||||
FROM route_health_events e
|
||||
JOIN (
|
||||
SELECT server_id, domain, MAX(id) AS id
|
||||
FROM route_health_events WHERE at < ?
|
||||
GROUP BY server_id, domain
|
||||
) m ON m.id = e.id`, stamp, stamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
events := make([]RouteHealthEvent, 0, 32)
|
||||
for rows.Next() {
|
||||
var event RouteHealthEvent
|
||||
if err := rows.Scan(&event.ServerID, &event.Domain, &event.State, &event.Reason, &event.Detail, &event.At); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// pruneRouteHealthEvents 清理 before 之前的记录,返回删除条数
|
||||
func (s *Store) pruneRouteHealthEvents(ctx context.Context, before time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM route_health_events WHERE at < ?`, before.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
@@ -4,16 +4,19 @@ import "testing"
|
||||
|
||||
func TestSummarizeRouteHealth(t *testing.T) {
|
||||
items := []RouteHealthCandidate{
|
||||
{DNSOK: true, CertOK: true, TunnelOK: true, ServiceOK: true},
|
||||
{DNSOK: true, CertOK: false, TunnelOK: true, ServiceOK: false},
|
||||
{DNSOK: false, CertOK: true, TunnelOK: false, ServiceOK: true},
|
||||
{DNSOK: true, CertOK: true, TunnelOK: true, TunnelKnown: true, ServiceOK: true},
|
||||
{DNSOK: true, CertOK: false, TunnelOK: true, TunnelKnown: true, ServiceOK: false},
|
||||
{DNSOK: false, CertOK: true, TunnelOK: false, TunnelKnown: true, ServiceOK: true},
|
||||
// frps 管理接口不可用:隧道状态判不出来,既不算通也不是不通
|
||||
{DNSOK: true, CertOK: true, ServiceOK: true},
|
||||
}
|
||||
summary := summarizeRouteHealth(2, []string{"vps-b"}, items)
|
||||
|
||||
if summary.Servers != 2 || summary.Routes != 3 || summary.Healthy != 1 {
|
||||
if summary.Servers != 2 || summary.Routes != 4 || summary.Healthy != 1 {
|
||||
t.Fatalf("unexpected summary counts: %+v", summary)
|
||||
}
|
||||
if summary.DNSFailed != 1 || summary.CertMissing != 1 || summary.TunnelDown != 1 || summary.ServiceDown != 1 {
|
||||
if summary.DNSFailed != 1 || summary.CertMissing != 1 || summary.TunnelDown != 1 ||
|
||||
summary.TunnelUnknown != 1 || summary.ServiceDown != 1 {
|
||||
t.Fatalf("unexpected segment counts: %+v", summary)
|
||||
}
|
||||
if len(summary.FailedServers) != 1 || summary.FailedServers[0] != "vps-b" {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func findUptimeStat(t *testing.T, stats []RouteUptimeStat, domain string) RouteUptimeStat {
|
||||
t.Helper()
|
||||
for _, stat := range stats {
|
||||
if stat.Domain == domain {
|
||||
return stat
|
||||
}
|
||||
}
|
||||
t.Fatalf("结果里没有 %s:%+v", domain, stats)
|
||||
return RouteUptimeStat{}
|
||||
}
|
||||
|
||||
func TestComputeRouteUptimeIncidentAndUnknown(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
to := from.Add(24 * time.Hour)
|
||||
at := func(offset time.Duration) string { return from.Add(offset).Format(time.RFC3339) }
|
||||
|
||||
events := []RouteHealthEvent{
|
||||
// 窗口开始前就是 up:状态延续到窗口开始
|
||||
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: at(-2 * time.Hour)},
|
||||
{ServerID: 1, Domain: "a.com", State: RouteHealthDown, At: at(time.Hour), Reason: "tunnel"},
|
||||
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: at(2 * time.Hour)},
|
||||
// 窗口内才第一次出现
|
||||
{ServerID: 1, Domain: "b.com", State: RouteHealthUp, At: at(6 * time.Hour)},
|
||||
// 判不出来
|
||||
{ServerID: 2, Domain: "c.com", State: RouteHealthUnknown, At: at(-time.Hour)},
|
||||
}
|
||||
|
||||
stats := ComputeRouteUptime(events, from, to)
|
||||
if len(stats) != 3 {
|
||||
t.Fatalf("应有 3 条规则的统计,实际 %d:%+v", len(stats), stats)
|
||||
}
|
||||
|
||||
a := findUptimeStat(t, stats, "a.com")
|
||||
if a.CoveredMinutes != 24*60 {
|
||||
t.Fatalf("窗口开始前的状态应延续,覆盖 24h,实际 %d 分钟", a.CoveredMinutes)
|
||||
}
|
||||
if a.DownMinutes != 60 {
|
||||
t.Fatalf("不可用应为 60 分钟,实际 %d", a.DownMinutes)
|
||||
}
|
||||
if a.Uptime < 95 || a.Uptime > 96 {
|
||||
t.Fatalf("可用率应约 95.8%%,实际 %.2f", a.Uptime)
|
||||
}
|
||||
if len(a.Incidents) != 1 || a.Incidents[0].Minutes != 60 || a.Incidents[0].Reason != "tunnel" {
|
||||
t.Fatalf("不可用区间不对:%+v", a.Incidents)
|
||||
}
|
||||
if a.Incidents[0].To == "" {
|
||||
t.Fatal("已恢复的区间应有结束时间")
|
||||
}
|
||||
if a.State != RouteHealthUp {
|
||||
t.Fatalf("窗口末尾状态应为 up,实际 %s", a.State)
|
||||
}
|
||||
|
||||
b := findUptimeStat(t, stats, "b.com")
|
||||
if b.CoveredMinutes != 18*60 {
|
||||
t.Fatalf("窗口内才出现的规则应从第一条记录起算 18h,实际 %d", b.CoveredMinutes)
|
||||
}
|
||||
if b.Uptime != 100 || b.DownMinutes != 0 {
|
||||
t.Fatalf("没有任何 down 时可用率应为 100:%+v", b)
|
||||
}
|
||||
|
||||
c := findUptimeStat(t, stats, "c.com")
|
||||
if c.UnknownMinutes != 24*60 || c.DownMinutes != 0 {
|
||||
t.Fatalf("全程 unknown 不该算作不可用:%+v", c)
|
||||
}
|
||||
if c.Uptime != 0 {
|
||||
t.Fatalf("全程判不出来时不能编造可用率(不给 100),实际 %.2f", c.Uptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRouteUptimeOngoingIncident(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
to := from.Add(24 * time.Hour)
|
||||
|
||||
events := []RouteHealthEvent{
|
||||
{ServerID: 1, Domain: "a.com", State: RouteHealthUp, At: from.Add(0).Format(time.RFC3339)},
|
||||
{ServerID: 1, Domain: "a.com", State: RouteHealthDown, At: from.Add(20 * time.Hour).Format(time.RFC3339), Reason: "service"},
|
||||
}
|
||||
|
||||
stats := ComputeRouteUptime(events, from, to)
|
||||
stat := findUptimeStat(t, stats, "a.com")
|
||||
if stat.DownMinutes != 4*60 {
|
||||
t.Fatalf("到窗口结束仍在持续应计 240 分钟,实际 %d", stat.DownMinutes)
|
||||
}
|
||||
if len(stat.Incidents) != 1 {
|
||||
t.Fatalf("应有 1 段区间:%+v", stat.Incidents)
|
||||
}
|
||||
if stat.Incidents[0].To != "" {
|
||||
t.Fatalf("仍在持续的区间不应有结束时间,实际 %q", stat.Incidents[0].To)
|
||||
}
|
||||
if stat.State != RouteHealthDown {
|
||||
t.Fatalf("窗口末尾状态应为 down,实际 %s", stat.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeRouteUptimeSkipsRulesWithoutEvents(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
// 窗口内没有任何记录 → 不出现在结果里,而不是给一个 100%
|
||||
if stats := ComputeRouteUptime(nil, from, from.Add(time.Hour)); len(stats) != 0 {
|
||||
t.Fatalf("没有记录时不该有统计:%+v", stats)
|
||||
}
|
||||
}
|
||||
+26
-16
@@ -870,12 +870,14 @@ func (s *Service) InstallAgentNginx(ctx context.Context, serverID int64) (map[st
|
||||
// 健康判定与规则页一致:四段(DNS/证书/隧道/内网服务)全通过才算健康;
|
||||
// 一个域名可同时命中多段失败,因此各段计数之和可能大于 Routes-Healthy。
|
||||
type RouteHealthSummary struct {
|
||||
Servers int `json:"servers"`
|
||||
Routes int `json:"routes"`
|
||||
Healthy int `json:"healthy"`
|
||||
DNSFailed int `json:"dns_failed"`
|
||||
CertMissing int `json:"cert_missing"`
|
||||
TunnelDown int `json:"tunnel_down"`
|
||||
Servers int `json:"servers"`
|
||||
Routes int `json:"routes"`
|
||||
Healthy int `json:"healthy"`
|
||||
DNSFailed int `json:"dns_failed"`
|
||||
CertMissing int `json:"cert_missing"`
|
||||
TunnelDown int `json:"tunnel_down"`
|
||||
// TunnelUnknown 是「frps 管理接口不可用、隧道状态判不出来」的条数
|
||||
TunnelUnknown int `json:"tunnel_unknown"`
|
||||
ServiceDown int `json:"service_down"`
|
||||
FailedServers []string `json:"failed_servers,omitempty"`
|
||||
}
|
||||
@@ -911,7 +913,11 @@ func summarizeRouteHealth(servers int, failedServers []string, items []RouteHeal
|
||||
if !item.CertOK {
|
||||
summary.CertMissing++
|
||||
}
|
||||
if !item.TunnelOK {
|
||||
switch {
|
||||
case !item.TunnelKnown:
|
||||
// frps 管理接口不可用时隧道状态判不出来,不能计入「不通」
|
||||
summary.TunnelUnknown++
|
||||
case !item.TunnelOK:
|
||||
summary.TunnelDown++
|
||||
}
|
||||
if !item.ServiceOK {
|
||||
@@ -926,13 +932,16 @@ func summarizeRouteHealth(servers int, failedServers []string, items []RouteHeal
|
||||
|
||||
// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
|
||||
type RouteHealthCandidate struct {
|
||||
ProxyID int64 `json:"proxy_id"`
|
||||
Domain string `json:"domain"`
|
||||
DNSOK bool `json:"dns_ok"`
|
||||
DNSDetail string `json:"dns_detail,omitempty"`
|
||||
CertOK bool `json:"cert_ok"`
|
||||
CertDetail string `json:"cert_detail,omitempty"`
|
||||
TunnelOK bool `json:"tunnel_ok"`
|
||||
ProxyID int64 `json:"proxy_id"`
|
||||
Domain string `json:"domain"`
|
||||
DNSOK bool `json:"dns_ok"`
|
||||
DNSDetail string `json:"dns_detail,omitempty"`
|
||||
CertOK bool `json:"cert_ok"`
|
||||
CertDetail string `json:"cert_detail,omitempty"`
|
||||
TunnelOK bool `json:"tunnel_ok"`
|
||||
// TunnelKnown 表示「隧道状态是判得出来的」:frps 管理接口没配或连不上时 TunnelOK 恒为 false,
|
||||
// 但那是「无法判定」而不是「不通」——告警与汇总都必须按它区分,否则会误报。
|
||||
TunnelKnown bool `json:"tunnel_known"`
|
||||
TunnelName string `json:"tunnel_name,omitempty"`
|
||||
TunnelDetail string `json:"tunnel_detail,omitempty"`
|
||||
ServiceOK bool `json:"service_ok"`
|
||||
@@ -1067,6 +1076,8 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
||||
// frp 会自动给代理名加 "<user>." 前缀(如 admin.ssssss),两种形态都接受。
|
||||
item.TunnelName = proxy.Name
|
||||
if dashboardReady {
|
||||
// dashboard 可用:这一次的隧道状态是「判得出来」的,不管结论是通还是不通
|
||||
item.TunnelKnown = true
|
||||
// 优先按域名索引匹配(V2 API 返回 spec.http.customDomains,代理名含 user 前缀不可靠),
|
||||
// 再回退代理名(裸名或 user.前缀名)
|
||||
switch {
|
||||
@@ -1088,8 +1099,7 @@ func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealt
|
||||
}
|
||||
if item.TunnelOK {
|
||||
item.TunnelDetail = "frps 已注册: " + item.TunnelName
|
||||
} else {
|
||||
// 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
|
||||
} else { // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
|
||||
names := make([]string, 0, len(tunnelNames))
|
||||
for name := range tunnelNames {
|
||||
if !strings.HasPrefix(name, "domain:") {
|
||||
|
||||
Reference in New Issue
Block a user