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
+37 -1
View File
@@ -70,7 +70,13 @@
<div class="stat-card">
<n-icon :component="SwapHorizontalOutline" class="stat-card__icon" :class="portIconClass" aria-hidden="true" />
<div class="port-list">
<span v-for="port in watchedPorts" :key="port" class="port-item" :class="{ 'port-item--on': portOn(port) }">
<span
v-for="port in watchedPorts"
:key="port"
class="port-item"
:class="{ 'port-item--on': portOn(port) }"
:title="portMeaning(port)"
>
{{ port }} {{ portOn(port) ? '✓' : '✗' }}
</span>
</div>
@@ -123,7 +129,9 @@
<div><label>二进制路径</label><span class="mono mono-wrap">{{ agentStatus?.frps_binary || '—' }}</span></div>
<div><label>配置文件</label><span class="mono mono-wrap">{{ agentStatus?.frps_config_path || '—' }}</span></div>
<div class="field-grid__wide"><label>启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '—' }}</span></div>
<div><label>启动一致性</label><span class="field-tags"><StatusBadge :kind="frpsConsistency.kind" :text="frpsConsistency.text" /></span></div>
</div>
<p v-if="!frpsConsistency.ok && frpsConsistency.detail" class="form-hint">{{ frpsConsistency.detail }}</p>
</div>
</FonuCard>
</section>
@@ -282,6 +290,34 @@ let timer: number | undefined
const watchedPorts = ['80', '443', '7000', '8080'] as const
// 端口含义(P0-1):让端口矩阵直接可读,不用再去查哪个端口是谁的
const portMeanings: Record<string, string> = {
'80': '公网 HTTP 入口(Nginx)',
'443': '公网 HTTPS 入口(Nginx)',
'7000': 'frps 控制端口(frpc 连接用)',
'8080': 'frps vhost HTTP 端口(Nginx 回源到 frps)',
}
function portMeaning(port: string) {
return `${portMeanings[port] ?? '端口'}:${portOn(port) ? '正在监听' : '未监听'}`
}
// frps 运行一致性(P0-3):systemd 实际执行的启动命令,是否用的是 Fonu 下发的二进制与配置文件。
// 不一致意味着:手工改过 unit,或者 Fonu 写入的 frps.toml 并不是 frps 实际加载的那一份。
const frpsConsistency = computed(() => {
const exec = agentStatus.value?.frps_unit_exec ?? ''
if (!exec) return { ok: false, kind: 'warning' as const, text: '未获取', detail: '' }
const binary = agentStatus.value?.frps_binary ?? ''
const config = agentStatus.value?.frps_config_path ?? ''
const problems: string[] = []
if (binary && !exec.includes(binary)) problems.push(`启动命令未使用配置的二进制(${binary})`)
if (config && !exec.includes(config)) problems.push(`启动命令未使用 Fonu 下发的配置文件(${config})`)
if (problems.length > 0) {
return { ok: false, kind: 'warning' as const, text: '不一致', detail: problems.join(';') }
}
return { ok: true, kind: 'success' as const, text: '一致', detail: '启动命令与配置的二进制、配置文件一致' }
})
const server = computed(() => servers.value.find((item) => item.id === selectedId.value) ?? null)
const serverOptions = computed(() => servers.value
.filter((item) => item.agent_configured || item.ssh_configured)
+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