feat: 公网反代部署漂移与全量重部署,仪表盘补公网穿透/系统资源/中国 IP 段/通知通道(v1.2.3)

仪表盘:新增「公网穿透」卡与运行状态「公网反代」行(新增 GET /api/frpmulti/routes/summary 逐台聚合);「性能」面板(今日异常/平均响应)与「流量 Top 5」;新增一行三卡「系统资源 / 中国 IP 段 / 通知通道」,系统资源来自新增跨平台采集 internal/sysinfo

仪表盘:「服务」卡不再写死「正常」,按 Fonu/Nginx/DDNS/证书四项实际状态聚合;/api/status 每 15 秒刷新并按页面可见性暂停轮询;删除死样式

公网反代:部署漂移清单(未部署/孤儿配置/未生效/内容不一致)+ 重新部署全部(逐条下发、单条失败不中断)

公网 Agent:端口矩阵补端口含义;frps 启动一致性核对;证书徽标补证书来源

FRP:frpc 版本探测失败时回落按二进制目录名推导,并逐台服务端探测

文档:新增 docs/agent-capability-plan.md(agent 能力扩展方案);VERSION 提升到 1.2.3
This commit is contained in:
2026-09-24 09:20:07 +08:00
parent 8f6fac802f
commit f75e672a45
11 changed files with 742 additions and 4 deletions
+82 -2
View File
@@ -35,6 +35,25 @@
<!-- 数据读取失败:必须可见(原先 loadErrorMsg 写了但从未渲染) -->
<n-alert v-if="loadErrorMsg" type="error" :bordered="false" class="page-alert">{{ loadErrorMsg }}</n-alert>
<!-- 部署漂移:库里的期望与 VPS 上的实际不一致时才出现(P0-2) -->
<n-alert v-if="driftItems.length > 0" type="warning" :bordered="false" class="page-alert">
<div class="alert-body">
<div class="alert-body__text">
<strong>检测到 {{ driftItems.length }} 项部署漂移({{ driftSummary }})</strong>
<span>VPS 上的实际配置与 Fonu 记录不一致:可能被手工修改过,或 agent 重装后没有重新部署。</span>
<span class="mono alert-body__detail">{{ driftPreview }}</span>
</div>
<n-popconfirm @positive-click="redeployAll">
<template #trigger>
<n-button size="small" type="primary" :loading="redeploying" :disabled="busy !== null">
重新部署全部
</n-button>
</template>
将按 Fonu 里的记录重新下发该服务端的全部启用规则,会覆盖 VPS 上的手工修改。确定继续?
</n-popconfirm>
</div>
</n-alert>
<!-- Nginx 前置条件:缺失 / 校验未通过 -->
<n-alert v-if="nginxBlocking" :type="nginxBlocking.type" :bordered="false" class="page-alert">
<div class="alert-body">
@@ -331,7 +350,8 @@ import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
import { api } from '../api/client'
import type {
FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer,
CertificateRecord, FrpAgentRouteDetail, FrpAgentRouteHealth, FrpAgentStatus, FrpProxy, FrpProxyTraffic,
FrpRouteDriftReport, FrpServer,
} from '../api/types'
import { formatBytes, formatDate, formatRate, formatRelativeTime } from '../utils/format'
import { copyText } from '../utils/clipboard'
@@ -355,6 +375,9 @@ const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
const trafficErrors = ref<Record<string, string>>({})
const agentStatus = ref<FrpAgentStatus | null>(null)
const statusLoaded = ref(false)
const routeDrift = ref<FrpRouteDriftReport | null>(null)
const certificates = ref<CertificateRecord[]>([])
const redeploying = ref(false)
const settingsModal = ref(false)
const settingsTarget = ref<FrpProxy | null>(null)
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
@@ -452,6 +475,8 @@ async function refreshAll(options: { silent?: boolean } = { silent: true }) {
await track('规则列表', () => api.getFrpAgentRouteCandidates(current.id), (value) => { candidates.value = value })
await track('路由文件', () => api.getFrpAgentRoutes(current.id), (value) => { routeDetails.value = value })
await track('健康检查', () => api.getFrpAgentRouteHealth(current.id), (value) => { routeHealth.value = value })
await track('配置漂移', () => api.getFrpAgentRouteDrift(current.id), (value) => { routeDrift.value = value })
await track('证书列表', () => api.listCertificates(), (value) => { certificates.value = value })
await track('流量采集', () => api.listFrpProxiesTraffic(), (value) => {
proxyTraffic.value = value.items ?? {}
trafficErrors.value = value.errors ?? {}
@@ -502,17 +527,72 @@ function stateOf(candidate: FrpProxy): { state: RouteState; label: string; kind:
return { state: 'none', label: '未部署', kind: 'disabled' }
}
// 证书来源(P0-4):Fonu 证书库里是否有该域名的证书(决定重新部署时能否推送证书、公网 HTTPS 能否终结)
function certSummary(domain: string) {
const record = certificates.value.find((item) => item.domain === domain)
if (!record) return 'Fonu 证书库:无(需先在「证书管理」申请)'
return `Fonu 证书库:有(剩余 ${record.days_left} 天)`
}
// 健康四段(按域名):DNS / 证书 / 隧道 / 服务
function healthOf(domain: string) {
const health = routeHealth.value.find((item) => item.domain === domain)
return [
{ label: 'DNS', ok: health?.dns_ok ?? false, detail: health?.dns_detail || (health?.dns_ok ? '已指向服务端' : '未指向服务端或未检测') },
{ label: '证书', ok: health?.cert_ok ?? false, detail: health?.cert_detail || (health?.cert_ok ? '证书已覆盖该域名' : '未发现该域名的证书') },
{
label: '证书',
ok: health?.cert_ok ?? 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 ? '内网服务可达' : '内网服务不可达' },
]
}
// 部署漂移:库里的期望与 VPS 上的实际之间的差异(未部署 / 孤儿配置 / 未生效 / 内容不一致)
const driftKindText: Record<string, string> = {
missing: '未部署',
orphan: '孤儿配置',
not_loaded: '未生效',
content_mismatch: '内容不一致',
}
const driftItems = computed(() => routeDrift.value?.items ?? [])
const driftSummary = computed(() => {
const counts = new Map<string, number>()
for (const item of driftItems.value) {
counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1)
}
return [...counts.entries()].map(([kind, count]) => `${driftKindText[kind] ?? kind} ${count}`).join(' · ')
})
const driftPreview = computed(() => {
const preview = driftItems.value.slice(0, 3).map((item) => `${item.domain}(${driftKindText[item.kind] ?? item.kind})`)
return driftItems.value.length > 3 ? `${preview.join(';')} …` : preview.join(';')
})
// 全量重部署:逐条下发,失败不中断(后端已逐条汇总)
async function redeployAll() {
const current = server.value
if (!current) return
redeploying.value = true
try {
const result = await api.redeployFrpAgentRoutes(current.id)
await refreshAll()
if (result.failed > 0) {
const firstError = result.results.find((item) => !item.ok)?.error ?? ''
message.warning(`重新部署:成功 ${result.succeeded} 条,失败 ${result.failed} 条${firstError ? `;首个失败:${firstError}` : ''}`)
} else {
message.success(`重新部署完成:${result.succeeded} 条规则全部成功`)
}
} catch (error) {
message.error(error instanceof Error ? error.message : '重新部署失败')
} finally {
redeploying.value = false
}
}
function isHealthy(candidate: FrpProxy) {
const domains = domainsOf(candidate)
if (!domains.length) return false