Files
Havline/web/src/views/AgentView.vue
T
mrlee 278f8267b7 chore: 初始提交 Fonu 1.0.2
- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
2026-09-23 09:40:59 +08:00

369 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageHeader title="公网 Agent" description="管理部署在公网 VPS 上的 fonu-agent 与 frps 服务">
<template #actions>
<n-select
v-model:value="selectedId"
:options="serverOptions"
placeholder="选择公网服务端"
style="width: 220px"
:disabled="servers.length === 0"
/>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else>
<EmptyState
v-if="servers.length === 0"
title="尚未配置 FRP 服务端"
description="先在「内网穿透」页添加公网服务端并保存 SSH 信息,再回到本页管理 Agent。"
>
<template #action>
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
</template>
</EmptyState>
<template v-else-if="server">
<section class="status-grid">
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatusOk ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="CloudOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">Agent 连接</div>
<div class="status-card__value">{{ agentStatusOk ? '在线' : '未连接' }}</div>
<div class="status-card__meta mono">{{ server.agent_url || '未配置 Agent 地址' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.frps_active ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="GitNetworkOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">frps 服务</div>
<div class="status-card__value">{{ agentStatus?.frps_active ? '运行中' : '未运行' }}</div>
<div class="status-card__meta">{{ agentStatus?.frps_version?.trim() || '未安装' }}</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['80'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="SwapHorizontalOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">80 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['80'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">无端口访问的入口</div>
</div>
</FonuCard>
<FonuCard class="status-card">
<div class="status-card__icon" :class="agentStatus?.ports?.['443'] ? 'status-card__icon--running' : 'status-card__icon--stopped'">
<n-icon :component="LockClosedOutline" />
</div>
<div class="status-card__body">
<div class="status-card__label">443 端口</div>
<div class="status-card__value">{{ agentStatus?.ports?.['443'] ? '监听中' : '未监听' }}</div>
<div class="status-card__meta">HTTPS 入口</div>
</div>
</FonuCard>
</section>
<section class="agent-page-grid">
<FonuCard class="section-card" title="frps 服务控制">
<template #header>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="refreshAll">刷新</n-button>
</template>
<div class="agent-section-body">
<div class="field-grid">
<div><label>Agent 版本</label><span class="field-tags"><n-tag size="small" :bordered="false">{{ agentStatus?.agent_version || '-' }}</n-tag></span></div>
<div><label>frps 启动命令</label><span class="mono mono-wrap">{{ agentStatus?.frps_unit_exec || '-' }}</span></div>
<div><label>frps 配置</label><span class="mono" :title="agentStatus?.frps_config_path">{{ agentStatus?.frps_config_path || '-' }}</span></div>
<div><label>开机自启</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_enabled ? 'success' : 'warning'">{{ agentStatus?.frps_enabled ? '已启用' : '未启用' }}</n-tag></span></div>
<div><label>7000 端口</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.ports?.['7000'] ? 'success' : 'warning'">{{ agentStatus?.ports?.['7000'] ? '监听中' : '未监听' }}</n-tag></span></div>
</div>
<div class="agent-actions">
<n-button v-if="agentStatus?.frps_active" size="small" type="warning" :loading="agentActionId === server.id" @click="frpsAction(server, 'stop')">停止 frps</n-button>
<n-button v-else size="small" type="success" :loading="agentActionId === server.id" @click="frpsAction(server, 'start')">启动 frps</n-button>
<n-button size="small" :loading="agentActionId === server.id" @click="frpsAction(server, 'restart')">重启 frps</n-button>
<n-button v-if="dashboardURL" size="small" quaternary @click="openDashboard(dashboardURL)">frps 管理</n-button>
</div>
<p v-if="!dashboardURL" class="form-hint" style="margin-top: 8px">未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存即可开启面板入口</p>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 配置与版本">
<div class="agent-section-body">
<div class="field-grid">
<div><label>已安装 frps 版本</label><span class="field-tags"><n-tag size="small" :bordered="false" :type="agentStatus?.frps_version ? 'success' : 'warning'">{{ installedFrpsVersion || '未安装' }}</n-tag></span></div>
<div><label>安装位置</label><span class="mono" :title="agentStatus?.frps_binary">{{ agentStatus?.frps_binary || '-' }}</span></div>
</div>
<div class="agent-actions" style="margin-top: var(--fonu-space-3)">
<n-button size="small" :loading="agentActionId === server.id" @click="pushAgentConfig(server)">下发 frps 配置</n-button>
</div>
<div class="frps-install-row">
<n-select v-model:value="frpsInstallVersion" :options="frpsVersionOptions" placeholder="选择 frps 官方版本" style="width: 200px" filterable />
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installFrps(server)">安装/升级 frps</n-button>
</div>
<p class="form-hint">VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置。</p>
</div>
</FonuCard>
</section>
<section class="agent-page-grid">
<FonuCard class="section-card" title="公网 Nginx 反代">
<div class="agent-section-body">
<p class="form-hint">反代管理已独立成「公网反代」页面:逐条部署/移除、配置查看、强制跳转、IP 白名单、Basic Auth、WebSocket 等都在那里。</p>
<router-link to="/public-proxy"><n-button size="small" type="primary">前往公网反代</n-button></router-link>
</div>
</FonuCard>
<FonuCard class="section-card" title="frps 最近日志">
<div class="agent-section-body">
<pre class="code-block">{{ agentStatus?.frps_log_tail || '暂无日志' }}</pre>
</div>
</FonuCard>
</section>
<FonuCard class="section-card" title="Agent 生命周期(SSH)">
<template #header>
<div class="section-actions">
<n-button size="small" type="primary" :loading="agentActionId === server.id" @click="probeAgent(server)">探测环境</n-button>
<n-button size="small" type="success" :loading="agentActionId === server.id" @click="installAgent(server)">{{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}</n-button>
<n-button size="small" type="warning" :loading="agentActionId === server.id" @click="uninstallAgent(server, false)">卸载(含数据)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="uninstallAgent(server, true)">卸载(保留路由/证书)</n-button>
<n-button size="small" quaternary :loading="agentActionId === server.id" @click="diagnoseAgent(server)">SSH 诊断</n-button>
</div>
</template>
<div class="agent-section-body">
<p class="form-hint">
首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
</p>
<pre v-if="agentOutput" class="code-block">{{ agentOutput }}</pre>
</div>
</FonuCard>
</template>
</template>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMessage } from 'naive-ui'
import { CloudOutline, GitNetworkOutline, LockClosedOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import { api } from '../api/client'
import type { FrpAgentStatus, FrpRelease, FrpServer } from '../api/types'
const message = useMessage()
const servers = ref<FrpServer[]>([])
const selectedId = ref<number | null>(null)
const loadError = ref('')
const agentStatus = ref<FrpAgentStatus | null>(null)
const agentStatusOk = ref(false)
const agentActionId = ref<number | null>(null)
const agentOutput = ref('')
const frpsInstallVersion = ref<string | null>(null)
const releases = ref<FrpRelease[]>([])
let timer: number | undefined
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)
.map((item) => ({ label: item.name, value: item.id })))
const installedFrpsVersion = computed(() => (agentStatus.value?.frps_version || '').trim() || '')
const frpsVersionOptions = computed(() => [
...(releases.value.length ? [] : [{ label: '0.71.0(默认,官方版本未加载)', value: '0.71.0' }]),
...releases.value.map((release) => ({ label: release.version, value: release.version })),
])
async function load() {
loadError.value = ''
try {
servers.value = (await api.listFrpServers()).filter((item) => item.agent_configured || item.ssh_configured)
if (selectedId.value === null || !servers.value.some((item) => item.id === selectedId.value)) {
selectedId.value = servers.value[0]?.id ?? null
}
if (server.value) void refreshAll()
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
}
async function refreshAll() {
const current = server.value
if (!current) return
agentActionId.value = current.id
try {
agentStatus.value = await api.getFrpAgentStatus(current.id)
agentStatusOk.value = true
} catch (error) {
agentStatusOk.value = false
agentOutput.value = error instanceof Error ? error.message : 'Agent 状态获取失败'
} finally { agentActionId.value = null }
}
async function probeAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.probeFrpAgent(target.id)
agentOutput.value = Object.entries(result).map(([key, value]) => `${key}: ${value}`).join('\n')
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '探测失败' }
finally { agentActionId.value = null }
}
async function installAgent(target: FrpServer) {
agentActionId.value = target.id
try {
await api.installFrpAgent(target.id)
agentOutput.value = 'Agent 安装完成,已自动回填 Agent 地址与 Token'
message.success('Agent 安装成功')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装失败' }
finally { agentActionId.value = null }
}
async function uninstallAgent(target: FrpServer, keepData: boolean) {
agentActionId.value = target.id
try {
const result = await api.uninstallFrpAgent(target.id, keepData)
agentOutput.value = result.output || '已卸载'
message.success('Agent 已卸载')
await load()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '卸载失败' }
finally { agentActionId.value = null }
}
async function diagnoseAgent(target: FrpServer) {
agentActionId.value = target.id
try {
const result = await api.sshDiagnoseFrpAgent(target.id)
agentOutput.value = result.output || '无输出'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '诊断失败' }
finally { agentActionId.value = null }
}
// frps 管理面板地址:直接读服务端表单里已配置的「frps 管理接口」(dashboard_addr/port);
// 监听 0.0.0.0/:: 时用服务端公网地址拼 URL(浏览器在外网访问)
const dashboardURL = computed(() => {
const current = server.value
if (!current) return ''
const addr = (current.options.dashboard_addr || '').trim()
const port = current.options.dashboard_port || 0
if (!addr || !port) return ''
const host = addr === '0.0.0.0' || addr === '::' ? (current.server_addr || '127.0.0.1') : addr
return `http://${host}:${port}`
})
function openDashboard(url: string) {
if (url) window.open(url, '_blank', 'noopener')
}
async function frpsAction(target: FrpServer, action: 'start' | 'stop' | 'restart') {
agentActionId.value = target.id
const actionText = action === 'stop' ? '停止' : action === 'start' ? '启动' : '重启'
try {
// agent 对"命令成功但服务未进入运行态"返回 200+ok:false+error(含 journal 日志)
const result = await api.frpsServerActionRaw(target.id, action)
if (result && result.ok === false) {
agentOutput.value = result.error || `frps ${actionText}后未进入预期状态`
message.error(`frps ${actionText}失败,详见输出`)
} else {
message.success(`frps 已${actionText}`)
}
await refreshAll()
} catch (error) {
agentOutput.value = error instanceof Error ? error.message : 'frps 操作失败'
message.error(`frps ${actionText}失败,详见输出`)
} finally { agentActionId.value = null }
}
async function pushAgentConfig(target: FrpServer) {
agentActionId.value = target.id
try {
await api.pushFrpConfigToAgent(target.id)
message.success('frps 配置已下发')
agentOutput.value = 'frps 同源配置已下发到 VPS,点「重启 frps」后生效'
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '下发配置失败' }
finally { agentActionId.value = null }
}
async function installFrps(target: FrpServer) {
agentActionId.value = target.id
try {
const version = frpsInstallVersion.value?.trim() || '0.71.0'
const result = await api.installFrpServer(target.id, version, '')
const notes = Array.isArray(result?.notes) ? result.notes.join('\n') : ''
agentOutput.value = `frps ${result?.version ?? version} 安装完成\n${notes}`
message.success('VPS frps 已安装')
await refreshAll()
} catch (error) { agentOutput.value = error instanceof Error ? error.message : '安装 frps 失败' }
finally { agentActionId.value = null }
}
async function loadReleases() {
if (releases.value.length > 0) return
try { releases.value = await api.listFrpReleases() } catch { /* 回退默认版本 */ }
}
onMounted(() => {
void load()
void loadReleases()
timer = window.setInterval(() => { if (server.value) void refreshAll() }, 10000)
})
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
</script>
<style scoped>
/* 对齐 DDNS 页风格:surface 卡片、settings-grid 字段、统一代码块 */
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.status-card { display: flex; gap: var(--fonu-space-3); align-items: center; padding: var(--fonu-space-4) var(--fonu-space-5); }
.status-card__icon { width: 44px; height: 44px; border-radius: var(--fonu-radius-sm); display: flex; align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0; }
.status-card__icon--running { background: rgba(34, 197, 94, 0.14); color: #16a34a; }
.status-card__icon--stopped { background: rgba(148, 163, 184, 0.16); color: #64748b; }
.status-card__body { min-width: 0; }
.status-card__label { color: var(--fonu-text-secondary); font-size: 12px; }
.status-card__value { font-size: 18px; font-weight: 700; color: var(--fonu-text); }
.status-card__meta { color: var(--fonu-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-page-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); }
.agent-section-body { padding: var(--fonu-space-2) var(--fonu-space-5) var(--fonu-space-4); }
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--fonu-space-4); }
.field-grid > div { display: grid; gap: 4px; min-width: 0; }
.field-grid label { color: var(--fonu-text-secondary); font-size: 12px; }
.field-grid .mono { font-family: var(--fonu-mono); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field-grid .mono-wrap { white-space: normal; word-break: break-all; overflow: visible; text-overflow: unset; }
.field-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.agent-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--fonu-space-4); }
.route-list { display: grid; gap: 8px; margin-top: var(--fonu-space-3); }
.route-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-surface); }
.route-item__main { display: grid; gap: 3px; min-width: 0; }
.route-item__domains { color: var(--fonu-text); font-size: 12px; overflow-wrap: anywhere; }
.route-item__upstream { color: var(--fonu-text-muted); font-size: 12px; }
.route-item__actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; flex-shrink: 0; }
.empty-route { margin-top: var(--fonu-space-3); padding: 18px; border: 1px dashed var(--fonu-border); border-radius: var(--fonu-radius-sm); color: var(--fonu-text-muted); text-align: center; }
.frps-install-row { display: flex; align-items: center; gap: 10px; margin-top: var(--fonu-space-4); flex-wrap: wrap; }
.frps-install-row .form-hint { flex-basis: 100%; margin: 0; }
.code-block {
margin: var(--fonu-space-3) 0 0;
padding: var(--fonu-space-3) var(--fonu-space-4);
background: var(--fonu-bg-muted);
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
font-family: var(--fonu-mono);
font-size: 12.5px;
line-height: 1.65;
color: var(--fonu-text);
overflow: auto;
max-height: 240px;
white-space: pre-wrap;
word-break: break-all;
}
.form-hint { color: var(--fonu-text-secondary); font-size: 12.5px; }
.mono { font-family: var(--fonu-mono); }
.section-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 900px) { .field-grid { grid-template-columns: 1fr; } }
</style>