- 自动备份:每日一份(按最新备份是否在 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:
@@ -2,6 +2,41 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.2.11] - 2026-09-25
|
||||
|
||||
### 新增
|
||||
|
||||
- **定时自动备份 + 保留策略**:每天自动备份一份到 `<DataDir>/backups`,超过保留份数自动清理最旧的;重启不会重复备份(按「最新备份是否在 24 小时内」判定)。设置页「数据」卡可开关、改份数、立即备份,并列出/下载/恢复/删除备份,**恢复前先校验归档**
|
||||
- **健康检查持续化 + 公开状态页**:规则的四段健康结论按**状态变更**落库(采样间隔即巡检轮次),由此推导可用率与不可用区间;设置页「高级」新增「公开状态页」——默认关闭,可设访问码(只存 SHA-256、不下发明文),按 IP 限流,只暴露域名与可用率
|
||||
- **指标历史与趋势图**:新增纵向点表 `metrics_samples`,记录本机 CPU / 内存 / 磁盘与每条规则的上行/下行速率(采样跟随巡检,30 天保留);仪表盘新增「资源与流量趋势」卡,支持 1 小时 / 24 小时 / 7 天
|
||||
- 告警事件新增「自动备份失败」(默认开启)
|
||||
|
||||
### 修复
|
||||
|
||||
- **隧道探活:「判不了」不再当成「不通」**:`RouteHealthCandidate` 新增 `tunnel_known`,frps 管理接口未配置或不可用时不再把 `tunnel_ok=false` 当成故障——此前会对这类用户误报「隧道连续不通」
|
||||
- 健康汇总同样区分「无法判定」与「不通」:新增 `tunnel_unknown` 计数,规则表徽标用警告色并说明「不代表隧道不通」,「无法判定」不再算作不健康
|
||||
|
||||
### 改进
|
||||
|
||||
- 巡检每轮只采一次主机资源,同时喂给「资源阀值告警」与「指标历史」(CPU 占用率靠两次采样的差值,采两次会互相干扰)
|
||||
- 流量聚合抽出 `aggregateHostKeys`,端点聚合与按域名聚合共用同一套速率算法,避免两处漂移
|
||||
- 仪表盘:新增趋势卡;「运行状态」并入顶部统计区(运行时长补进「服务」卡);「性能」并入系统资源那一行,整行 4 列
|
||||
- 趋势图:图例与绘图区留白调整;点少时画点(此前只有一两个点时线根本画不出来);空桶断开而不是补 0
|
||||
|
||||
### 测试
|
||||
|
||||
- `internal/backup`:导出 + 校验往返、校验拒绝非 gzip、列表/剪枝/名称白名单、到期判断
|
||||
- `internal/metrics`:分桶平均与空桶不产点、窗口外的点、退化输入
|
||||
- `internal/frp`:可用率推导(区间 + unknown、仍在持续、无记录不出结果)、健康汇总的 unknown 用例
|
||||
- `internal/api`:状态页限流与访问码哈希
|
||||
- `internal/app`:monitor 与 frp 两侧状态字面量的翻译
|
||||
|
||||
### 变更
|
||||
|
||||
- 新增迁移 `026_route_health.sql`、`027_metrics_samples.sql`(启动时自动执行)
|
||||
- agent 无改动,`AgentVersion` 保持 **0.12.2**,本轮不需要在 VPS 上重新升级 Agent
|
||||
- 版本号提升到 1.2.11;`web/package.json` 同步对齐
|
||||
|
||||
## [v1.2.10] - 2026-09-25
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -280,6 +280,18 @@
|
||||
|
||||
**结构变化**:`internal/monitor` 的注入方式从「一个 routes 闭包」改成 `Source` 接口(Routes / Servers / Agents / Drift),frp 侧适配放在新文件 `internal/app/monitor_source.go`——monitor 依旧不依赖 frp 包,新增数据源只需扩接口。三条新检查与原有隧道巡检共用 `failureTransition`(原 `tunnelTransition` 泛化),漂移因不是「连续失败」型而单独用冷却;`notify.Alert` 一律在锁外调用。
|
||||
|
||||
## 十一、P1 实施状态(2026-09-25)
|
||||
|
||||
| 项 | 状态 | 落点与关键决定 |
|
||||
| --- | --- | --- |
|
||||
| 7 定时自动备份 + 保留策略 | ✅ | `internal/backup`:导出改 `writeArchive` 逐个检查 tar→gzip→文件的 `Close()`(修审查报告 §二);`AutoRunner` 按「最新备份是否在 24h 内」判定,重启不重复备份;导出后回读校验 |
|
||||
| 6 健康检查持续化 + 只读状态页 | ✅ | 先修 `tunnel_known`(判不了 ≠ 不通,修掉了新告警的误报根因);`route_health_events` 只存状态变更(up/down/unknown),可用率与不可用区间由它推导;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率 |
|
||||
| 5 指标历史与趋势图 | ✅ | 纵向点表 `(scope, metric, value, at)`,**不上降采样表**(每 2 分钟采样行量极小,查询时按桶取平均);本机资源 + 每规则上下行速率(`SnapshotByHost` 与端点聚合共用 `aggregateHostKeys`);仪表盘趋势卡 1h/24h/7d |
|
||||
| 8 一键迁移包 | ⬜ 未做 | 建议缩范围:先只做「导出加密包 + 只允许导入到空实例」 |
|
||||
| 9 Docker 容器统计 | ⬜ 未做 | 要动 agent(递增 `AgentVersion` + 重编 + 用户升级),建议排在「本机 SMART + UPS」之后 |
|
||||
|
||||
**本轮教训(记一下免得重犯)**:`vue-tsc` 不校验模板标签闭合,模板结构错误只有 `npx vite build` 会拦(本轮搬卡片时标签失衡就是它报的 `Element is missing end tag`)——前端改动两个都要跑。
|
||||
|
||||
---
|
||||
|
||||
> 说明:本文件是**建议清单**,未改动任何代码;实施时按本仓库既有约定(每步验证 `go build/vet/test` + `vue-tsc/vite build`、agent 行为变更递增 `AgentVersion`、提交同批更新 `CHANGELOG` 与 `VERSION`)。
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
svc *backup.Service
|
||||
settings *settings.Store
|
||||
}
|
||||
|
||||
func NewBackupHandler(svc *backup.Service) *BackupHandler {
|
||||
return &BackupHandler{svc: svc}
|
||||
func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *BackupHandler {
|
||||
return &BackupHandler{svc: svc, settings: settingsStore}
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -72,3 +74,94 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
_ = os.Remove(tmpPath)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
}
|
||||
|
||||
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
|
||||
// 与「立即下载」不同:这些文件长期留在 <DataDir>/backups 下。
|
||||
func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
|
||||
archives, err := h.svc.List()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取备份列表失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"archives": archives,
|
||||
"keep": backup.KeepCount(r.Context(), h.settings),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
|
||||
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Export(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
// 写失败的备份不能留在列表里冒充可用备份
|
||||
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||
_ = h.svc.Remove(name)
|
||||
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
removed, err := h.svc.Prune(backup.KeepCount(r.Context(), h.settings))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "备份已创建,但清理旧备份失败:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "removed": removed})
|
||||
}
|
||||
|
||||
// DownloadArchive 下载指定备份;用 ServeContent 支持断点续传
|
||||
func (h *BackupHandler) DownloadArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Path(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusNotFound, "备份不存在")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取备份失败", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// RestoreArchive 从指定备份恢复。恢复会覆盖数据库与证书,前端必须二次确认
|
||||
func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Path(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// 恢复前先校验:半个包不能拿去覆盖现有数据
|
||||
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
if err := h.svc.Restore(r.Context(), path); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
}
|
||||
|
||||
// DeleteArchive 删除指定备份
|
||||
func (h *BackupHandler) DeleteArchive(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Remove(r.PathValue("name")); err != nil {
|
||||
if err.Error() == "备份不存在" {
|
||||
writeError(r, w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
@@ -28,6 +29,7 @@ type Deps struct {
|
||||
Settings *settings.Store
|
||||
ChinaCIDR *chinacidr.Service
|
||||
Backup *backup.Service
|
||||
Metrics *metrics.Store
|
||||
Discovery *discovery.Service
|
||||
FRPMulti *frp.Service
|
||||
Traffic *traffic.Collector
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
)
|
||||
@@ -439,6 +440,23 @@ func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": domain, "content": content})
|
||||
}
|
||||
|
||||
// RouteUptime 返回每条反代规则在最近窗口内的可用率与不可用区间。
|
||||
// 数据来自后台巡检落库的「状态变更」序列,不做现场探测。
|
||||
func (h *FRPMultiHandler) RouteUptime(w http.ResponseWriter, r *http.Request) {
|
||||
hours := 24
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*7 {
|
||||
hours = parsed
|
||||
}
|
||||
}
|
||||
stats, err := h.svc.RouteUptime(r.Context(), time.Duration(hours)*time.Hour)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取可用率失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"hours": hours, "items": stats})
|
||||
}
|
||||
|
||||
// AgentRouteVersions 返回规则首个域名在 VPS 上的当前 vhost 与历史版本,供差异对比与回滚。
|
||||
func (h *FRPMultiHandler) AgentRouteVersions(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
)
|
||||
|
||||
type MetricsHandler struct {
|
||||
store *metrics.Store
|
||||
}
|
||||
|
||||
func NewMetricsHandler(store *metrics.Store) *MetricsHandler {
|
||||
return &MetricsHandler{store: store}
|
||||
}
|
||||
|
||||
// History 返回指标历史:窗口内的序列已按桶取平均,前端拿到就能直接画。
|
||||
// hours 最多 30 天(=保留期),buckets 最多 metrics.MaxBuckets。
|
||||
func (h *MetricsHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||
hours := 24
|
||||
if raw := r.URL.Query().Get("hours"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*30 {
|
||||
hours = parsed
|
||||
}
|
||||
}
|
||||
buckets := 48
|
||||
if raw := r.URL.Query().Get("buckets"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||
buckets = parsed
|
||||
}
|
||||
}
|
||||
if buckets > metrics.MaxBuckets {
|
||||
buckets = metrics.MaxBuckets
|
||||
}
|
||||
series, err := h.store.Query(r.Context(), time.Duration(hours)*time.Hour, buckets)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取指标历史失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"hours": hours,
|
||||
"buckets": buckets,
|
||||
"series": series,
|
||||
})
|
||||
}
|
||||
+14
-1
@@ -22,7 +22,9 @@ func NewRouter(deps Deps) http.Handler {
|
||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||
logsHandler: logsHandler,
|
||||
backupHandler: NewBackupHandler(deps.Backup),
|
||||
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
|
||||
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
|
||||
metricsHandler: NewMetricsHandler(deps.Metrics),
|
||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
||||
authSvc: deps.Auth,
|
||||
@@ -32,6 +34,8 @@ func NewRouter(deps Deps) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
||||
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||
mux.HandleFunc("GET /api/status-page", r.statusPageHandler.Get)
|
||||
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
|
||||
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
|
||||
@@ -88,9 +92,15 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/logs/access", r.logsHandler.Access)
|
||||
protect("GET /api/logs/error", r.logsHandler.Error)
|
||||
protect("GET /api/logs/system", r.logsHandler.System)
|
||||
protect("GET /api/metrics/history", r.metricsHandler.History)
|
||||
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
||||
protect("GET /api/backup/export", r.backupHandler.Export)
|
||||
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
||||
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
|
||||
protect("POST /api/backup/archives", r.backupHandler.CreateArchive)
|
||||
protect("GET /api/backup/archives/{name}", r.backupHandler.DownloadArchive)
|
||||
protect("POST /api/backup/archives/{name}/restore", r.backupHandler.RestoreArchive)
|
||||
protect("DELETE /api/backup/archives/{name}", r.backupHandler.DeleteArchive)
|
||||
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
|
||||
if deps.FRPMulti != nil {
|
||||
frpHandler := r.frpmultiHandler
|
||||
@@ -122,6 +132,7 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||
protect("GET /api/frpmulti/routes/uptime", frpHandler.RouteUptime)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
|
||||
@@ -175,6 +186,8 @@ type Router struct {
|
||||
chinaCIDRHandler *ChinaCIDRHandler
|
||||
logsHandler *LogsHandler
|
||||
backupHandler *BackupHandler
|
||||
statusPageHandler *StatusPageHandler
|
||||
metricsHandler *MetricsHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
authSvc *auth.Service
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
@@ -29,6 +30,11 @@ func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||
}
|
||||
delete(values, settings.KeyStatusPageCodeHash)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
@@ -80,6 +86,13 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyNotifyFrpsFailThreshold: true,
|
||||
settings.KeyNotifyAgentFailThreshold: true,
|
||||
settings.KeyNotifyDriftCooldownHours: true,
|
||||
settings.KeyBackupAutoEnabled: true,
|
||||
settings.KeyRouteHealthEnabled: true,
|
||||
settings.KeyStatusPageEnabled: true,
|
||||
settings.KeyMetricsHistoryEnabled: true,
|
||||
settings.KeyStatusPageCode: true,
|
||||
settings.KeyBackupKeepCount: true,
|
||||
settings.KeyNotifyOnBackupFailure: true,
|
||||
settings.KeyTrustedProxy: true,
|
||||
settings.KeyGlobalIPBlacklist: true,
|
||||
settings.KeyGlobalIPWhitelist: true,
|
||||
@@ -98,6 +111,19 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
|
||||
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
|
||||
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
|
||||
delete(filtered, settings.KeyStatusPageCode)
|
||||
trimmed := strings.TrimSpace(code)
|
||||
switch {
|
||||
case trimmed == MaskedStatusPageCode:
|
||||
case trimmed == "":
|
||||
filtered[settings.KeyStatusPageCodeHash] = ""
|
||||
default:
|
||||
filtered[settings.KeyStatusPageCodeHash] = hashStatusPageCode(trimmed)
|
||||
}
|
||||
}
|
||||
if h.notify != nil && h.notify.Store() != nil {
|
||||
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
|
||||
if err := h.notify.Store().Save(r.Context(), in); err != nil {
|
||||
@@ -127,6 +153,11 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||
}
|
||||
delete(values, settings.KeyStatusPageCodeHash)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
@@ -163,6 +194,7 @@ func isNotifySettingKey(key string) bool {
|
||||
settings.KeyNotifyFrpsFailThreshold,
|
||||
settings.KeyNotifyAgentFailThreshold,
|
||||
settings.KeyNotifyDriftCooldownHours,
|
||||
settings.KeyNotifyOnBackupFailure,
|
||||
settings.KeyNotifyWebhookURL:
|
||||
return true
|
||||
default:
|
||||
@@ -199,6 +231,13 @@ func (h *SettingsHandler) validateSetting(key, value string) error {
|
||||
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyBackupKeepCount:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 1 || n > 60 {
|
||||
return fmt.Errorf("备份保留份数需在 1–60 之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyNotifyLoginFailureThreshold:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
|
||||
// 状态页会被扫描器盯上,这里比登录接口更宽松但仍有限度。
|
||||
const (
|
||||
statusPageWindow = time.Minute
|
||||
statusPageLimit = 60
|
||||
)
|
||||
|
||||
// StatusPageHandler 提供**匿名只读**状态页数据,默认关闭。
|
||||
//
|
||||
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
|
||||
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
|
||||
type StatusPageHandler struct {
|
||||
settings *settings.Store
|
||||
frp *frp.Service
|
||||
limiter *statusPageLimiter
|
||||
}
|
||||
|
||||
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *StatusPageHandler {
|
||||
return &StatusPageHandler{
|
||||
settings: settingsStore,
|
||||
frp: frpSvc,
|
||||
limiter: newStatusPageLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
type statusPageRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
State string `json:"state"`
|
||||
Uptime float64 `json:"uptime_pct"`
|
||||
DownMinutes int `json:"down_minutes"`
|
||||
UnknownMinutes int `json:"unknown_minutes"`
|
||||
}
|
||||
|
||||
type statusPagePayload struct {
|
||||
Title string `json:"title"`
|
||||
CodeRequired bool `json:"code_required"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Up int `json:"up"`
|
||||
Down int `json:"down"`
|
||||
Unknown int `json:"unknown"`
|
||||
Routes []statusPageRoute `json:"routes"`
|
||||
}
|
||||
|
||||
// Get 是唯一的匿名入口:关闭时一律 404(与「不存在」不可区分),
|
||||
// 设了访问码时校验 ?code=,校验失败也回 404。
|
||||
func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.settings == nil {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
if !h.limiter.allow(clientIPForLimit(r)) {
|
||||
writeError(r, w, http.StatusTooManyRequests, "请求过于频繁")
|
||||
return
|
||||
}
|
||||
|
||||
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
|
||||
if !enabled || h.frp == nil {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
codeHash, err := h.settings.Get(r.Context(), settings.KeyStatusPageCodeHash)
|
||||
if err != nil {
|
||||
codeHash = ""
|
||||
}
|
||||
codeRequired := strings.TrimSpace(codeHash) != ""
|
||||
if codeRequired && hashStatusPageCode(r.URL.Query().Get("code")) != codeHash {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := statusPagePayload{
|
||||
Title: "Fonu 服务状态",
|
||||
CodeRequired: codeRequired,
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Routes: make([]statusPageRoute, 0, len(stats)),
|
||||
}
|
||||
for _, stat := range stats {
|
||||
switch stat.State {
|
||||
case frp.RouteHealthUp:
|
||||
payload.Up++
|
||||
case frp.RouteHealthDown:
|
||||
payload.Down++
|
||||
default:
|
||||
payload.Unknown++
|
||||
}
|
||||
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||
Domain: stat.Domain,
|
||||
State: stat.State,
|
||||
Uptime: stat.Uptime,
|
||||
DownMinutes: stat.DownMinutes,
|
||||
UnknownMinutes: stat.UnknownMinutes,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func hashStatusPageCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// clientIPForLimit 取限流用的客户端 IP
|
||||
func clientIPForLimit(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// statusPageLimiter 按 IP 的滑动窗口限流;只给匿名状态页用
|
||||
type statusPageLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newStatusPageLimiter() *statusPageLimiter {
|
||||
return &statusPageLimiter{hits: map[string][]time.Time{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (l *statusPageLimiter) allow(ip string) bool {
|
||||
now := l.now()
|
||||
cutoff := now.Add(-statusPageWindow)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
kept := make([]time.Time, 0, len(l.hits[ip])+1)
|
||||
for _, at := range l.hits[ip] {
|
||||
if at.After(cutoff) {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
if len(kept) >= statusPageLimit {
|
||||
l.hits[ip] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[ip] = append(kept, now)
|
||||
// 顺手清理长期不活跃的 IP,避免 map 无限增长
|
||||
if len(l.hits) > 1024 {
|
||||
for key, times := range l.hits {
|
||||
if len(times) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MaskedStatusPageCode 供设置接口回显用(与通知模块的掩码约定一致)
|
||||
const MaskedStatusPageCode = notify.MaskedSecret
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStatusPageLimiterBlocksAfterLimit(t *testing.T) {
|
||||
limiter := newStatusPageLimiter()
|
||||
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < statusPageLimit; i++ {
|
||||
if !limiter.allow("1.2.3.4") {
|
||||
t.Fatalf("第 %d 次请求应放行", i+1)
|
||||
}
|
||||
}
|
||||
if limiter.allow("1.2.3.4") {
|
||||
t.Fatal("超过配额应拒绝")
|
||||
}
|
||||
// 限流按 IP 计:别的访客不该被牵连
|
||||
if !limiter.allow("5.6.7.8") {
|
||||
t.Fatal("其它 IP 不应被牵连")
|
||||
}
|
||||
// 窗口滑过后恢复
|
||||
now = now.Add(statusPageWindow + time.Second)
|
||||
if !limiter.allow("1.2.3.4") {
|
||||
t.Fatal("窗口过后应恢复放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashStatusPageCodeHidesPlaintext(t *testing.T) {
|
||||
hash := hashStatusPageCode("my-secret")
|
||||
if hash == "my-secret" || len(hash) != 64 {
|
||||
t.Fatalf("访问码应以 SHA-256 存储,实际 %q", hash)
|
||||
}
|
||||
if hashStatusPageCode(" my-secret ") != hash {
|
||||
t.Fatal("前后空白裁剪后应得到同一哈希")
|
||||
}
|
||||
if hashStatusPageCode("other") == hash {
|
||||
t.Fatal("不同访问码不应得到同一哈希")
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/monitor"
|
||||
"github.com/fonu/fonu/internal/traffic"
|
||||
)
|
||||
|
||||
// monitorSource 把 frp 服务适配成 monitor.Source:monitor 只关心「状态快照」,
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
type monitorSource struct {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
// 保留份数的边界:0/负数按默认值处理,上限防止把磁盘塞满
|
||||
const (
|
||||
DefaultKeepCount = 7
|
||||
MaxKeepCount = 60
|
||||
)
|
||||
|
||||
// archiveNamePattern 备份命名格式(Export 用 CreateTemp 生成,后缀是随机数字)。
|
||||
// List / Path 都按它过滤,避免把 backups 目录里任意文件当成备份读写或删除。
|
||||
var archiveNamePattern = regexp.MustCompile(`^fonu-backup-\d{8}-\d{6}-\d+\.tar\.gz$`)
|
||||
|
||||
// Archive 留在磁盘上的一份备份
|
||||
type Archive struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
}
|
||||
|
||||
func (s *Service) backupsDir() string {
|
||||
return filepath.Join(s.dataDir, "backups")
|
||||
}
|
||||
|
||||
// List 按新的在前列出备份;目录不存在返回空列表(还没备份过是正常状态)。
|
||||
func (s *Service) List() ([]Archive, error) {
|
||||
entries, err := os.ReadDir(s.backupsDir())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
archives := make([]Archive, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !archiveNamePattern.MatchString(entry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
archives = append(archives, Archive{Name: entry.Name(), Size: info.Size(), ModTime: info.ModTime().UTC()})
|
||||
}
|
||||
// 文件名前缀就是时间戳,按名倒序即按时间倒序
|
||||
sort.Slice(archives, func(i, j int) bool { return archives[i].Name > archives[j].Name })
|
||||
return archives, nil
|
||||
}
|
||||
|
||||
// Prune 只保留最新的 keep 份,返回删除数量;keep <= 0 表示不剪枝。
|
||||
func (s *Service) Prune(keep int) (int, error) {
|
||||
if keep <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
archives, err := s.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
removed := 0
|
||||
for _, archive := range archives[min(keep, len(archives)):] {
|
||||
if err := s.Remove(archive.Name); err != nil {
|
||||
return removed, err
|
||||
}
|
||||
removed++
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// Path 把备份名解析成绝对路径;名字必须匹配备份命名格式,避免被用来读写目录外的文件。
|
||||
func (s *Service) Path(name string) (string, error) {
|
||||
if !archiveNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("备份名无效")
|
||||
}
|
||||
return filepath.Join(s.backupsDir(), name), nil
|
||||
}
|
||||
|
||||
// Remove 删除指定备份
|
||||
func (s *Service) Remove(name string) error {
|
||||
path, err := s.Path(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("备份不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify 回读归档并返回条目数:确认这份备份不是写了一半的残件,
|
||||
// 同时要求包内必须有 fonu.db(否则多半不是 Fonu 的备份)。
|
||||
func (s *Service) Verify(ctx context.Context, path string) (int, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gr, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("备份文件无法解压:%w", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
tr := tar.NewReader(gr)
|
||||
count := 0
|
||||
hasDatabase := false
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("备份文件已损坏:%w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
if header.Name == "fonu.db" {
|
||||
hasDatabase = true
|
||||
}
|
||||
}
|
||||
if !hasDatabase {
|
||||
return count, fmt.Errorf("备份里没有 fonu.db,可能不是 Fonu 的备份")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// KeepCount 读取保留份数(未配置或越界时回落到默认值)。
|
||||
// 自动备份任务与「立即备份」共用它,避免两处各写一套默认值。
|
||||
func KeepCount(ctx context.Context, store *settings.Store) int {
|
||||
keep, err := store.GetInt(ctx, settings.KeyBackupKeepCount)
|
||||
if err != nil || keep <= 0 {
|
||||
return DefaultKeepCount
|
||||
}
|
||||
if keep > MaxKeepCount {
|
||||
return MaxKeepCount
|
||||
}
|
||||
return keep
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
// AutoInterval 自动备份间隔。scheduler 启动时会先跑一轮,靠「最新备份是否还在间隔内」
|
||||
// 判断该不该备份,因此频繁重启(NAS 上很常见)不会重复备份。
|
||||
const AutoInterval = 24 * time.Hour
|
||||
|
||||
// AutoRunner 定期导出一份归档、回读校验、再按保留份数剪枝。
|
||||
type AutoRunner struct {
|
||||
svc *Service
|
||||
settings *settings.Store
|
||||
notify *notify.Service
|
||||
logger *slog.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAutoRunner(svc *Service, settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger) *AutoRunner {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AutoRunner{svc: svc, settings: settingsStore, notify: notifySvc, logger: logger, now: time.Now}
|
||||
}
|
||||
|
||||
// Run 跑一轮。失败只通知、不返回错误:scheduler 的任务没有重试机制,通知才是可见性来源。
|
||||
func (r *AutoRunner) Run(ctx context.Context) {
|
||||
if r == nil || r.svc == nil || r.settings == nil {
|
||||
return
|
||||
}
|
||||
enabled, err := r.settings.GetBool(ctx, settings.KeyBackupAutoEnabled)
|
||||
if err != nil || !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
archives, err := r.svc.List()
|
||||
if err != nil {
|
||||
r.logger.Warn("读取备份列表失败", "module", "BACKUP", "error", err.Error())
|
||||
return
|
||||
}
|
||||
if len(archives) > 0 && !dueForBackup(archives[0].ModTime, r.now(), AutoInterval) {
|
||||
return
|
||||
}
|
||||
|
||||
path, err := r.svc.Export(ctx)
|
||||
if err != nil {
|
||||
r.fail(ctx, fmt.Sprintf("导出备份失败:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
entries, err := r.svc.Verify(ctx, path)
|
||||
if err != nil {
|
||||
// 校验不过的归档不算一份可用备份:删掉并如实告警,别让用户以为已经有备份
|
||||
if removeErr := r.svc.Remove(filepath.Base(path)); removeErr != nil {
|
||||
r.logger.Warn("删除校验失败的备份失败", "module", "BACKUP", "error", removeErr.Error())
|
||||
}
|
||||
r.fail(ctx, fmt.Sprintf("备份校验失败:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
removed, err := r.svc.Prune(KeepCount(ctx, r.settings))
|
||||
if err != nil {
|
||||
r.logger.Warn("剪枝旧备份失败", "module", "BACKUP", "error", err.Error())
|
||||
}
|
||||
r.logger.Info("自动备份完成", "module", "BACKUP",
|
||||
"file", filepath.Base(path), "entries", entries, "removed", removed)
|
||||
}
|
||||
|
||||
// dueForBackup 判断该不该做一次备份:没有备份,或最新一份已超过间隔。
|
||||
func dueForBackup(newest, now time.Time, interval time.Duration) bool {
|
||||
if newest.IsZero() {
|
||||
return true
|
||||
}
|
||||
return now.Sub(newest) >= interval
|
||||
}
|
||||
|
||||
func (r *AutoRunner) fail(ctx context.Context, message string) {
|
||||
r.logger.Error("自动备份失败", "module", "BACKUP", "error", message)
|
||||
if r.notify != nil {
|
||||
r.notify.Alert(ctx, notify.EventBackupFailure, "自动备份失败", message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+43
-28
@@ -26,45 +26,60 @@ func New(dataDir string) *Service {
|
||||
return &Service{dataDir: dataDir}
|
||||
}
|
||||
|
||||
// backupTimeFmt 归档文件名里的时间戳;List / Path 都按同一套格式校验
|
||||
const backupTimeFmt = "20060102-150405"
|
||||
|
||||
// Export 导出一份归档并返回路径;文件留在 <DataDir>/backups 下,由调用方决定下载后删除还是长期保留。
|
||||
func (s *Service) Export(ctx context.Context) (string, error) {
|
||||
tmpDir := filepath.Join(s.dataDir, "backups")
|
||||
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(s.backupsDir(), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fmt.Sprintf("fonu-backup-%s.tar.gz", time.Now().Format("20060102-His"))
|
||||
outPath := filepath.Join(tmpDir, name)
|
||||
file, err := os.Create(outPath)
|
||||
// CreateTemp 保证同一秒内多次导出不会覆盖已有备份(文件名带随机后缀)
|
||||
file, err := os.CreateTemp(s.backupsDir(), fmt.Sprintf("fonu-backup-%s-*.tar.gz", time.Now().Format(backupTimeFmt)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
gw := gzip.NewWriter(file)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
entries := []string{
|
||||
"fonu.db",
|
||||
"certs",
|
||||
"nginx",
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
||||
_ = tw.Close()
|
||||
_ = gw.Close()
|
||||
_ = os.Remove(outPath)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
outPath := file.Name()
|
||||
if err := s.writeArchive(ctx, file, outPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
// writeArchive 写归档,逐层检查关闭错误。
|
||||
//
|
||||
// Close 的错误必须往上抛:tar/gzip 的尾部与文件系统的 flush 都发生在 Close,
|
||||
// 忽略它就会出现「备份文件不完整、函数却返回成功」——这是最坏的一种假成功。
|
||||
// 任一步失败都删掉半成品,不留一个看起来能用的备份。
|
||||
func (s *Service) writeArchive(ctx context.Context, file *os.File, outPath string) error {
|
||||
discard := func() {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(outPath)
|
||||
}
|
||||
|
||||
gw := gzip.NewWriter(file)
|
||||
tw := tar.NewWriter(gw)
|
||||
for _, entry := range []string{"fonu.db", "certs", "nginx"} {
|
||||
if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
|
||||
discard()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
discard()
|
||||
return err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
discard()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(outPath)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Restore(ctx context.Context, archivePath string) error {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -876,6 +876,8 @@ type RouteHealthSummary struct {
|
||||
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 {
|
||||
@@ -933,6 +939,9 @@ type RouteHealthCandidate struct {
|
||||
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:") {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package metrics 保存指标历史(纵向 (scope, metric, at) 点表)并按窗口查询。
|
||||
//
|
||||
// 采样跟随巡检轮次(2 分钟),行量极小,所以不建降采样表:查询时按桶取平均即可,
|
||||
// 这样只有一套数据、没有「原始数据与聚合数据不一致」这类问题。
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ScopeHost 本机资源;server:<id> / route:<serverID>:<domain> 留给后续序列
|
||||
ScopeHost = "host"
|
||||
|
||||
// 指标名:与前端展示一一对应,改动即改动契约
|
||||
MetricCPU = "cpu_percent"
|
||||
MetricMemUsed = "mem_used_bytes"
|
||||
MetricMemTotal = "mem_total_bytes"
|
||||
MetricDiskUsed = "disk_used_bytes"
|
||||
MetricDiskTotal = "disk_total_bytes"
|
||||
|
||||
// 流量序列按规则区分:scope 形如 route:<serverID>:<domain>
|
||||
MetricTrafficIn = "traffic_in_rate"
|
||||
MetricTrafficOut = "traffic_out_rate"
|
||||
|
||||
// MaxBuckets 单次查询最多返回的桶数(前端最多画这么多点)
|
||||
MaxBuckets = 720
|
||||
// Retention 保留期
|
||||
Retention = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// HostMetrics 是本机序列的固定集合(顺序即前端展示顺序)
|
||||
var HostMetrics = []string{MetricCPU, MetricMemUsed, MetricMemTotal, MetricDiskUsed, MetricDiskTotal}
|
||||
|
||||
// Sample 一条待写入的样本
|
||||
type Sample struct {
|
||||
Scope string
|
||||
Metric string
|
||||
Value float64
|
||||
}
|
||||
|
||||
// Point 查询结果里的一个点(bucket 为平均值;At 是该桶的起始时刻)
|
||||
type Point struct {
|
||||
At string `json:"at"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// Series 一条序列
|
||||
type Series struct {
|
||||
Scope string `json:"scope"`
|
||||
Metric string `json:"metric"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Record 批量写入一批样本(同一事务,避免每点一个来回)
|
||||
func (s *Store) Record(ctx context.Context, samples []Sample) error {
|
||||
if len(samples) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
stmt, err := tx.PrepareContext(ctx,
|
||||
`INSERT INTO metrics_samples(scope, metric, value, at) VALUES (?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
for _, sample := range samples {
|
||||
if sample.Scope == "" || sample.Metric == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.ExecContext(ctx, sample.Scope, sample.Metric, sample.Value, now); err != nil {
|
||||
_ = stmt.Close()
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := stmt.Close(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Prune 删除超过保留期的样本
|
||||
func (s *Store) Prune(ctx context.Context) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM metrics_samples WHERE at < ?`, time.Now().UTC().Add(-Retention).Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// Query 取窗口内的序列并按 buckets 个等宽桶聚合(每桶取平均)。
|
||||
// buckets <= 0 时按原始点返回;都会按 MaxBuckets 截断。
|
||||
func (s *Store) Query(ctx context.Context, window time.Duration, buckets int) ([]Series, error) {
|
||||
if window <= 0 {
|
||||
window = 24 * time.Hour
|
||||
}
|
||||
if buckets <= 0 {
|
||||
buckets = 48
|
||||
}
|
||||
if buckets > MaxBuckets {
|
||||
buckets = MaxBuckets
|
||||
}
|
||||
to := time.Now().UTC()
|
||||
from := to.Add(-window)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT scope, metric, at, value FROM metrics_samples
|
||||
WHERE at >= ? ORDER BY scope, metric, at`, from.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type raw struct {
|
||||
scope, metric string
|
||||
at time.Time
|
||||
value float64
|
||||
}
|
||||
grouped := map[string][]raw{}
|
||||
order := make([]string, 0, 8)
|
||||
for rows.Next() {
|
||||
var (
|
||||
scope, metric, at string
|
||||
value float64
|
||||
)
|
||||
if err := rows.Scan(&scope, &metric, &at, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := scope + "\x00" + metric
|
||||
if _, ok := grouped[key]; !ok {
|
||||
order = append(order, key)
|
||||
}
|
||||
grouped[key] = append(grouped[key], raw{scope: scope, metric: metric, at: parsed, value: value})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
series := make([]Series, 0, len(order))
|
||||
for _, key := range order {
|
||||
points := grouped[key]
|
||||
at := make([]time.Time, 0, len(points))
|
||||
values := make([]float64, 0, len(points))
|
||||
for _, item := range points {
|
||||
at = append(at, item.at)
|
||||
values = append(values, item.value)
|
||||
}
|
||||
if buckets <= 0 {
|
||||
buckets = 48
|
||||
}
|
||||
series = append(series, Series{
|
||||
Scope: points[0].scope,
|
||||
Metric: points[0].metric,
|
||||
Points: Bucketize(at, values, from, to, buckets),
|
||||
})
|
||||
}
|
||||
// 固定顺序:本机序列按 HostMetrics 的顺序排,便于前端稳定取用
|
||||
sort.SliceStable(series, func(i, j int) bool {
|
||||
return metricOrder(series[i].Metric) < metricOrder(series[j].Metric)
|
||||
})
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// Bucketize 把 (时刻, 值) 序列按 [from, to] 等宽分成 buckets 个桶并取平均。
|
||||
// 空桶不产出点(前端画线时自动跨过,不会出现「凭空补 0」的假谷底)。
|
||||
func Bucketize(at []time.Time, values []float64, from, to time.Time, buckets int) []Point {
|
||||
if buckets <= 0 || to.Before(from) || len(at) == 0 {
|
||||
return nil
|
||||
}
|
||||
span := to.Sub(from)
|
||||
if span <= 0 {
|
||||
return nil
|
||||
}
|
||||
width := span / time.Duration(buckets)
|
||||
if width <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sums := make([]float64, buckets)
|
||||
counts := make([]int, buckets)
|
||||
for i, moment := range at {
|
||||
if i >= len(values) {
|
||||
break
|
||||
}
|
||||
if moment.Before(from) || moment.After(to) {
|
||||
continue
|
||||
}
|
||||
index := int(moment.Sub(from) / width)
|
||||
if index >= buckets {
|
||||
index = buckets - 1
|
||||
}
|
||||
if index < 0 {
|
||||
continue
|
||||
}
|
||||
sums[index] += values[i]
|
||||
counts[index]++
|
||||
}
|
||||
|
||||
points := make([]Point, 0, buckets)
|
||||
for i := 0; i < buckets; i++ {
|
||||
if counts[i] == 0 {
|
||||
continue
|
||||
}
|
||||
points = append(points, Point{
|
||||
At: from.Add(time.Duration(i) * width).UTC().Format(time.RFC3339),
|
||||
Value: sums[i] / float64(counts[i]),
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func metricOrder(metric string) int {
|
||||
for index, name := range HostMetrics {
|
||||
if name == metric {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(HostMetrics) + 1
|
||||
}
|
||||
|
||||
// Percent 由「已用 / 总量」算百分比:总量为 0 时返回 0(而不是 NaN)
|
||||
func Percent(used, total float64) float64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
return used / total * 100
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBucketizeAveragesAndSkipsEmptyBuckets(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
to := from.Add(time.Hour)
|
||||
|
||||
// 12 个 5 分钟桶:第 0 桶两个点(取平均)、第 1 桶一个、第 11 桶一个
|
||||
at := []time.Time{
|
||||
from.Add(time.Minute),
|
||||
from.Add(2 * time.Minute),
|
||||
from.Add(6 * time.Minute),
|
||||
from.Add(59 * time.Minute),
|
||||
}
|
||||
values := []float64{10, 20, 30, 40}
|
||||
|
||||
points := Bucketize(at, values, from, to, 12)
|
||||
if len(points) != 3 {
|
||||
t.Fatalf("空桶不该产出点(否则图上会出现凭空补 0 的谷底),实际 %d 个:%+v", len(points), points)
|
||||
}
|
||||
if points[0].Value != 15 {
|
||||
t.Fatalf("第 0 桶应为 (10+20)/2=15,实际 %v", points[0].Value)
|
||||
}
|
||||
if points[0].At != from.Format(time.RFC3339) {
|
||||
t.Fatalf("桶时间戳应为桶起点,实际 %q", points[0].At)
|
||||
}
|
||||
if points[1].Value != 30 || points[2].Value != 40 {
|
||||
t.Fatalf("后续桶取值不对:%+v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketizeDropsOutOfWindowPoints(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
to := from.Add(time.Hour)
|
||||
|
||||
at := []time.Time{from.Add(-time.Minute), from.Add(30 * time.Minute), to.Add(time.Minute)}
|
||||
values := []float64{1, 2, 3}
|
||||
points := Bucketize(at, values, from, to, 12)
|
||||
if len(points) != 1 || points[0].Value != 2 {
|
||||
t.Fatalf("窗口外的点应被丢掉,实际 %+v", points)
|
||||
}
|
||||
|
||||
if points := Bucketize(at[:1], values[:1], from, to, 12); len(points) != 0 {
|
||||
t.Fatalf("全在窗口外时应返回空,实际 %+v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketizeRejectsDegenerateInput(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
values := []float64{1}
|
||||
|
||||
if points := Bucketize([]time.Time{from}, values, from, from, 12); points != nil {
|
||||
t.Fatalf("零宽窗口应返回 nil,实际 %+v", points)
|
||||
}
|
||||
if points := Bucketize([]time.Time{from}, values, from, from.Add(time.Hour), 0); points != nil {
|
||||
t.Fatalf("桶数为 0 应返回 nil,实际 %+v", points)
|
||||
}
|
||||
if points := Bucketize(nil, nil, from, from.Add(time.Hour), 12); points != nil {
|
||||
t.Fatalf("没有样本应返回 nil,实际 %+v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketizeKeepsPointsShorterThanValues(t *testing.T) {
|
||||
from := time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC)
|
||||
points := Bucketize([]time.Time{from.Add(time.Minute)}, nil, from, from.Add(time.Hour), 12)
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("值与时刻数量不一致时应忽略多出的时刻,实际 %+v", points)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+237
-14
@@ -14,16 +14,47 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
"github.com/fonu/fonu/internal/sysinfo"
|
||||
)
|
||||
|
||||
// RouteStatus 是巡检关心的隧道状态。
|
||||
// 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
|
||||
}
|
||||
|
||||
// ServerStatus 是巡检关心的公网服务端状态:只有 DesiredState 表示「用户要它运行」时才判定掉线。
|
||||
@@ -52,12 +83,25 @@ type DriftStatus struct {
|
||||
Items []string
|
||||
}
|
||||
|
||||
// Source 提供巡检需要的外部状态;由 app 侧把 frp 服务适配过来,monitor 因此不依赖 frp 包。
|
||||
// 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 每轮都重新读设置项:改阈值或开关不需要重启。
|
||||
@@ -66,6 +110,8 @@ type Checker struct {
|
||||
notify *notify.Service
|
||||
logger *slog.Logger
|
||||
source Source
|
||||
health HealthRecorder
|
||||
metrics MetricsRecorder
|
||||
|
||||
mu sync.Mutex
|
||||
cpuPrev sysinfo.CPUSample
|
||||
@@ -73,9 +119,11 @@ type Checker struct {
|
||||
failures map[string]int
|
||||
driftAlerted map[int64]bool
|
||||
driftNotified map[int64]time.Time
|
||||
lastHealthPrune time.Time
|
||||
lastMetricsPrune time.Time
|
||||
}
|
||||
|
||||
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source) *Checker {
|
||||
func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.Logger, source Source, health HealthRecorder, metricsRecorder MetricsRecorder) *Checker {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
@@ -84,6 +132,8 @@ func New(settingsStore *settings.Store, notifySvc *notify.Service, logger *slog.
|
||||
notify: notifySvc,
|
||||
logger: logger,
|
||||
source: source,
|
||||
health: health,
|
||||
metrics: metricsRecorder,
|
||||
resourceAlerts: map[string]time.Time{},
|
||||
failures: map[string]int{},
|
||||
driftAlerted: map[int64]bool{},
|
||||
@@ -96,27 +146,86 @@ func (c *Checker) Run(ctx context.Context) {
|
||||
if c == nil || c.settings == nil {
|
||||
return
|
||||
}
|
||||
c.checkResources(ctx)
|
||||
c.runHostChecks(ctx)
|
||||
c.checkRoutes(ctx)
|
||||
c.checkServers(ctx)
|
||||
c.checkAgents(ctx)
|
||||
c.checkDrift(ctx)
|
||||
}
|
||||
|
||||
func (c *Checker) checkResources(ctx context.Context) {
|
||||
// 开关关闭时连采集都省掉(关掉的功能不该有后台开销)
|
||||
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnResourceThreshold); !enabled {
|
||||
// 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
|
||||
}
|
||||
cpuLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyCPUThreshold)
|
||||
memoryLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyMemoryThreshold)
|
||||
diskLimit, _ := c.settings.GetInt(ctx, settings.KeyNotifyDiskThreshold)
|
||||
|
||||
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
|
||||
@@ -164,11 +273,14 @@ func (c *Checker) checkRoutes(ctx context.Context) {
|
||||
if c.source == nil {
|
||||
return
|
||||
}
|
||||
if enabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown); !enabled {
|
||||
return
|
||||
recordHealth, _ := c.settings.GetBool(ctx, settings.KeyRouteHealthEnabled)
|
||||
if c.health == nil {
|
||||
recordHealth = false
|
||||
}
|
||||
limit, _ := c.settings.GetInt(ctx, settings.KeyNotifyTunnelFailThreshold)
|
||||
if limit <= 0 {
|
||||
alertEnabled, _ := c.settings.GetBool(ctx, settings.KeyNotifyOnTunnelDown)
|
||||
recordMetrics := c.metricsHistoryEnabled(ctx)
|
||||
// 三件事都关着就别去探测:RouteHealth 是这一轮里最贵的调用
|
||||
if !recordHealth && !alertEnabled && !recordMetrics {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,10 +289,48 @@ func (c *Checker) checkRoutes(ctx context.Context) {
|
||||
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:
|
||||
@@ -193,6 +343,79 @@ func (c *Checker) checkRoutes(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
EventFrpsServerDown = "frps_server_down"
|
||||
EventAgentUnreachable = "agent_unreachable"
|
||||
EventRouteDrift = "route_drift"
|
||||
EventBackupFailure = "backup_failure"
|
||||
|
||||
MaskedSecret = "********"
|
||||
|
||||
@@ -125,6 +126,7 @@ type Config struct {
|
||||
FrpsFailThreshold int `json:"frps_fail_threshold"`
|
||||
AgentFailThreshold int `json:"agent_fail_threshold"`
|
||||
DriftCooldownHours int `json:"drift_cooldown_hours"`
|
||||
OnBackupFailure bool `json:"on_backup_failure"`
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
@@ -166,6 +168,7 @@ type SaveInput struct {
|
||||
FrpsFailThreshold int
|
||||
AgentFailThreshold int
|
||||
DriftCooldownHours int
|
||||
OnBackupFailure bool
|
||||
}
|
||||
|
||||
type TestInput struct {
|
||||
|
||||
@@ -96,6 +96,8 @@ func eventMetaFor(event string) eventMeta {
|
||||
return eventMeta{"📡", "隧道"}
|
||||
case EventRouteDrift:
|
||||
return eventMeta{"🧭", "配置"}
|
||||
case EventBackupFailure:
|
||||
return eventMeta{"💾", "系统"}
|
||||
case "test":
|
||||
return eventMeta{"🔔", "系统"}
|
||||
default:
|
||||
|
||||
@@ -252,6 +252,8 @@ func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
|
||||
return cfg.OnAgentUnreachable
|
||||
case EventRouteDrift:
|
||||
return cfg.OnRouteDrift
|
||||
case EventBackupFailure:
|
||||
return cfg.OnBackupFailure
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets,
|
||||
cfg.OnFrpsDown = flag(s.settings, ctx, settings.KeyNotifyOnFrpsDown, false)
|
||||
cfg.OnAgentUnreachable = flag(s.settings, ctx, settings.KeyNotifyOnAgentUnreachable, false)
|
||||
cfg.OnRouteDrift = flag(s.settings, ctx, settings.KeyNotifyOnRouteDrift, false)
|
||||
// 备份失败属于「你以为有备份其实没有」,默认开启
|
||||
cfg.OnBackupFailure = flag(s.settings, ctx, settings.KeyNotifyOnBackupFailure, true)
|
||||
} else {
|
||||
legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
|
||||
legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
|
||||
@@ -202,6 +204,7 @@ func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
|
||||
settings.KeyNotifyOnFrpsDown,
|
||||
settings.KeyNotifyOnAgentUnreachable,
|
||||
settings.KeyNotifyOnRouteDrift,
|
||||
settings.KeyNotifyOnBackupFailure,
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, err := store.Get(ctx, key)
|
||||
@@ -271,6 +274,9 @@ func (s *Store) Save(ctx context.Context, in SaveInput) error {
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnRouteDrift, in.OnRouteDrift); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetBool(ctx, settings.KeyNotifyOnBackupFailure, in.OnBackupFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.settings.SetInt(ctx, settings.KeyNotifyFrpsFailThreshold, clampThreshold(in.FrpsFailThreshold, DefaultFrpsFailThreshold)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -523,6 +529,7 @@ func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
|
||||
in.OnFrpsDown = values[settings.KeyNotifyOnFrpsDown] == "1" || strings.EqualFold(values[settings.KeyNotifyOnFrpsDown], "true")
|
||||
in.OnAgentUnreachable = values[settings.KeyNotifyOnAgentUnreachable] == "1" || strings.EqualFold(values[settings.KeyNotifyOnAgentUnreachable], "true")
|
||||
in.OnRouteDrift = values[settings.KeyNotifyOnRouteDrift] == "1" || strings.EqualFold(values[settings.KeyNotifyOnRouteDrift], "true")
|
||||
in.OnBackupFailure = values[settings.KeyNotifyOnBackupFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnBackupFailure], "true")
|
||||
in.FrpsFailThreshold = intSettingFromMap(values, settings.KeyNotifyFrpsFailThreshold, DefaultFrpsFailThreshold)
|
||||
in.AgentFailThreshold = intSettingFromMap(values, settings.KeyNotifyAgentFailThreshold, DefaultAgentFailThreshold)
|
||||
in.DriftCooldownHours = intSettingFromMap(values, settings.KeyNotifyDriftCooldownHours, DefaultDriftCooldownHours)
|
||||
|
||||
@@ -52,6 +52,16 @@ const (
|
||||
KeyNotifyFrpsFailThreshold = "notify_frps_fail_threshold"
|
||||
KeyNotifyAgentFailThreshold = "notify_agent_fail_threshold"
|
||||
KeyNotifyDriftCooldownHours = "notify_drift_cooldown_hours"
|
||||
KeyBackupAutoEnabled = "backup_auto_enabled"
|
||||
KeyBackupKeepCount = "backup_keep_count"
|
||||
KeyRouteHealthEnabled = "route_health_enabled"
|
||||
KeyStatusPageEnabled = "status_page_enabled"
|
||||
KeyMetricsHistoryEnabled = "metrics_history_enabled"
|
||||
// KeyStatusPageCodeHash 存访问码的 SHA-256;明文只在保存请求里出现一次
|
||||
KeyStatusPageCodeHash = "status_page_code_hash"
|
||||
// KeyStatusPageCode 只是「保存请求」里用的字段名,不会被直接写库
|
||||
KeyStatusPageCode = "status_page_code"
|
||||
KeyNotifyOnBackupFailure = "notify_on_backup_failure"
|
||||
KeyTrustedProxy = "trusted_proxy_json"
|
||||
KeyGlobalIPBlacklist = "global_ip_blacklist"
|
||||
KeyGlobalIPWhitelist = "global_ip_whitelist"
|
||||
|
||||
@@ -228,6 +228,15 @@ func (c *Collector) pruneHost(st *hostState, now time.Time) {
|
||||
}
|
||||
|
||||
func (c *Collector) aggregateEndpoints(endpoints []proxy.Endpoint, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
|
||||
keys := make([]string, 0, len(endpoints))
|
||||
for _, ep := range endpoints {
|
||||
keys = append(keys, EndpointKey(ep.Hostname, ep.Port))
|
||||
}
|
||||
return c.aggregateHostKeys(keys, now)
|
||||
}
|
||||
|
||||
// aggregateHostKeys 汇总若干 host:port 条目的总量、速率与活跃连接数
|
||||
func (c *Collector) aggregateHostKeys(keys []string, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
@@ -235,8 +244,7 @@ func (c *Collector) aggregateEndpoints(endpoints []proxy.Endpoint, now time.Time
|
||||
var oldest time.Time
|
||||
clientSet := map[string]time.Time{}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
key := EndpointKey(ep.Hostname, ep.Port)
|
||||
for _, key := range keys {
|
||||
st, ok := c.hosts[key]
|
||||
if !ok {
|
||||
continue
|
||||
@@ -297,6 +305,51 @@ func (c *Collector) SnapshotForRules(rules []proxy.Rule) []RuleTraffic {
|
||||
return out
|
||||
}
|
||||
|
||||
// HostTraffic 单个域名的当前速率与总量(指标历史按域名落点)
|
||||
type HostTraffic struct {
|
||||
Hostname string `json:"hostname"`
|
||||
UploadTotal int64 `json:"upload_total"`
|
||||
DownloadTotal int64 `json:"download_total"`
|
||||
UploadRate float64 `json:"upload_rate"`
|
||||
DownloadRate float64 `json:"download_rate"`
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
|
||||
// SnapshotByHost 按域名汇总当前速率:内部键是 host:port,
|
||||
// 同一域名的多个端口会合并,与 aggregateEndpoints 的算法保持一致。
|
||||
func (c *Collector) SnapshotByHost(hostnames []string) map[string]HostTraffic {
|
||||
now := time.Now()
|
||||
out := map[string]HostTraffic{}
|
||||
for _, host := range hostnames {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
prefix := host + ":"
|
||||
c.mu.RLock()
|
||||
keys := make([]string, 0, 2)
|
||||
for key := range c.hosts {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
up, down, upRate, downRate, conns := c.aggregateHostKeys(keys, now)
|
||||
out[host] = HostTraffic{
|
||||
Hostname: host,
|
||||
UploadTotal: up,
|
||||
DownloadTotal: down,
|
||||
UploadRate: upRate,
|
||||
DownloadRate: downRate,
|
||||
Connections: conns,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
|
||||
now := time.Now()
|
||||
activeCutoff := now.Add(-activeWindow)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 反代规则健康状态变更记录:只存「状态变化」(up / down / unknown),
|
||||
-- 可用率与不可用区间由变更序列推导——避免每 2 分钟一行原始采样把表撑大。
|
||||
-- unknown 表示「frps 管理接口不可用、判不出来」,与 down 严格区分(判不了不等于不通)。
|
||||
CREATE TABLE IF NOT EXISTS route_health_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id INTEGER NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
detail TEXT,
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_route_health_events_lookup ON route_health_events(server_id, domain, at);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 指标历史:按 (scope, metric) 纵向存点,避免「每加一个指标就改一次表结构」。
|
||||
-- scope 形如 host / server:3 / route:3:example.com;采样跟随巡检轮次(2 分钟)。
|
||||
-- 行量很小(本机 3 条指标约每天 2160 行),因此不做降采样表,查询时按桶取平均。
|
||||
CREATE TABLE IF NOT EXISTS metrics_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_metrics_samples_lookup ON metrics_samples(scope, metric, at);
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fonu-web",
|
||||
"private": true,
|
||||
"version": "1.2.10",
|
||||
"version": "1.2.11",
|
||||
"packageManager": "npm@10.9.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -349,6 +349,18 @@ export const api = {
|
||||
request<import('./types').FrpAgentRouteHealth[]>(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
|
||||
getFrpRouteHealthSummary: () =>
|
||||
request<import('./types').FrpRouteHealthSummary>('/api/frpmulti/routes/summary'),
|
||||
getFrpRouteUptime: (hours = 24) =>
|
||||
request<{ hours: number; items: import('./types').FrpRouteUptime[] }>(`/api/frpmulti/routes/uptime?hours=${hours}`),
|
||||
|
||||
// 指标历史:窗口内的序列已按桶取平均,拿到就能直接画
|
||||
getMetricsHistory: (hours = 24, buckets = 48) =>
|
||||
request<import('./types').MetricsHistory>(`/api/metrics/history?hours=${hours}&buckets=${buckets}`),
|
||||
|
||||
// 匿名状态页:未开启或访问码不对时后端回 404
|
||||
getStatusPage: (code?: string) =>
|
||||
request<import('./types').StatusPagePayload>(
|
||||
`/api/status-page${code ? `?code=${encodeURIComponent(code)}` : ''}`,
|
||||
),
|
||||
removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
|
||||
pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
|
||||
frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
|
||||
@@ -399,6 +411,28 @@ export const api = {
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
listBackupArchives: () =>
|
||||
request<{ archives: import('./types').BackupArchive[]; keep: number }>('/api/backup/archives'),
|
||||
createBackupArchive: () =>
|
||||
request<{ name: string; removed: number }>('/api/backup/archives', { method: 'POST' }),
|
||||
restoreBackupArchive: (name: string) =>
|
||||
request<{ message: string }>(`/api/backup/archives/${encodeURIComponent(name)}/restore`, { method: 'POST' }),
|
||||
deleteBackupArchive: (name: string) =>
|
||||
request<{ ok: boolean }>(`/api/backup/archives/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
||||
async downloadBackupArchive(name: string) {
|
||||
const response = await fetch(`/api/backup/archives/${encodeURIComponent(name)}`, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('下载备份失败')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = name
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
async restoreBackup(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
@@ -720,6 +720,8 @@ export interface FrpAgentRouteHealth {
|
||||
cert_ok: boolean
|
||||
cert_detail?: string
|
||||
tunnel_ok: boolean
|
||||
/** 隧道状态是否判得出来:frps 管理接口不可用时 tunnel_ok 恒为 false,但那不代表隧道不通 */
|
||||
tunnel_known?: boolean
|
||||
tunnel_name?: string
|
||||
tunnel_detail?: string
|
||||
service_ok: boolean
|
||||
@@ -732,6 +734,8 @@ export interface FrpRouteHealthSummary {
|
||||
dns_failed: number
|
||||
cert_missing: number
|
||||
tunnel_down: number
|
||||
/** frps 管理接口不可用、隧道状态判不出来的条数 */
|
||||
tunnel_unknown?: number
|
||||
service_down: number
|
||||
failed_servers?: string[]
|
||||
}
|
||||
@@ -940,3 +944,68 @@ export interface ApiToken {
|
||||
export interface CreatedApiToken extends ApiToken {
|
||||
token: string
|
||||
}
|
||||
|
||||
// 留在服务器上的备份(自动备份 + 手动「立即备份」)
|
||||
export interface BackupArchive {
|
||||
name: string
|
||||
size: number
|
||||
mod_time: string
|
||||
}
|
||||
|
||||
// 规则可用率:由后台巡检落库的状态变更推导,不是页面现算
|
||||
// (不可用区间与判不出来的时长分开给,unknown 不计入可用率分母)
|
||||
export interface FrpRouteUptimeIncident {
|
||||
from: string
|
||||
to?: string
|
||||
reason?: string
|
||||
detail?: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export interface FrpRouteUptime {
|
||||
server_id: number
|
||||
domain: string
|
||||
state: string
|
||||
uptime_pct: number
|
||||
down_minutes: number
|
||||
unknown_minutes: number
|
||||
covered_minutes: number
|
||||
incidents: FrpRouteUptimeIncident[]
|
||||
}
|
||||
|
||||
// 指标历史:scope=host 是本机,scope=route:<serverID>:<domain> 是单条规则的流量
|
||||
export interface MetricsPoint {
|
||||
at: string
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface MetricsSeries {
|
||||
scope: string
|
||||
metric: string
|
||||
points: MetricsPoint[]
|
||||
}
|
||||
|
||||
export interface MetricsHistory {
|
||||
hours: number
|
||||
buckets: number
|
||||
series: MetricsSeries[]
|
||||
}
|
||||
|
||||
// 公开状态页(匿名只读):只有域名与可用率,不含内网地址/端口/证书路径
|
||||
export interface StatusPageRoute {
|
||||
domain: string
|
||||
state: string
|
||||
uptime_pct: number
|
||||
down_minutes: number
|
||||
unknown_minutes: number
|
||||
}
|
||||
|
||||
export interface StatusPagePayload {
|
||||
title: string
|
||||
code_required: boolean
|
||||
checked_at: string
|
||||
up: number
|
||||
down: number
|
||||
unknown: number
|
||||
routes: StatusPageRoute[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<VChart class="trend-chart" :option="option" autoresize />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { use } from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { formatRate } from '../utils/format'
|
||||
|
||||
use([CanvasRenderer, LineChart, GridComponent, LegendComponent, TooltipComponent])
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[]
|
||||
series: { name: string; values: (number | null)[] }[]
|
||||
unit: 'percent' | 'rate'
|
||||
}>()
|
||||
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// 与其它图表保持同一套配色(ECharts 是色值的既有例外)
|
||||
const SERIES_COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6']
|
||||
|
||||
function axisValue(value: number): string {
|
||||
if (props.unit === 'percent') return `${Math.round(value)}%`
|
||||
return formatRate(value)
|
||||
}
|
||||
|
||||
const option = computed(() => {
|
||||
const textColor = isDark.value ? '#94a3b8' : '#9ca3af'
|
||||
const splitColor = isDark.value ? '#334155' : '#e5e7eb'
|
||||
|
||||
return {
|
||||
grid: { left: 4, right: 12, top: 30, bottom: 6, containLabel: true },
|
||||
legend: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
icon: 'roundRect',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
itemGap: 14,
|
||||
textStyle: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: isDark.value ? '#1e293b' : '#fff',
|
||||
borderColor: isDark.value ? '#334155' : '#e5e7eb',
|
||||
textStyle: { color: isDark.value ? '#f1f5f9' : '#111827', fontSize: 12 },
|
||||
valueFormatter: (value: number | null | undefined) =>
|
||||
value === null || value === undefined ? '—' : axisValue(value),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.labels,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
splitNumber: 3,
|
||||
splitLine: { lineStyle: { color: splitColor, type: 'dashed' } },
|
||||
axisLabel: { color: textColor, fontSize: 11, formatter: (v: number) => axisValue(v) },
|
||||
},
|
||||
series: props.series.map((item, index) => ({
|
||||
name: item.name,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
// 刚开始采样时只有一两个点:必须画点,否则「无线可画」看起来像没有数据
|
||||
showSymbol: props.labels.length <= 12,
|
||||
symbolSize: 5,
|
||||
// 空桶在数据里是 null:图上断开而不是补 0,避免凭空出现谷底
|
||||
connectNulls: false,
|
||||
data: item.values,
|
||||
lineStyle: { width: 2, color: SERIES_COLORS[index % SERIES_COLORS.length] },
|
||||
itemStyle: { color: SERIES_COLORS[index % SERIES_COLORS.length] },
|
||||
})),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-chart {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="trend-panel" :class="{ 'trend-panel--loading': loading }">
|
||||
<p v-if="error" class="trend-panel__hint trend-panel__hint--error">{{ error }}</p>
|
||||
<template v-else>
|
||||
<section class="trend-panel__section">
|
||||
<span class="trend-panel__label">资源占用</span>
|
||||
<MetricsTrendChart :labels="resource.labels" :series="resource.series" unit="percent" />
|
||||
<p v-if="resource.labels.length === 0" class="trend-panel__hint">
|
||||
还没有资源数据:从这一版起每 2 分钟采样一次,等一两分钟就会出现。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="trend-panel__section">
|
||||
<span class="trend-panel__label">隧道流量</span>
|
||||
<MetricsTrendChart :labels="traffic.labels" :series="traffic.series" unit="rate" />
|
||||
<p v-if="traffic.labels.length === 0" class="trend-panel__hint">
|
||||
还没有流量数据(需要反代规则产生过访问)。
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import MetricsTrendChart from './MetricsTrendChart.vue'
|
||||
import { api } from '../api/client'
|
||||
import { METRICS_RANGES } from '../constants/metrics'
|
||||
import type { MetricsSeries } from '../api/types'
|
||||
|
||||
// 时间档与切换按钮都由父级(卡片表头)给,本组件只负责取数与绘制
|
||||
const props = defineProps<{ hours: number }>()
|
||||
|
||||
const seriesRaw = ref<MetricsSeries[]>([])
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const range = METRICS_RANGES.find((item) => item.hours === props.hours) ?? METRICS_RANGES[1]
|
||||
const result = await api.getMetricsHistory(range.hours, range.buckets)
|
||||
seriesRaw.value = result.series ?? []
|
||||
error.value = ''
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '读取指标历史失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(at: string): string {
|
||||
const date = new Date(at)
|
||||
if (Number.isNaN(date.getTime())) return at
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return props.hours <= 24 ? time : `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
||||
}
|
||||
|
||||
function pick(items: MetricsSeries[], metric: string) {
|
||||
return items.find((item) => item.metric === metric)
|
||||
}
|
||||
|
||||
// 各序列的点数不一致(空桶不产点),所以按时间戳对齐、并集做横轴
|
||||
function unionLabels(items: MetricsSeries[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const item of items) {
|
||||
for (const point of item.points) set.add(point.at)
|
||||
}
|
||||
return [...set].sort()
|
||||
}
|
||||
|
||||
function valueByTime(series?: MetricsSeries) {
|
||||
const map = new Map<string, number>()
|
||||
for (const point of series?.points ?? []) map.set(point.at, point.value)
|
||||
return map
|
||||
}
|
||||
|
||||
const resource = computed(() => {
|
||||
const host = seriesRaw.value.filter((item) => item.scope === 'host')
|
||||
const labels = unionLabels(host)
|
||||
const cpu = valueByTime(pick(host, 'cpu_percent'))
|
||||
const memUsed = valueByTime(pick(host, 'mem_used_bytes'))
|
||||
const memTotal = valueByTime(pick(host, 'mem_total_bytes'))
|
||||
const diskUsed = valueByTime(pick(host, 'disk_used_bytes'))
|
||||
const diskTotal = valueByTime(pick(host, 'disk_total_bytes'))
|
||||
|
||||
const percentOf = (used: Map<string, number>, total: Map<string, number>, at: string) => {
|
||||
const u = used.get(at)
|
||||
const t = total.get(at)
|
||||
if (u === undefined || t === undefined || t <= 0) return null
|
||||
return (u / t) * 100
|
||||
}
|
||||
|
||||
return {
|
||||
labels: labels.map(formatLabel),
|
||||
series: [
|
||||
{ name: 'CPU', values: labels.map((at) => cpu.get(at) ?? null) },
|
||||
{ name: '内存', values: labels.map((at) => percentOf(memUsed, memTotal, at)) },
|
||||
{ name: '磁盘', values: labels.map((at) => percentOf(diskUsed, diskTotal, at)) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const traffic = computed(() => {
|
||||
const routes = seriesRaw.value.filter((item) => item.scope.startsWith('route:'))
|
||||
const labels = unionLabels(routes)
|
||||
const sumByTime = (metric: string) => {
|
||||
const totals = new Map<string, number>()
|
||||
for (const item of routes) {
|
||||
if (item.metric !== metric) continue
|
||||
for (const point of item.points) {
|
||||
totals.set(point.at, (totals.get(point.at) ?? 0) + point.value)
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
const upload = sumByTime('traffic_in_rate')
|
||||
const download = sumByTime('traffic_out_rate')
|
||||
return {
|
||||
labels: labels.map(formatLabel),
|
||||
series: [
|
||||
{ name: '上传', values: labels.map((at) => upload.get(at) ?? null) },
|
||||
{ name: '下载', values: labels.map((at) => download.get(at) ?? null) },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
watch(() => props.hours, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trend-panel { display: flex; flex-direction: column; gap: var(--fonu-space-4); }
|
||||
.trend-panel--loading { opacity: 0.6; }
|
||||
/* 每块自带小标题,卡片被同行撑高时也不会显得内容散掉 */
|
||||
.trend-panel__section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.trend-panel__label { font-size: 12px; font-weight: 600; color: var(--fonu-text-secondary); }
|
||||
.trend-panel__hint { margin: 0; font-size: 12.5px; color: var(--fonu-text-muted); }
|
||||
.trend-panel__hint--error { color: var(--fonu-error); }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
// 趋势图的时间档:后端按 hours 取窗口、按 buckets 取桶数(点位在该数量级最清楚)
|
||||
export interface MetricsRange {
|
||||
label: string
|
||||
hours: number
|
||||
buckets: number
|
||||
}
|
||||
|
||||
export const METRICS_RANGES: MetricsRange[] = [
|
||||
{ label: '1 小时', hours: 1, buckets: 30 },
|
||||
{ label: '24 小时', hours: 24, buckets: 48 },
|
||||
{ label: '7 天', hours: 24 * 7, buckets: 84 },
|
||||
]
|
||||
@@ -71,6 +71,11 @@ export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
|
||||
label: 'Nginx 重载失败',
|
||||
hint: '保存反代或证书后 Nginx 未能成功重载',
|
||||
},
|
||||
{
|
||||
key: 'on_backup_failure',
|
||||
label: '自动备份失败',
|
||||
hint: '定时备份导出或校验失败——没有备份这件事,往往到需要它的那天才发现',
|
||||
},
|
||||
{
|
||||
key: 'on_resource_threshold',
|
||||
label: '资源占用过高',
|
||||
|
||||
@@ -11,12 +11,15 @@ import SettingsView from '../views/SettingsView.vue'
|
||||
import FrpView from '../views/FrpView.vue'
|
||||
import AgentView from '../views/AgentView.vue'
|
||||
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
|
||||
import StatusPageView from '../views/StatusPageView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/setup', redirect: '/login' },
|
||||
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
|
||||
// 公开状态页:挂到公网给别人看的,不经过登录守卫
|
||||
{ path: '/status', name: 'status', component: StatusPageView, meta: { public: true } },
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
@@ -83,6 +86,8 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
// 公开页(状态页)不登录也能打开,也不必为它去问一次登录状态
|
||||
if (to.meta.public && to.name !== 'login') return true
|
||||
const status = await api.authStatus()
|
||||
if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' }
|
||||
if (to.name === 'login' && status.authenticated) return { name: 'dashboard' }
|
||||
|
||||
@@ -60,8 +60,10 @@
|
||||
<template #value>{{ serviceHealth.value }}</template>
|
||||
<p class="stat-desc">{{ serviceHealth.desc }}</p>
|
||||
<div class="stat-foot">
|
||||
<div class="stat-foot__line">Fonu / Nginx / DDNS / 证书</div>
|
||||
<div class="stat-foot__muted">上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}</div>
|
||||
<div class="stat-foot__line">Fonu · Nginx · DDNS · 证书</div>
|
||||
<div class="stat-foot__muted">
|
||||
运行 {{ formatUptime(status?.uptime_seconds ?? 0) }} · 上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
|
||||
</div>
|
||||
</div>
|
||||
</StatCard>
|
||||
|
||||
@@ -138,33 +140,34 @@
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="panel health-panel">
|
||||
<FonuCard class="panel trend-card">
|
||||
<div class="panel__head">
|
||||
<div class="panel-title">
|
||||
<n-icon :component="PulseOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||
<span>运行状态</span>
|
||||
<n-icon :component="StatsChartOutline" class="panel-title__icon panel-title__icon--teal" />
|
||||
<span>资源与流量趋势</span>
|
||||
</div>
|
||||
<router-link :to="{ name: 'settings' }" class="card-link">
|
||||
查看详情
|
||||
<n-icon :component="ChevronForwardOutline" />
|
||||
</router-link>
|
||||
<div class="trend-range">
|
||||
<n-button
|
||||
v-for="item in METRICS_RANGES"
|
||||
:key="item.hours"
|
||||
size="tiny"
|
||||
quaternary
|
||||
:type="trendHours === item.hours ? 'primary' : 'default'"
|
||||
@click="trendHours = item.hours"
|
||||
>
|
||||
{{ item.label }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="panel__body health-list">
|
||||
<div v-for="item in healthItems" :key="item.name" class="health-item">
|
||||
<div class="health-item__icon" :class="`health-item__icon--${item.tone}`">
|
||||
<n-icon :component="item.icon" />
|
||||
</div>
|
||||
<div class="health-item__main">
|
||||
<span class="health-item__name">{{ item.name }}</span>
|
||||
<span class="health-item__meta">{{ item.desc }}</span>
|
||||
<span class="health-item__detail">{{ item.meta }}</span>
|
||||
</div>
|
||||
<StatusBadge :value="item.status" :text="item.label" />
|
||||
</div>
|
||||
<div class="panel__body">
|
||||
<MetricsTrendPanel :hours="trendHours" />
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="panel">
|
||||
</div>
|
||||
|
||||
<div class="ops-row">
|
||||
<FonuCard class="panel perf-panel">
|
||||
<div class="panel__head">
|
||||
<div class="panel-title">
|
||||
<n-icon :component="FlashOutline" class="panel-title__icon panel-title__icon--amber" />
|
||||
@@ -201,9 +204,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</div>
|
||||
|
||||
<div class="ops-row">
|
||||
<FonuCard class="panel">
|
||||
<div class="panel__head">
|
||||
<div class="panel-title">
|
||||
@@ -352,6 +353,7 @@ import {
|
||||
PulseOutline,
|
||||
ServerOutline,
|
||||
ShieldCheckmarkOutline,
|
||||
StatsChartOutline,
|
||||
SwapVerticalOutline,
|
||||
WifiOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
@@ -379,6 +381,11 @@ import FonuCard from '../components/FonuCard.vue'
|
||||
import LoadError from '../components/LoadError.vue'
|
||||
import MiniBarChart from '../components/MiniBarChart.vue'
|
||||
import MiniTrafficChart from '../components/MiniTrafficChart.vue'
|
||||
import MetricsTrendPanel from '../components/MetricsTrendPanel.vue'
|
||||
import { METRICS_RANGES } from '../constants/metrics'
|
||||
|
||||
// 「资源与流量趋势」的时间档:切换即让子组件按新窗口重新取数
|
||||
const trendHours = ref(24)
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import {
|
||||
@@ -582,6 +589,7 @@ const remoteHealthMeta = computed(() => {
|
||||
if (summary.dns_failed) issues.push(`DNS ${summary.dns_failed}`)
|
||||
if (summary.cert_missing) issues.push(`证书 ${summary.cert_missing}`)
|
||||
if (summary.tunnel_down) issues.push(`隧道 ${summary.tunnel_down}`)
|
||||
if (summary.tunnel_unknown) issues.push(`隧道无法判定 ${summary.tunnel_unknown}`)
|
||||
if (summary.service_down) issues.push(`服务 ${summary.service_down}`)
|
||||
const parts = [`${summary.healthy}/${summary.routes} 条健康`]
|
||||
if (issues.length > 0) parts.push(issues.join(' · '))
|
||||
@@ -1392,7 +1400,7 @@ html.dark .hero-art {
|
||||
|
||||
.ops-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--fonu-space-4);
|
||||
margin-bottom: var(--fonu-space-4);
|
||||
}
|
||||
@@ -1405,6 +1413,9 @@ html.dark .hero-art {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 趋势卡片占两列:这样「性能」才能跟「系统资源」等一起排到下一行的四列里 */
|
||||
.trend-card { grid-column: span 2; }
|
||||
|
||||
.metrics-row > .fonu-card {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1446,6 +1457,9 @@ html.dark .hero-art {
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
/* 趋势卡片表头里的时间档切换(对齐「实时流量」的 range-tag 位置) */
|
||||
.trend-range { display: flex; gap: 2px; }
|
||||
|
||||
.range-tag {
|
||||
background: var(--fonu-bg) !important;
|
||||
color: var(--fonu-text-secondary) !important;
|
||||
|
||||
@@ -422,7 +422,7 @@ import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
|
||||
FrpRouteDriftReport, FrpServer, NginxConfigVersionEntry,
|
||||
FrpRouteDriftReport, FrpRouteUptime, FrpServer, NginxConfigVersionEntry,
|
||||
} from '../api/types'
|
||||
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
|
||||
import { copyText } from '../utils/clipboard'
|
||||
@@ -447,6 +447,7 @@ const trafficErrors = ref<Record<string, string>>({})
|
||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||
const statusLoaded = ref(false)
|
||||
const routeDrift = ref<FrpRouteDriftReport | null>(null)
|
||||
const routeUptime = ref<FrpRouteUptime[]>([])
|
||||
const certificates = ref<CertificateRecord[]>([])
|
||||
const redeploying = ref(false)
|
||||
const settingsModal = ref(false)
|
||||
@@ -552,6 +553,7 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
|
||||
track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value }),
|
||||
track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value }),
|
||||
track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value }),
|
||||
track('可用率', () => api.getFrpRouteUptime(), (value) => { routeUptime.value = value.items ?? [] }),
|
||||
track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value }),
|
||||
track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
|
||||
proxyTraffic.value = value.items ?? {}
|
||||
@@ -611,18 +613,29 @@ function certSummary(domain: string) {
|
||||
return `Fonu 证书库:有(剩余 ${record.days_left} 天)`
|
||||
}
|
||||
|
||||
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
|
||||
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务。
|
||||
// unknown 表示这一段判不出来(frps 管理接口未配或不可用),UI 必须与「失败」区分开,
|
||||
// 否则用户会看到一条红色的「隧道」却怎么查都没问题。
|
||||
function healthOf(domain: string) {
|
||||
const health = routeHealth.value.find((item) => item.domain === domain)
|
||||
const tunnelUnknown = health?.tunnel_known === false
|
||||
return [
|
||||
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||
{ label: 'DNS', ok: health?.dns_ok ?? false, unknown: false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
|
||||
{
|
||||
label: '证书',
|
||||
ok: health?.cert_ok ?? false,
|
||||
unknown: false,
|
||||
detail: `${health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书')}|${certSummary(domain)}`,
|
||||
},
|
||||
{ label: '隧道', ok: health?.tunnel_ok ?? false, detail: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||
{ label: '服务', ok: health?.service_ok ?? false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
{
|
||||
label: '隧道',
|
||||
ok: health?.tunnel_ok ?? false,
|
||||
unknown: tunnelUnknown,
|
||||
detail: tunnelUnknown
|
||||
? `无法判定:${health?.tunnel_detail || 'frps 管理接口未配置或不可用'}(不代表隧道不通)`
|
||||
: health?.tunnel_detail || (health?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道'),
|
||||
},
|
||||
{ label: '服务', ok: health?.service_ok ?? false, unknown: false, detail: health?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -670,10 +683,34 @@ async function redeployAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// 可用率:由后台巡检落库的状态变更推导;没有记录时返回 undefined(UI 要写「暂无数据」)
|
||||
function uptimeOf(candidate: FrpProxy) {
|
||||
const domain = domainsOf(candidate)[0]
|
||||
if (!domain) return undefined
|
||||
return routeUptime.value.find((item) => item.domain === domain)
|
||||
}
|
||||
|
||||
function uptimeDetail(stat: FrpRouteUptime) {
|
||||
const parts = [
|
||||
`可用率 ${stat.uptime_pct.toFixed(1)}%`,
|
||||
`不可用 ${stat.down_minutes} 分钟`,
|
||||
`无法判定 ${stat.unknown_minutes} 分钟`,
|
||||
`观测 ${stat.covered_minutes} 分钟`,
|
||||
]
|
||||
if (stat.incidents.length > 0) {
|
||||
const list = stat.incidents
|
||||
.map((item) => `${formatDate(item.from)}${item.to ? ` → ${formatDate(item.to)}` : ' → 至今'}(${item.reason || '未知环节'},${item.minutes} 分钟)`)
|
||||
.join(';')
|
||||
parts.push(`不可用区间:${list}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
function isHealthy(candidate: FrpProxy) {
|
||||
const domains = domainsOf(candidate)
|
||||
if (!domains.length) return false
|
||||
return domains.every((domain) => healthOf(domain).every((item) => item.ok))
|
||||
// 判不出来的那一段不能算作「不健康」:否则 dashboard 一挂,整页规则都会变红
|
||||
return domains.every((domain) => healthOf(domain).every((item) => item.ok || item.unknown))
|
||||
}
|
||||
|
||||
// 域名快捷操作:复制 / 新窗口打开(http 规则走 http,https 规则走 https)
|
||||
@@ -741,7 +778,18 @@ const columns = computed<DataTableColumns<FrpProxy>>(() => [
|
||||
title: '健康(DNS · 证书 · 隧道 · 服务)', key: 'health', width: 260,
|
||||
render: (row) => h('div', { class: 'cell-health' }, domainsOf(row).flatMap((domain) =>
|
||||
healthOf(domain).map((item) => h('span', { title: item.detail },
|
||||
[h(StatusBadge, { kind: item.ok ? 'success' : 'error', text: item.label })])))),
|
||||
[h(StatusBadge, { kind: item.unknown ? 'warning' : item.ok ? 'success' : 'error', text: item.label })])))),
|
||||
},
|
||||
{
|
||||
title: '可用率 24h', key: 'uptime', width: 120, align: 'right',
|
||||
render: (row) => {
|
||||
const stat = uptimeOf(row)
|
||||
// 没有巡检记录时明说「暂无数据」:直接显示 100% 会让人以为一直没问题
|
||||
if (!stat) return h('span', { class: 'mono cell-muted' }, '暂无数据')
|
||||
const kind = stat.state === 'down' ? 'error' : stat.state === 'up' ? 'success' : 'warning'
|
||||
return h('span', { class: 'mono', title: uptimeDetail(stat) },
|
||||
[h(StatusBadge, { kind, text: `${stat.uptime_pct.toFixed(1)}%` })])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '连接', key: 'conns', width: 80, align: 'right',
|
||||
|
||||
@@ -106,6 +106,66 @@
|
||||
</div>
|
||||
</n-upload>
|
||||
</div>
|
||||
<div class="backup-auto">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">自动备份</div>
|
||||
<div class="form-switch-row__hint">
|
||||
每天自动备份一份到服务器,超过保留份数后自动清理最旧的(重启不会重复备份)
|
||||
</div>
|
||||
</div>
|
||||
<n-switch v-model:value="backupAutoEnabled" />
|
||||
</div>
|
||||
<div class="backup-auto__actions">
|
||||
<div class="settings-field backup-auto__keep">
|
||||
<label class="settings-field__label">保留份数</label>
|
||||
<n-input-number v-model:value="backupKeepCount" :min="1" :max="60" class="settings-field__input" />
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
:loading="backupBusy === 'create'"
|
||||
:disabled="backupBusy !== null"
|
||||
@click="createBackupArchive"
|
||||
>
|
||||
立即备份
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-list">
|
||||
<div v-for="archive in backupArchives" :key="archive.name" class="backup-row">
|
||||
<div class="backup-row__main">
|
||||
<span class="mono">{{ formatDate(archive.mod_time) }}</span>
|
||||
<span class="backup-row__size">{{ formatBytes(archive.size) }}</span>
|
||||
</div>
|
||||
<div class="backup-row__actions">
|
||||
<n-button
|
||||
size="tiny"
|
||||
quaternary
|
||||
:loading="backupBusy === `download:${archive.name}`"
|
||||
:disabled="backupBusy !== null"
|
||||
@click="downloadBackupArchive(archive)"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="restoreBackupArchive(archive)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="warning" :disabled="backupBusy !== null">恢复</n-button>
|
||||
</template>
|
||||
恢复会覆盖当前的数据库、证书与 Nginx 配置,确定继续?
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="deleteBackupArchive(archive)">
|
||||
<template #trigger>
|
||||
<n-button size="tiny" quaternary type="error" :disabled="backupBusy !== null">删除</n-button>
|
||||
</template>
|
||||
删除这份备份?
|
||||
</n-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="backupError" class="field-hint field-hint--error">{{ backupError }}</p>
|
||||
<p v-else-if="backupArchives.length === 0" class="field-hint">还没有留在服务器上的备份。</p>
|
||||
</div>
|
||||
|
||||
<p class="field-hint data-note">恢复配置将覆盖当前数据,操作前请确保已备份。</p>
|
||||
</FonuCard>
|
||||
</div>
|
||||
@@ -676,6 +736,34 @@
|
||||
</div>
|
||||
<p v-else-if="!tokenError" class="field-hint">还没有令牌。</p>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard title="公开状态页" subtitle="给外部看的只读页面(默认关闭)">
|
||||
<p class="field-hint">
|
||||
开启后 <code>/status</code> 任何人都能访问,只展示域名与 24 小时可用率——不含内网地址、端口与证书路径。
|
||||
建议设一个访问码,它会让页面需要带码才能打开。
|
||||
</p>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">开启状态页</div>
|
||||
<div class="form-switch-row__hint">关闭时接口一律返回 404,且匿名访问按 IP 限流</div>
|
||||
</div>
|
||||
<n-switch v-model:value="statusPageEnabled" />
|
||||
</div>
|
||||
<div class="settings-field status-page-field">
|
||||
<ConfiguredSecretField
|
||||
v-model="statusPageCode"
|
||||
label="访问码"
|
||||
:configured="statusPageConfigured"
|
||||
placeholder="留空表示不需要访问码"
|
||||
/>
|
||||
</div>
|
||||
<div class="status-page-actions">
|
||||
<n-button size="small" :disabled="!statusPageEnabled" @click="openStatusPage">
|
||||
打开状态页
|
||||
</n-button>
|
||||
<span class="field-hint">访问码只存 SHA-256,忘了只能重设。</span>
|
||||
</div>
|
||||
</FonuCard>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
@@ -715,6 +803,7 @@ import {
|
||||
import { api } from '../api/client'
|
||||
import type {
|
||||
ApiToken,
|
||||
BackupArchive,
|
||||
ChinaCIDRStatus,
|
||||
GlobalNginxView,
|
||||
NginxConfigVersionEntry,
|
||||
@@ -761,6 +850,9 @@ import { formatBytes, formatDate, formatRelativeTime } from '../utils/format'
|
||||
import type { StatusKind } from '../utils/status'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 与后端 backup.DefaultKeepCount 一致:设置项没填时两边都用 7
|
||||
const DEFAULT_BACKUP_KEEP_COUNT = 7
|
||||
const { setThemeMode } = useTheme()
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -769,6 +861,18 @@ const changingPassword = ref(false)
|
||||
const exporting = ref(false)
|
||||
const restoring = ref(false)
|
||||
|
||||
// 备份:开关与保留份数走设置项(随「保存设置」提交),备份列表单独读
|
||||
const backupAutoEnabled = ref(true)
|
||||
const backupKeepCount = ref(DEFAULT_BACKUP_KEEP_COUNT)
|
||||
const backupArchives = ref<BackupArchive[]>([])
|
||||
const backupBusy = ref<string | null>(null)
|
||||
const backupError = ref('')
|
||||
|
||||
// 公开状态页:开关与访问码走设置项(访问码按「掩码往返」处理)
|
||||
const statusPageEnabled = ref(false)
|
||||
const statusPageCode = ref('')
|
||||
const statusPageConfigured = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
theme: 'system',
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -791,6 +895,7 @@ const notifyEventFlags = reactive<Record<string, boolean>>({
|
||||
on_frps_down: false,
|
||||
on_agent_unreachable: false,
|
||||
on_route_drift: false,
|
||||
on_backup_failure: true,
|
||||
})
|
||||
const notifyIPFrequentThreshold = ref(100)
|
||||
const notifyIPFrequentWindowSec = ref(60)
|
||||
@@ -996,6 +1101,12 @@ function applySettingsToForm(settings: Record<string, string>) {
|
||||
ddnsInterval.value = Number(settings.ddns_check_interval_minutes ?? 5)
|
||||
certThreshold.value = Number(settings.cert_renew_threshold_days ?? 30)
|
||||
logRetention.value = Number(settings.log_retention_days ?? 30)
|
||||
// 未配置时按默认值处理:自动备份是默认开的
|
||||
backupAutoEnabled.value = settings.backup_auto_enabled !== '0'
|
||||
backupKeepCount.value = Number(settings.backup_keep_count || DEFAULT_BACKUP_KEEP_COUNT)
|
||||
statusPageEnabled.value = settings.status_page_enabled === '1'
|
||||
statusPageCode.value = settings.status_page_code ?? ''
|
||||
statusPageConfigured.value = statusPageCode.value === MASKED_SECRET
|
||||
acmeEmail.value = settings.acme_email ?? ''
|
||||
zerosslHasKey.value = Boolean(settings.zerossl_api_key?.trim())
|
||||
zerosslApiKey.value = ''
|
||||
@@ -1308,6 +1419,7 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_frps_down = flagOptIn('notify_on_frps_down')
|
||||
notifyEventFlags.on_agent_unreachable = flagOptIn('notify_on_agent_unreachable')
|
||||
notifyEventFlags.on_route_drift = flagOptIn('notify_on_route_drift')
|
||||
notifyEventFlags.on_backup_failure = flagOn('notify_on_backup_failure')
|
||||
} else {
|
||||
const legacyDDNS = flagOn('notify_on_ddns_error')
|
||||
const legacyCert = flagOn('notify_on_cert_error')
|
||||
@@ -1324,6 +1436,7 @@ function applyNotifySettings(settings: Record<string, string>) {
|
||||
notifyEventFlags.on_frps_down = false
|
||||
notifyEventFlags.on_agent_unreachable = false
|
||||
notifyEventFlags.on_route_drift = false
|
||||
notifyEventFlags.on_backup_failure = true
|
||||
}
|
||||
notifyIPFrequentThreshold.value = Number(settings.notify_ip_frequent_threshold || 100)
|
||||
notifyIPFrequentWindowSec.value = Number(settings.notify_ip_frequent_window_sec || 60)
|
||||
@@ -1446,9 +1559,14 @@ function buildSavePayload() {
|
||||
notify_on_frps_down: notifyEventFlags.on_frps_down ? '1' : '0',
|
||||
notify_on_agent_unreachable: notifyEventFlags.on_agent_unreachable ? '1' : '0',
|
||||
notify_on_route_drift: notifyEventFlags.on_route_drift ? '1' : '0',
|
||||
notify_on_backup_failure: notifyEventFlags.on_backup_failure ? '1' : '0',
|
||||
notify_frps_fail_threshold: String(notifyFrpsFailThreshold.value || DEFAULT_FRPS_FAIL_THRESHOLD),
|
||||
notify_agent_fail_threshold: String(notifyAgentFailThreshold.value || DEFAULT_AGENT_FAIL_THRESHOLD),
|
||||
notify_drift_cooldown_hours: String(notifyDriftCooldownHours.value || DEFAULT_DRIFT_COOLDOWN_HOURS),
|
||||
backup_auto_enabled: backupAutoEnabled.value ? '1' : '0',
|
||||
backup_keep_count: String(backupKeepCount.value || DEFAULT_BACKUP_KEEP_COUNT),
|
||||
status_page_enabled: statusPageEnabled.value ? '1' : '0',
|
||||
status_page_code: statusPageCode.value.trim(),
|
||||
notify_cpu_threshold: String(notifyCPUThreshold.value || DEFAULT_CPU_THRESHOLD),
|
||||
notify_memory_threshold: String(notifyMemoryThreshold.value || DEFAULT_MEMORY_THRESHOLD),
|
||||
notify_disk_threshold: String(notifyDiskThreshold.value || DEFAULT_DISK_THRESHOLD),
|
||||
@@ -1526,6 +1644,63 @@ async function changePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackupArchive() {
|
||||
if (backupBusy.value) return
|
||||
backupBusy.value = 'create'
|
||||
try {
|
||||
await api.createBackupArchive()
|
||||
await loadBackupArchives()
|
||||
message.success('已创建一份备份')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '备份失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function loadBackupArchives() {
|
||||
try {
|
||||
const result = await api.listBackupArchives()
|
||||
backupArchives.value = result.archives ?? []
|
||||
backupError.value = ''
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : '读取备份列表失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `download:${archive.name}`
|
||||
try {
|
||||
await api.downloadBackupArchive(archive.name)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '下载失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function restoreBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `restore:${archive.name}`
|
||||
try {
|
||||
const result = await api.restoreBackupArchive(archive.name)
|
||||
message.success(result.message || '备份已恢复,建议重启 Fonu')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '恢复失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
async function deleteBackupArchive(archive: BackupArchive) {
|
||||
backupBusy.value = `delete:${archive.name}`
|
||||
try {
|
||||
await api.deleteBackupArchive(archive.name)
|
||||
await loadBackupArchives()
|
||||
message.success('已删除该备份')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除失败')
|
||||
} finally { backupBusy.value = null }
|
||||
}
|
||||
|
||||
// 存了访问码时会先要求输入,所以这里只开页面、不带参数
|
||||
function openStatusPage() {
|
||||
window.open('/status', '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function exportBackup() {
|
||||
exporting.value = true
|
||||
try {
|
||||
@@ -1554,6 +1729,7 @@ async function onRestoreFile({ file }: { file: UploadFileInfo }) {
|
||||
onMounted(() => {
|
||||
void load()
|
||||
void loadApiTokens()
|
||||
void loadBackupArchives()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1694,6 +1870,27 @@ onMounted(() => {
|
||||
.config-versions__hint { font-size: 12px; color: var(--fonu-text-muted); line-height: 1.6; }
|
||||
.config-versions__error { margin: 8px 0 0; font-size: 12px; color: var(--fonu-error); line-height: 1.6; }
|
||||
|
||||
/* 自动备份:开关 + 保留份数 + 备份列表 */
|
||||
.backup-auto {
|
||||
display: grid; gap: var(--fonu-space-3);
|
||||
margin-top: var(--fonu-space-4); padding-top: var(--fonu-space-4);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
.backup-auto__actions { display: flex; align-items: end; gap: var(--fonu-space-3); }
|
||||
.backup-auto__keep { max-width: 160px; }
|
||||
.backup-list { display: grid; gap: 6px; margin-top: var(--fonu-space-3); }
|
||||
.backup-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3);
|
||||
padding: 6px var(--fonu-space-3); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
|
||||
}
|
||||
.backup-row__main { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||
.backup-row__size { font-size: 12px; color: var(--fonu-text-muted); }
|
||||
.backup-row__actions { display: flex; align-items: center; gap: 4px; flex: none; }
|
||||
|
||||
/* 公开状态页:访问码 + 打开链接 */
|
||||
.status-page-field { margin-top: var(--fonu-space-3); }
|
||||
.status-page-actions { display: flex; align-items: center; gap: var(--fonu-space-3); margin-top: var(--fonu-space-3); flex-wrap: wrap; }
|
||||
|
||||
/* API Token:创建区一行放四项,列表一行一项 */
|
||||
/* 必须带上 .settings-fields 提高优先级:那条纵向 flex 规则在样式表里位置更靠后,
|
||||
否则 display 会被它覆盖回 flex-column(子项被推到右侧、整卡变很高) */
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="status-page">
|
||||
<div class="status-page__card">
|
||||
<header class="status-page__head">
|
||||
<h1 class="status-page__title">{{ page?.title || '服务状态' }}</h1>
|
||||
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
|
||||
</header>
|
||||
|
||||
<p v-if="page" class="status-page__meta">
|
||||
共 {{ page.routes.length }} 项 · 正常 {{ page.up }} · 异常 {{ page.down }} · 无法判定 {{ page.unknown }}
|
||||
<span v-if="page.checked_at">|数据截至 {{ formatDate(page.checked_at) }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="needsCode" class="status-page__code">
|
||||
<n-input v-model:value="code" placeholder="请输入访问码" @keyup.enter="load" />
|
||||
<n-button type="primary" :loading="loading" @click="load">查看</n-button>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="status-page__error">{{ error }}</p>
|
||||
|
||||
<div v-if="page && page.routes.length" class="status-page__list">
|
||||
<div v-for="route in page.routes" :key="route.domain" class="status-row">
|
||||
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
|
||||
<span class="status-row__domain mono">{{ route.domain }}</span>
|
||||
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
|
||||
|
||||
<p class="status-page__hint status-page__hint--foot">本页只展示域名与可用率,由 Fonu 生成。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import { api } from '../api/client'
|
||||
import type { StatusPagePayload } from '../api/types'
|
||||
import type { StatusKind } from '../utils/status'
|
||||
import { formatDate } from '../utils/format'
|
||||
|
||||
const page = ref<StatusPagePayload | null>(null)
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const needsCode = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
page.value = await api.getStatusPage(code.value.trim())
|
||||
error.value = ''
|
||||
needsCode.value = false
|
||||
} catch (err) {
|
||||
page.value = null
|
||||
// 状态页关闭与访问码错误都返回 404(不向匿名访问者确认页面是否存在)
|
||||
error.value = code.value.trim() ? '访问码不正确或状态页未开启' : '状态页未开启'
|
||||
needsCode.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function kindOf(state: string): StatusKind {
|
||||
if (state === 'up') return 'success'
|
||||
if (state === 'down') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function stateText(state: string): string {
|
||||
if (state === 'up') return '正常'
|
||||
if (state === 'down') return '异常'
|
||||
return '无法判定'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--fonu-space-6) var(--fonu-space-4);
|
||||
background: var(--fonu-bg);
|
||||
}
|
||||
.status-page__card {
|
||||
width: min(680px, 100%);
|
||||
align-self: flex-start;
|
||||
padding: var(--fonu-space-5);
|
||||
background: var(--fonu-surface);
|
||||
border: 1px solid var(--fonu-border);
|
||||
border-radius: var(--fonu-radius);
|
||||
box-shadow: var(--fonu-shadow);
|
||||
}
|
||||
.status-page__head { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-3); }
|
||||
.status-page__title { margin: 0; font-size: 18px; color: var(--fonu-text); }
|
||||
.status-page__meta { margin: 8px 0 0; font-size: 12.5px; color: var(--fonu-text-secondary); }
|
||||
.status-page__code { display: flex; gap: 8px; margin-top: var(--fonu-space-4); }
|
||||
.status-page__error { margin: var(--fonu-space-4) 0 0; font-size: 13px; color: var(--fonu-error); }
|
||||
.status-page__list { display: grid; gap: 6px; margin-top: var(--fonu-space-4); }
|
||||
.status-row {
|
||||
display: flex; align-items: center; gap: var(--fonu-space-3);
|
||||
padding: 8px var(--fonu-space-3);
|
||||
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
|
||||
}
|
||||
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--fonu-text-secondary); }
|
||||
.status-page__hint { margin: var(--fonu-space-3) 0 0; font-size: 12.5px; color: var(--fonu-text-muted); }
|
||||
.status-page__hint--foot { margin-top: var(--fonu-space-5); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user