feat: create standalone native fnOS repository

This commit is contained in:
2026-10-02 15:19:09 +08:00
parent 69f3c30fab
commit 6725fc131d
492 changed files with 94023 additions and 3 deletions
+721
View File
@@ -0,0 +1,721 @@
// Package monitor 周期巡检几类必须持续观察才会出现的问题:主机资源占用、隧道连通性、
// 公网服务端掉线、agent 不可达、配置漂移。
// 事件型通知(DDNS 失败、证书到期)由各自模块在发生时直接发出,不需要巡检。
//
// 判定在这里,通知只走 notify.Service:开关、阈值、冷却都读设置项,
// 所以本包不需要知道通知通道的任何细节。
package monitor
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/havline/havline/internal/metrics"
"github.com/havline/havline/internal/notify"
"github.com/havline/havline/internal/settings"
"github.com/havline/havline/internal/sysinfo"
)
// RouteStatus 是巡检关心的隧道与健康状态。
type RouteStatus struct {
ServerID int64
Domain string
// 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
}
// CloudflareRouteStatus 是 Cloudflare 隧道域名的巡检快照。
type CloudflareRouteStatus struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
Known bool
}
// CloudflareHealthSample 是 Cloudflare 隧道的状态变更样本。
type CloudflareHealthSample struct {
TunnelID int64
Name string
Domain string
State string
Reason string
Detail string
}
// CloudflareHealthSource 提供 Cloudflare 隧道健康快照。
type CloudflareHealthSource interface {
CloudflareRoutes(ctx context.Context) ([]CloudflareRouteStatus, error)
}
// CloudflareHealthRecorder 持久化 Cloudflare 隧道健康状态。
type CloudflareHealthRecorder interface {
RecordCloudflareHealth(ctx context.Context, samples []CloudflareHealthSample) error
PruneCloudflareHealth(ctx context.Context) error
}
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
type ServerStatus struct {
ID int64
Name string
DesiredState string
ProcessState string
ConnectionState string
Detail string
}
// AgentStatus 是某台服务端上 havline-agent 的可达性;没配 agent 的服务端不会出现在这里。
type AgentStatus struct {
ID int64
Name string
Reachable bool
Detail string
}
// DriftStatus 是某台服务端的部署漂移摘要,Count 为 0 表示没有漂移。
type DriftStatus struct {
ID int64
Name string
Count int
Items []string
}
// 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 每轮都重新读设置项:改阈值或开关不需要重启。
type Checker struct {
settings *settings.Store
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
lastHealthPrune time.Time
lastCFHealthPrune time.Time
lastMetricsPrune time.Time
}
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source, health HealthRecorder, metricsRecorder MetricsRecorder) *Checker {
if logger == nil {
logger = slog.Default()
}
return &Checker{
settings: settingsStore,
notify: notifySvc,
logger: logger,
source: source,
health: health,
metrics: metricsRecorder,
resourceAlerts: map[string]time.Time{},
failures: map[string]int{},
driftAlerted: map[int64]bool{},
driftNotified: map[int64]time.Time{},
}
}
// Run 跑一轮巡检。scheduler 已对任务包了 recover,这里不重复兜底。
func (c *Checker) Run(ctx context.Context) {
if c == nil || c.settings == nil {
return
}
c.runHostChecks(ctx)
c.checkRoutes(ctx)
c.checkServers(ctx)
c.checkAgents(ctx)
c.checkDrift(ctx)
c.checkCloudflare(ctx)
}
// 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
}
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
limit int
value float64
}{
{"cpu", "CPU", cpuLimit, stats.CPUPercent},
{"memory", "内存", memoryLimit, percent(stats.MemUsedBytes, stats.MemTotalBytes)},
{"disk", "磁盘", diskLimit, percent(stats.DiskUsedBytes, stats.DiskTotalBytes)},
}
now := time.Now()
for _, item := range limits {
// 阈值 <= 0 表示这一项不告警(CPU 首次采样时占用率为 0,也不会误报)
if item.limit <= 0 {
continue
}
if item.value < float64(item.limit) {
// 回落到阈值以下就清冷却:再次超标可以立刻提醒
c.mu.Lock()
delete(c.resourceAlerts, item.metric)
c.mu.Unlock()
continue
}
if !c.allowResourceAlert(item.metric, now) {
continue
}
c.notify.Alert(ctx, notify.EventResourceThreshold, "资源占用过高",
fmt.Sprintf("%s 使用率 %.1f%%,已超过阈值 %d%%", item.label, item.value, item.limit))
}
}
// allowResourceAlert 按静默期限流:持续高占用每 DefaultResourceAlertCooldown 提醒一次。
func (c *Checker) allowResourceAlert(metric string, now time.Time) bool {
cooldown := time.Duration(notify.DefaultResourceAlertCooldown) * time.Second
c.mu.Lock()
defer c.mu.Unlock()
if last, ok := c.resourceAlerts[metric]; ok && now.Sub(last) < cooldown {
return false
}
c.resourceAlerts[metric] = now
return true
}
func (c *Checker) checkRoutes(ctx context.Context) {
if c.source == nil {
return
}
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
if c.health == nil {
recordHealth = false
}
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
recordMetrics := c.metricsHistoryEnabled(ctx)
// 三件事都关着就别去探测:RouteHealth 是这一轮里最贵的调用
if !recordHealth && !alertEnabled && !recordMetrics {
return
}
routes, err := c.source.Routes(ctx)
if err != nil {
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:
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未在 frps 上注册:%s", route.Domain, limit, detailOr(route.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "隧道已恢复",
fmt.Sprintf("%s 已重新在 frps 上注册", route.Domain))
}
}
}
// checkCloudflare 把 Cloudflare 隧道纳入现有巡检、通知与健康记录。
func (c *Checker) checkCloudflare(ctx context.Context) {
source, ok := c.source.(CloudflareHealthSource)
if !ok {
return
}
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
recorder, canRecord := c.source.(CloudflareHealthRecorder)
if !canRecord {
recordHealth = false
}
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
if !recordHealth && !alertEnabled {
return
}
routes, err := source.CloudflareRoutes(ctx)
if err != nil {
c.logger.Warn("巡检 Cloudflare 隧道状态失败", "module", "MONITOR", "error", err.Error())
return
}
if recordHealth {
samples := make([]CloudflareHealthSample, 0, len(routes))
for _, route := range routes {
if strings.TrimSpace(route.Domain) == "" {
continue
}
samples = append(samples, CloudflareHealthSample{
TunnelID: route.TunnelID,
Name: route.Name,
Domain: route.Domain,
State: route.State,
Reason: route.Reason,
Detail: route.Detail,
})
}
if err := recorder.RecordCloudflareHealth(ctx, samples); err != nil {
c.logger.Warn("记录 Cloudflare 隧道健康失败", "module", "MONITOR", "error", err.Error())
}
c.pruneCloudflareHealthIfDue(ctx, recorder)
}
if !alertEnabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
if limit <= 0 {
return
}
for _, route := range routes {
if !route.Known || strings.TrimSpace(route.Domain) == "" {
continue
}
_, event := c.track(fmt.Sprintf("cloudflare:%d:%s", route.TunnelID, route.Domain), route.State == HealthUp, limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道连续不通",
fmt.Sprintf("%s 连续 %d 次巡检未连接:%s", route.Domain, limit, detailOr(route.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventTunnelDown, "Cloudflare 隧道已恢复",
fmt.Sprintf("%s 已重新连接 Cloudflare 边缘", route.Domain))
}
}
}
func (c *Checker) pruneCloudflareHealthIfDue(ctx context.Context, recorder CloudflareHealthRecorder) {
c.mu.Lock()
due := c.lastCFHealthPrune.IsZero() || time.Since(c.lastCFHealthPrune) >= 24*time.Hour
if due {
c.lastCFHealthPrune = time.Now()
}
c.mu.Unlock()
if !due {
return
}
if err := recorder.PruneCloudflareHealth(ctx); err != nil {
c.logger.Warn("清理 Cloudflare 隧道健康记录失败", "module", "MONITOR", "error", err.Error())
}
}
// 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) {
if c.source == nil {
return
}
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnFrpsDown); !enabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyFrpsFailThreshold)
if limit <= 0 {
return
}
servers, err := c.source.Servers(ctx)
if err != nil {
c.logger.Warn("巡检服务端状态失败", "module", "MONITOR", "error", err.Error())
return
}
for _, server := range servers {
label := serverLabel(server.ID, server.Name)
_, event := c.track(fmt.Sprintf("server:%d", server.ID), !serverDown(server), limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventFrpsServerDown, "公网服务端掉线",
fmt.Sprintf("%s 连续 %d 次巡检未运行或未连接(进程 %s / 连接 %s):%s",
label, limit, stateOr(server.ProcessState), stateOr(server.ConnectionState), detailOr(server.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventFrpsServerDown, "公网服务端已恢复",
fmt.Sprintf("%s 已重新运行并连接", label))
}
}
}
// checkAgents agent 不可达:VPS 重启后 agent 没起来是最常见的成因。
func (c *Checker) checkAgents(ctx context.Context) {
if c.source == nil {
return
}
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnAgentUnreachable); !enabled {
return
}
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyAgentFailThreshold)
if limit <= 0 {
return
}
agents, err := c.source.Agents(ctx)
if err != nil {
c.logger.Warn("巡检 agent 可达性失败", "module", "MONITOR", "error", err.Error())
return
}
for _, agent := range agents {
label := serverLabel(agent.ID, agent.Name)
_, event := c.track(fmt.Sprintf("agent:%d", agent.ID), agent.Reachable, limit)
switch event {
case failureEventDown:
c.notify.Alert(ctx, notify.EventAgentUnreachable, "公网 agent 不可达",
fmt.Sprintf("%s 的 havline-agent 连续 %d 次无响应:%s", label, limit, detailOr(agent.Detail)))
case failureEventRecovered:
c.notify.Alert(ctx, notify.EventAgentUnreachable, "公网 agent 已恢复",
fmt.Sprintf("%s 的 havline-agent 已恢复响应", label))
}
}
}
// checkDrift 配置漂移:VPS 上的 vhost 与 Havline 记录不一致(被手工改过、或 agent 重装后没重部署)。
// 它不是「连续 N 次」型故障,因此用冷却时间限流:首次发现即报,之后每 cooldown 提醒一次,漂移清空时补一条恢复。
func (c *Checker) checkDrift(ctx context.Context) {
if c.source == nil {
return
}
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnRouteDrift); !enabled {
return
}
cooldownHours, _ := c.settings.GetInt(ctx, settings.KeyNotifyDriftCooldownHours)
if cooldownHours <= 0 {
cooldownHours = notify.DefaultDriftCooldownHours
}
cooldown := time.Duration(cooldownHours) * time.Hour
reports, err := c.source.Drift(ctx)
if err != nil {
c.logger.Warn("巡检配置漂移失败", "module", "MONITOR", "error", err.Error())
return
}
now := time.Now()
for _, report := range reports {
c.mu.Lock()
alerted := c.driftAlerted[report.ID]
last := c.driftNotified[report.ID]
if report.Count == 0 {
delete(c.driftAlerted, report.ID)
delete(c.driftNotified, report.ID)
}
c.mu.Unlock()
label := serverLabel(report.ID, report.Name)
if report.Count == 0 {
if alerted {
c.notify.Alert(ctx, notify.EventRouteDrift, "配置漂移已消除",
fmt.Sprintf("%s 上的 vhost 已与 Havline 记录一致", label))
}
continue
}
if alerted && !last.IsZero() && now.Sub(last) < cooldown {
continue
}
c.mu.Lock()
c.driftAlerted[report.ID] = true
c.driftNotified[report.ID] = now
c.mu.Unlock()
c.notify.Alert(ctx, notify.EventRouteDrift, "公网反代配置漂移",
fmt.Sprintf("%s 有 %d 项与 Havline 记录不一致:%s", label, report.Count, strings.Join(report.Items, "、")))
}
}
// track 推进某个实体的连续失败计数,返回本轮要发的事件。
// 锁只包住 map 读写:notify 一律在锁外调用。
func (c *Checker) track(key string, healthy bool, limit int) (int, failureEvent) {
c.mu.Lock()
defer c.mu.Unlock()
failures, event := failureTransition(c.failures[key], healthy, limit)
c.failures[key] = failures
return failures, event
}
type failureEvent string
const (
failureEventNone failureEvent = ""
failureEventDown failureEvent = "down"
failureEventRecovered failureEvent = "recovered"
)
// failureTransition 推进某个实体的连续失败计数,返回新计数与本轮要发的事件。
// 只在「刚好达到阈值」与「从失败中恢复」两个时刻发,避免每轮巡检都刷通知。
func failureTransition(previous int, healthy bool, limit int) (int, failureEvent) {
if healthy {
if previous >= limit {
return 0, failureEventRecovered
}
return 0, failureEventNone
}
next := previous + 1
if next == limit {
return next, failureEventDown
}
return next, failureEventNone
}
// serverDown 判定服务端是否处于「用户要它运行、但它没在跑或没连上」。
// ConnectionState 为 unknown 时按健康处理:状态源(manager+log)偶尔读不到不代表掉线,避免误报。
func serverDown(status ServerStatus) bool {
if status.DesiredState != "running" && status.DesiredState != "starting" {
return false
}
if status.ProcessState == "stopped" || status.ProcessState == "error" {
return true
}
return status.ConnectionState == "disconnected" || status.ConnectionState == "error"
}
func serverLabel(id int64, name string) string {
if trimmed := strings.TrimSpace(name); trimmed != "" {
return trimmed
}
return fmt.Sprintf("服务端 #%d", id)
}
// stateOr 让通知里的状态字段不会因为空串而变成「(进程 / 连接 )」。
func stateOr(state string) string {
if strings.TrimSpace(state) == "" {
return "未知"
}
return state
}
func percent(used, total uint64) float64 {
if total == 0 {
return 0
}
return float64(used) / float64(total) * 100
}
func detailOr(detail string) string {
if strings.TrimSpace(detail) == "" {
return "未返回具体原因"
}
return detail
}
+109
View File
@@ -0,0 +1,109 @@
package monitor
import "testing"
func TestFailureTransitionAlertsOncePerOutage(t *testing.T) {
const limit = 3
failures := 0
var events []failureEvent
// 连续三轮不通:只在第 3 轮(刚好达到阈值)发一次
for i := 0; i < 5; i++ {
var event failureEvent
failures, event = failureTransition(failures, false, limit)
if event != failureEventNone {
events = append(events, event)
}
}
if len(events) != 1 || events[0] != failureEventDown {
t.Fatalf("连续不通应只告警一次,实际 %v", events)
}
if failures != 5 {
t.Fatalf("失败计数应继续累加,实际 %d", failures)
}
// 恢复:发一次恢复通知并清零
failures, event := failureTransition(failures, true, limit)
if event != failureEventRecovered || failures != 0 {
t.Fatalf("恢复应通知一次并清零:event=%v failures=%d", event, failures)
}
// 恢复后再次不通:还能再报一次
failures, event = failureTransition(failures, false, limit)
if event != failureEventNone || failures != 1 {
t.Fatalf("恢复后重新计数:event=%v failures=%d", event, failures)
}
failures, event = failureTransition(failures, false, limit)
if event != failureEventNone {
t.Fatalf("未达阈值不应告警:%v", event)
}
if _, event = failureTransition(failures, false, limit); event != failureEventDown {
t.Fatalf("第二次中断达到阈值应再告警一次:%v", event)
}
}
func TestFailureTransitionBelowThresholdIsSilent(t *testing.T) {
// 单次抖动(未达阈值)不打扰用户,也不算作恢复
if failures, event := failureTransition(0, true, 3); failures != 0 || event != failureEventNone {
t.Fatalf("健康状态不应产生事件:%d %v", failures, event)
}
if failures, event := failureTransition(1, true, 3); failures != 0 || event != failureEventNone {
t.Fatalf("未达阈值的恢复不应通知:%d %v", failures, event)
}
}
func TestPercent(t *testing.T) {
if got := percent(0, 0); got != 0 {
t.Fatalf("总数为 0 时应返回 0,实际 %v", got)
}
if got := percent(50, 200); got != 25 {
t.Fatalf("期望 25,实际 %v", got)
}
}
func TestDetailOrFallsBack(t *testing.T) {
if got := detailOr(" "); got != "未返回具体原因" {
t.Fatalf("空详情应给出兜底文案,实际 %q", got)
}
if got := detailOr("dashboard 超时"); got != "dashboard 超时" {
t.Fatalf("非空详情应原样返回,实际 %q", got)
}
}
func TestServerDownOnlyWhenUserWantsItRunning(t *testing.T) {
// 用户手动停掉的不算掉线
stopped := ServerStatus{DesiredState: "stopped", ProcessState: "stopped", ConnectionState: "disconnected"}
if serverDown(stopped) {
t.Fatal("用户手动停止的服务端不应判为掉线")
}
// 期望运行但进程没在跑
if !serverDown(ServerStatus{DesiredState: "running", ProcessState: "stopped"}) {
t.Fatal("期望运行但进程未运行应判为掉线")
}
// 进程在跑但连不上 frps
if !serverDown(ServerStatus{DesiredState: "running", ProcessState: "running", ConnectionState: "disconnected"}) {
t.Fatal("连接断开应判为掉线")
}
// 状态源读不到连接状态时不报警:manager+log 偶尔读不到不代表掉线
if serverDown(ServerStatus{DesiredState: "running", ProcessState: "running", ConnectionState: "unknown"}) {
t.Fatal("连接状态未知时不应误报")
}
}
func TestServerLabelFallsBackToID(t *testing.T) {
if got := serverLabel(3, " "); got != "服务端 #3" {
t.Fatalf("无名服务端应回落到编号,实际 %q", got)
}
if got := serverLabel(3, "VPS-香港"); got != "VPS-香港" {
t.Fatalf("有名字应直接用名字,实际 %q", got)
}
}
func TestStateOrFallsBackToPlaceholder(t *testing.T) {
if got := stateOr(""); got != "未知" {
t.Fatalf("空状态应回落到占位文案,实际 %q", got)
}
if got := stateOr("error"); got != "error" {
t.Fatalf("非空状态应原样返回,实际 %q", got)
}
}