feat: 自动备份与保留策略、健康持续化与状态页、指标历史与趋势图,并修隧道判定误报与仪表盘布局(v1.2.11)

- 自动备份:每日一份(按最新备份是否在 24h 内判定,重启不重复)、保留份数可配、列表/下载/恢复/删除,恢复前先校验归档;顺手修 writeArchive 逐个检查 tar/gzip/文件 Close()(审查报告 §二)

- 健康持续化:只存状态变更(up/down/unknown)并推导可用率与不可用区间;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率

- 隧道判定修复:RouteHealthCandidate 新增 tunnel_known,frps 管理接口不可用时不再把「判不出来」当成「不通」(此前会误报「隧道连续不通」);汇总与徽标同样区分「无法判定」

- 指标历史:metrics_samples 纵向点表(本机 CPU/内存/磁盘 + 每规则上下行,30 天保留、查询按桶平均、不做降采样表);仪表盘趋势卡支持 1h/24h/7d

- 仪表盘布局:运行状态并入顶部统计区(运行时长补进「服务」卡);性能并入系统资源那一行,整行 4 列

- 测试:backup/metrics/frp/api/app 新增用例;gofmt、go build/vet/test、vue-tsc、vite build 全绿

- agent 无改动,AgentVersion 保持 0.12.2
This commit is contained in:
2026-09-25 13:05:58 +08:00
parent c8264bf280
commit 74d731d0ac
46 changed files with 2967 additions and 175 deletions
+55 -7
View File
@@ -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',