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
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
|
||||
<div class="auth-page">
|
||||
|
||||
<div class="auth-card">
|
||||
|
||||
<div class="auth-brand">
|
||||
|
||||
<img class="auth-brand__logo" :src="logoImg" alt="Fonu" />
|
||||
|
||||
<div>
|
||||
|
||||
<h1>Fonu</h1>
|
||||
|
||||
<p>让 NAS 访问更简单</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h2 class="auth-title">登录管理后台</h2>
|
||||
|
||||
<n-form @submit.prevent="submit">
|
||||
|
||||
<n-form-item label="用户名">
|
||||
|
||||
<n-input v-model:value="username" size="large" />
|
||||
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="密码">
|
||||
|
||||
<n-input v-model:value="password" type="password" show-password-on="click" size="large" />
|
||||
|
||||
</n-form-item>
|
||||
|
||||
<n-button type="primary" block size="large" :loading="loading" attr-type="submit">登录</n-button>
|
||||
|
||||
</n-form>
|
||||
|
||||
<p class="auth-hint">首次使用请查看系统日志中的初始密码,登录后请在「设置」中修改。</p>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { NButton, NForm, NFormItem, NInput, useMessage } from 'naive-ui'
|
||||
|
||||
import { api } from '../api/client'
|
||||
import logoImg from '../assets/brand/logo.png'
|
||||
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const username = ref('admin')
|
||||
|
||||
const password = ref('')
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
|
||||
async function submit() {
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
|
||||
await api.login(username.value, password.value)
|
||||
|
||||
message.success('登录成功')
|
||||
|
||||
router.push({ name: 'dashboard' })
|
||||
|
||||
} catch (error) {
|
||||
|
||||
message.error(error instanceof Error ? error.message : '登录失败')
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.auth-page {
|
||||
|
||||
min-height: 100vh;
|
||||
|
||||
display: grid;
|
||||
|
||||
place-items: center;
|
||||
|
||||
padding: var(--fonu-space-5);
|
||||
|
||||
background: var(--fonu-bg);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-card {
|
||||
|
||||
width: 100%;
|
||||
|
||||
max-width: 420px;
|
||||
|
||||
background: var(--fonu-surface);
|
||||
|
||||
border: 1px solid var(--fonu-border);
|
||||
|
||||
border-radius: var(--fonu-radius);
|
||||
|
||||
box-shadow: var(--fonu-shadow-md);
|
||||
|
||||
padding: var(--fonu-space-6) var(--fonu-space-5);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand {
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: var(--fonu-space-4);
|
||||
|
||||
margin-bottom: var(--fonu-space-5);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand__logo {
|
||||
|
||||
width: 48px;
|
||||
|
||||
height: 48px;
|
||||
|
||||
border-radius: 14px;
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.22);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand h1 {
|
||||
|
||||
margin: 0;
|
||||
|
||||
font-size: 22px;
|
||||
|
||||
font-weight: 700;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-brand p {
|
||||
|
||||
margin: 4px 0 0;
|
||||
|
||||
font-size: 13px;
|
||||
|
||||
color: var(--fonu-text-muted);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.auth-title {
|
||||
|
||||
margin: 0 0 var(--fonu-space-5);
|
||||
|
||||
font-size: 16px;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
color: var(--fonu-text);
|
||||
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
margin: var(--fonu-space-4) 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<PageHeader
|
||||
title="日志中心"
|
||||
description="系统日志记录 Fonu 全部运行输出;访问日志与 Nginx 错误日志来自反向代理"
|
||||
/>
|
||||
|
||||
<LoadError v-if="pageError" :message="pageError" @retry="loadAll" />
|
||||
|
||||
<FonuCard v-else flush class="logs-card">
|
||||
<n-tabs v-model:value="tab" type="line" animated class="logs-tabs">
|
||||
<n-tab-pane name="system" tab="系统日志">
|
||||
<LogToolbar
|
||||
v-model:keyword="systemKeyword"
|
||||
v-model:level="systemLevel"
|
||||
:show-level="true"
|
||||
@refresh="loadSystemLogs"
|
||||
/>
|
||||
<p class="tab-hint">Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。</p>
|
||||
<n-data-table
|
||||
v-if="filteredSystemLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="systemColumns"
|
||||
:data="pagedSystemLogs"
|
||||
:loading="loadingSystem"
|
||||
:bordered="false"
|
||||
:row-class-name="systemRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredSystemLogs.length > 0"
|
||||
v-model:page="systemPage"
|
||||
:item-count="filteredSystemLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingSystem && systemLogs.length === 0"
|
||||
title="暂无系统日志"
|
||||
description="应用启动、DDNS 更新、证书操作或 API 请求会记录在这里。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="access" tab="访问日志">
|
||||
<LogToolbar
|
||||
v-model:keyword="accessKeyword"
|
||||
v-model:status="accessStatus"
|
||||
:show-domain="true"
|
||||
:show-status="true"
|
||||
:auto-refresh="autoRefresh"
|
||||
@refresh="loadAccess"
|
||||
@toggle-auto="autoRefresh = !autoRefresh"
|
||||
/>
|
||||
<n-data-table
|
||||
v-if="filteredAccessLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="accessColumns"
|
||||
:data="pagedAccessLogs"
|
||||
:loading="loadingAccess"
|
||||
:bordered="false"
|
||||
:scroll-x="920"
|
||||
:row-class-name="accessRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredAccessLogs.length > 0"
|
||||
v-model:page="accessPage"
|
||||
:item-count="filteredAccessLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingAccess && accessLogs.length === 0"
|
||||
title="暂无访问日志"
|
||||
description="此处记录经 Nginx 反向代理的访问(非 Fonu 管理界面本身)。请通过代理域名访问后刷新。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="nginx" tab="Nginx 日志">
|
||||
<p class="tab-hint">Nginx 错误日志(error.log),记录 SSL 握手失败、上游连接异常、配置冲突等。</p>
|
||||
<LogToolbar v-model:keyword="errorKeyword" @refresh="loadErrorLogs" />
|
||||
<n-data-table
|
||||
v-if="filteredErrorLogs.length > 0"
|
||||
class="log-table"
|
||||
:columns="errorColumns"
|
||||
:data="pagedErrorLogs"
|
||||
:loading="loadingError"
|
||||
:bordered="false"
|
||||
:row-class-name="errorRowClassName"
|
||||
/>
|
||||
<LogPagination
|
||||
v-if="filteredErrorLogs.length > 0"
|
||||
v-model:page="errorPage"
|
||||
:item-count="filteredErrorLogs.length"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="!loadingError && errorLogs.length === 0"
|
||||
title="暂无 Nginx 错误日志"
|
||||
description="Nginx 出现 SSL、上游或配置相关错误时会记录在这里。"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</FonuCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NPagination,
|
||||
NSelect,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
NTag,
|
||||
useMessage,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { api, asList } from '../api/client'
|
||||
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
|
||||
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 {
|
||||
accessServiceTooltip,
|
||||
buildProxyBindingIndex,
|
||||
formatAccessFallback,
|
||||
resolveAccessServiceLabel,
|
||||
} from '../utils/accessService'
|
||||
import { formatLogTime, formatMs } from '../utils/format'
|
||||
import {
|
||||
latencyClass,
|
||||
nginxLevelTagType,
|
||||
parseNginxErrorLine,
|
||||
type ParsedNginxError,
|
||||
displaySystemLog,
|
||||
systemLevelTagType,
|
||||
systemModuleTagType,
|
||||
} from '../utils/logDisplay'
|
||||
import { httpStatusKind } from '../utils/status'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const LogPagination = defineComponent({
|
||||
name: 'LogPagination',
|
||||
props: {
|
||||
page: { type: Number, required: true },
|
||||
itemCount: { type: Number, required: true },
|
||||
},
|
||||
emits: ['update:page'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'log-pagination' }, [
|
||||
h(NPagination, {
|
||||
page: props.page,
|
||||
pageSize: PAGE_SIZE,
|
||||
itemCount: props.itemCount,
|
||||
showSizePicker: false,
|
||||
'onUpdate:page': (p: number) => emit('update:page', p),
|
||||
}),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const LogToolbar = defineComponent({
|
||||
name: 'LogToolbar',
|
||||
props: {
|
||||
keyword: { type: String, default: '' },
|
||||
status: { type: Number as () => number | null, default: null },
|
||||
level: { type: String, default: '' },
|
||||
showDomain: Boolean,
|
||||
showStatus: Boolean,
|
||||
showLevel: Boolean,
|
||||
autoRefresh: Boolean,
|
||||
},
|
||||
emits: ['update:keyword', 'update:status', 'update:level', 'refresh', 'toggle-auto'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
h('div', { class: 'toolbar' }, [
|
||||
h(NInput, {
|
||||
value: props.keyword,
|
||||
placeholder: '关键词搜索',
|
||||
clearable: true,
|
||||
style: 'max-width: 220px',
|
||||
'onUpdate:value': (v: string) => emit('update:keyword', v),
|
||||
}),
|
||||
props.showStatus
|
||||
? h(NSelect, {
|
||||
value: props.status,
|
||||
placeholder: '状态码',
|
||||
clearable: true,
|
||||
style: 'width: 120px',
|
||||
options: [200, 301, 400, 403, 404, 500].map((c) => ({ label: String(c), value: c })),
|
||||
'onUpdate:value': (v: number | null) => emit('update:status', v),
|
||||
})
|
||||
: null,
|
||||
props.showLevel
|
||||
? h(NSelect, {
|
||||
value: props.level,
|
||||
placeholder: '日志级别',
|
||||
clearable: true,
|
||||
style: 'width: 120px',
|
||||
options: [
|
||||
{ label: '信息', value: 'INFO' },
|
||||
{ label: '警告', value: 'WARN' },
|
||||
{ label: '错误', value: 'ERROR' },
|
||||
],
|
||||
'onUpdate:value': (v: string) => emit('update:level', v),
|
||||
})
|
||||
: null,
|
||||
h(NButton, { onClick: () => emit('refresh') }, () => '刷新'),
|
||||
props.autoRefresh !== undefined
|
||||
? h(NButton, { quaternary: true, onClick: () => emit('toggle-auto') }, () =>
|
||||
props.autoRefresh ? '关闭自动刷新' : '自动刷新',
|
||||
)
|
||||
: null,
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const LOG_TABS = ['system', 'access', 'nginx'] as const
|
||||
type LogTab = (typeof LOG_TABS)[number]
|
||||
|
||||
function resolveTab(queryTab: unknown): LogTab {
|
||||
if (queryTab === 'error' || queryTab === 'nginx') return 'nginx'
|
||||
if (queryTab === 'stream') return 'access'
|
||||
if (typeof queryTab === 'string' && (LOG_TABS as readonly string[]).includes(queryTab)) {
|
||||
return queryTab as LogTab
|
||||
}
|
||||
return 'system'
|
||||
}
|
||||
|
||||
const tab = ref(resolveTab(route.query.tab))
|
||||
const pageError = ref('')
|
||||
const autoRefresh = ref(false)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const accessLogs = ref<AccessLogEntry[]>([])
|
||||
const proxyRules = ref<ProxyRule[]>([])
|
||||
const proxyBindingIndex = computed(() => buildProxyBindingIndex(proxyRules.value))
|
||||
const errorLogs = ref<ParsedNginxError[]>([])
|
||||
const systemLogs = ref<SystemLogEntry[]>([])
|
||||
const loadingAccess = ref(false)
|
||||
const loadingError = ref(false)
|
||||
const loadingSystem = ref(false)
|
||||
|
||||
const accessKeyword = ref('')
|
||||
const accessStatus = ref<number | null>(null)
|
||||
const errorKeyword = ref('')
|
||||
const systemKeyword = ref('')
|
||||
const systemLevel = ref('')
|
||||
|
||||
const accessPage = ref(1)
|
||||
const errorPage = ref(1)
|
||||
const systemPage = ref(1)
|
||||
|
||||
const filteredAccessLogs = computed(() => {
|
||||
return accessLogs.value.filter((log) => {
|
||||
if (accessKeyword.value) {
|
||||
const kw = accessKeyword.value.toLowerCase()
|
||||
const service = resolveAccessServiceLabel(log, proxyBindingIndex.value)
|
||||
const fallback = formatAccessFallback(log)
|
||||
if (!`${service} ${fallback} ${log.domain} ${log.path} ${log.client_ip}`.toLowerCase().includes(kw)) return false
|
||||
}
|
||||
if (accessStatus.value && log.status !== accessStatus.value) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const filteredErrorLogs = computed(() => {
|
||||
if (!errorKeyword.value) return errorLogs.value
|
||||
const kw = errorKeyword.value.toLowerCase()
|
||||
return errorLogs.value.filter((entry) => entry.raw.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const filteredSystemLogs = computed(() => {
|
||||
return systemLogs.value.filter((log) => {
|
||||
const display = displaySystemLog(log)
|
||||
if (systemLevel.value && log.level.toUpperCase() !== systemLevel.value) return false
|
||||
if (
|
||||
systemKeyword.value &&
|
||||
!`${display.displayModule} ${log.message}`.toLowerCase().includes(systemKeyword.value.toLowerCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function paginate<T>(items: T[], page: number) {
|
||||
const start = (page - 1) * PAGE_SIZE
|
||||
return items.slice(start, start + PAGE_SIZE)
|
||||
}
|
||||
|
||||
const pagedAccessLogs = computed(() => paginate(filteredAccessLogs.value, accessPage.value))
|
||||
const pagedErrorLogs = computed(() => paginate(filteredErrorLogs.value, errorPage.value))
|
||||
const pagedSystemLogs = computed(() => paginate(filteredSystemLogs.value, systemPage.value))
|
||||
|
||||
const methodTagType = (method: string) => {
|
||||
const map: Record<string, 'success' | 'info' | 'warning' | 'error' | 'default'> = {
|
||||
GET: 'success',
|
||||
POST: 'info',
|
||||
PUT: 'warning',
|
||||
DELETE: 'error',
|
||||
}
|
||||
return map[method] ?? 'default'
|
||||
}
|
||||
|
||||
const statusTagType = (code: number) => {
|
||||
const kind = httpStatusKind(code)
|
||||
if (kind === 'success') return 'success'
|
||||
if (kind === 'warning') return 'warning'
|
||||
if (kind === 'error') return 'error'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const logTimeCell = (time: string) =>
|
||||
h('span', { class: 'log-time' }, formatLogTime(time))
|
||||
|
||||
function accessRowClassName(row: AccessLogEntry) {
|
||||
if (row.status >= 500) return 'log-row log-row--error'
|
||||
if (row.status >= 400) return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
function errorRowClassName(row: ParsedNginxError) {
|
||||
const type = nginxLevelTagType(row.level)
|
||||
if (type === 'error') return 'log-row log-row--error'
|
||||
if (type === 'warning') return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
function systemRowClassName(row: SystemLogEntry) {
|
||||
const display = displaySystemLog(row)
|
||||
if (display.displayLevel === '错误') return 'log-row log-row--error'
|
||||
if (display.displayLevel === '警告') return 'log-row log-row--warn'
|
||||
return 'log-row'
|
||||
}
|
||||
|
||||
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
|
||||
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
|
||||
{
|
||||
title: '服务',
|
||||
key: 'service',
|
||||
width: 128,
|
||||
render: (row) => {
|
||||
const tooltip = accessServiceTooltip(row, proxyBindingIndex.value)
|
||||
const label = resolveAccessServiceLabel(row, proxyBindingIndex.value)
|
||||
return h('span', { class: 'log-service nowrap', title: tooltip }, label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '方法',
|
||||
key: 'method',
|
||||
width: 68,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: methodTagType(row.method) }, () => row.method),
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
key: 'path',
|
||||
width: 220,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'log-path mono nowrap', title: row.path }, row.path),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 68,
|
||||
render: (row) => h(NTag, { size: 'small', bordered: false, type: statusTagType(row.status) }, () => String(row.status)),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
key: 'response_time',
|
||||
width: 80,
|
||||
render: (row) => h('span', { class: latencyClass(row.response_time) }, formatMs(row.response_time)),
|
||||
},
|
||||
{
|
||||
title: '来源 IP',
|
||||
key: 'client_ip',
|
||||
width: 140,
|
||||
render: (row) => h('span', { class: 'mono nowrap', title: row.client_ip }, row.client_ip),
|
||||
},
|
||||
])
|
||||
|
||||
const errorColumns: DataTableColumns<ParsedNginxError> = [
|
||||
{ title: '时间', key: 'time', width: 170, render: (row) => logTimeCell(row.time || '-') },
|
||||
{
|
||||
title: '级别',
|
||||
key: 'level',
|
||||
width: 88,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: nginxLevelTagType(row.level) },
|
||||
() => (row.level === 'unknown' ? '未知' : row.level.toUpperCase()),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'log-message mono' }, row.message),
|
||||
},
|
||||
]
|
||||
|
||||
const systemColumns: DataTableColumns<SystemLogEntry> = [
|
||||
{ title: '时间', key: 'time', width: 168, render: (row) => logTimeCell(row.time) },
|
||||
{
|
||||
title: '级别',
|
||||
key: 'level',
|
||||
width: 80,
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h(
|
||||
NTag,
|
||||
{ size: 'small', bordered: false, type: systemLevelTagType(display.displayLevel) },
|
||||
() => display.displayLevel,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
key: 'module',
|
||||
width: 108,
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: systemModuleTagType(display.displayModule),
|
||||
class: 'log-module-tag',
|
||||
},
|
||||
() => display.displayModule,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '消息',
|
||||
key: 'message',
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => {
|
||||
const display = displaySystemLog(row)
|
||||
return h('span', { class: 'log-message' }, display.message)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadProxyRules() {
|
||||
try {
|
||||
proxyRules.value = asList(await api.listProxies())
|
||||
} catch {
|
||||
proxyRules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccess() {
|
||||
loadingAccess.value = true
|
||||
try {
|
||||
await loadProxyRules()
|
||||
accessLogs.value = asList(await api.getAccessLogs({ limit: 100 }))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载访问日志失败')
|
||||
} finally {
|
||||
loadingAccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadErrorLogs() {
|
||||
loadingError.value = true
|
||||
try {
|
||||
const errors = asList(await api.getErrorLogs({ limit: 100 }))
|
||||
errorLogs.value = errors.map((line) => parseNginxErrorLine(line))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载错误日志失败')
|
||||
} finally {
|
||||
loadingError.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemLogs() {
|
||||
loadingSystem.value = true
|
||||
try {
|
||||
systemLogs.value = asList(await api.getSystemLogs({ limit: 100 }))
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '加载系统日志失败')
|
||||
} finally {
|
||||
loadingSystem.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
pageError.value = ''
|
||||
try {
|
||||
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs()])
|
||||
} catch (error) {
|
||||
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
|
||||
}
|
||||
}
|
||||
|
||||
watch([accessKeyword, accessStatus], () => {
|
||||
accessPage.value = 1
|
||||
})
|
||||
|
||||
watch(errorKeyword, () => {
|
||||
errorPage.value = 1
|
||||
})
|
||||
|
||||
watch([systemKeyword, systemLevel], () => {
|
||||
systemPage.value = 1
|
||||
})
|
||||
|
||||
watch(autoRefresh, (on) => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
if (on) refreshTimer = setInterval(() => loadAccess(), 10000)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(queryTab) => {
|
||||
tab.value = resolveTab(queryTab)
|
||||
},
|
||||
)
|
||||
|
||||
watch(tab, (name) => {
|
||||
if (name === 'access') loadAccess()
|
||||
else if (name === 'nginx') loadErrorLogs()
|
||||
else if (name === 'system') loadSystemLogs()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
tab.value = resolveTab(route.query.tab)
|
||||
loadAll()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logs-card :deep(.n-tabs-nav) {
|
||||
padding: var(--fonu-space-4) var(--fonu-space-5) 0;
|
||||
}
|
||||
|
||||
.logs-card :deep(.n-tabs-tab) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logs-card :deep(.n-tab-pane) {
|
||||
padding-top: var(--fonu-space-2);
|
||||
}
|
||||
|
||||
.tab-hint {
|
||||
margin: 0 var(--fonu-space-5) var(--fonu-space-3);
|
||||
font-size: 13px;
|
||||
color: var(--fonu-text-muted);
|
||||
}
|
||||
|
||||
.log-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--fonu-space-4) var(--fonu-space-5);
|
||||
border-top: 1px solid var(--fonu-border);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-th) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-base-table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-td) {
|
||||
font-size: 13px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row--error .n-data-table-td) {
|
||||
background: color-mix(in srgb, #ef4444 7%, transparent);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row--warn .n-data-table-td) {
|
||||
background: color-mix(in srgb, #f59e0b 7%, transparent);
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-table .n-data-table-tr.log-row:hover .n-data-table-td) {
|
||||
background: color-mix(in srgb, var(--fonu-brand) 5%, var(--fonu-surface));
|
||||
}
|
||||
|
||||
.log-time {
|
||||
white-space: nowrap;
|
||||
color: var(--fonu-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-service {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-path {
|
||||
color: var(--fonu-text-secondary);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--fonu-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.log-duration--slow {
|
||||
color: #d97706;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.log-duration--critical {
|
||||
color: #dc2626;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logs-card :deep(.log-module-tag) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<PageHeader title="公网反代" description="管理 VPS 上 Nginx 反代:部署/移除穿透规则的公网入口,支持强制跳转、IP 白名单、Basic Auth 与 WebSocket">
|
||||
<template #actions>
|
||||
<n-select
|
||||
v-model:value="selectedId"
|
||||
:options="serverOptions"
|
||||
placeholder="选择公网服务端"
|
||||
style="width: 220px"
|
||||
:disabled="servers.length === 0"
|
||||
/>
|
||||
<n-button :loading="refreshing" @click="refreshAll">刷新</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<LoadError v-if="loadError" :message="loadError" @retry="load" />
|
||||
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
v-if="servers.length === 0"
|
||||
title="尚未配置 FRP 服务端"
|
||||
description="先在「内网穿透」页添加公网服务端并保存 Agent/SSH 信息,再回到本页管理公网反代。"
|
||||
>
|
||||
<template #action>
|
||||
<router-link to="/frp"><n-button type="primary">去内网穿透页</n-button></router-link>
|
||||
</template>
|
||||
</EmptyState>
|
||||
|
||||
<template v-else-if="server">
|
||||
<div v-if="agentStatus?.nginx_missing" class="nginx-alert">
|
||||
<div class="nginx-alert__text">
|
||||
<strong>VPS 未安装 Nginx</strong>
|
||||
<span>公网反代需要 Nginx 承接 80/443 并终结 HTTPS。可点右侧按钮由 agent 自动安装(识别 apt-get / dnf / yum / apk,需 root),或在 VPS 手动执行:<code>apt install -y nginx</code> / <code>yum install -y nginx</code></span>
|
||||
</div>
|
||||
<n-button size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
||||
</div>
|
||||
<div v-else-if="agentStatus && agentStatus.nginx_ok === false" class="nginx-alert nginx-alert--warn">
|
||||
<div class="nginx-alert__text">
|
||||
<strong>Nginx 配置校验未通过</strong>
|
||||
<span class="mono">{{ agentStatus.nginx_error || 'nginx -t 执行失败' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FonuCard class="section-card" title="Nginx 状态">
|
||||
<div class="section-body">
|
||||
<div class="nginx-status-grid">
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">状态</span>
|
||||
<span class="nginx-status-item__value">
|
||||
<n-tag :type="nginxState().type" size="small" :bordered="false">{{ nginxState().label }}</n-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">进程</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_running ? '运行中' : '未运行' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">版本</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_version || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">可执行文件</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">vhost 配置目录</span>
|
||||
<span class="nginx-status-item__value mono">{{ agentStatus?.nginx_conf_dir || '—' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">配置校验</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">端口监听</span>
|
||||
<span class="nginx-status-item__value mono">80 {{ agentStatus?.ports?.['80'] ? '✓' : '✗' }} · 443 {{ agentStatus?.ports?.['443'] ? '✓' : '✗' }}</span>
|
||||
</div>
|
||||
<div class="nginx-status-item">
|
||||
<span class="nginx-status-item__label">反代配置文件</span>
|
||||
<span class="nginx-status-item__value">{{ agentStatus?.routes?.length ?? 0 }} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="agentStatus?.nginx_error" class="form-hint mono nginx-status-error">{{ agentStatus.nginx_error }}</p>
|
||||
<div class="nginx-status-actions">
|
||||
<n-button v-if="agentStatus?.nginx_missing" size="small" type="primary" :loading="installingNginx" @click="installNginx">一键安装 Nginx</n-button>
|
||||
<n-button size="small" :loading="refreshing" @click="refreshAll">重新检测</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="穿透规则与公网反代状态">
|
||||
<div class="section-body">
|
||||
<p class="form-hint">穿透规则保存后不会自动修改 VPS。点击「部署反代」把该规则的域名发布到公网 Nginx;部署时自动推送证书并做回源自检。</p>
|
||||
<div v-if="routeCandidates.length" class="route-list">
|
||||
<div v-for="candidate in routeCandidates" :key="candidate.id" class="route-item">
|
||||
<div class="route-item__main">
|
||||
<strong>{{ candidate.name }}</strong>
|
||||
<span class="route-item__domains mono">{{ candidate.custom_domains.join('、') }}</span>
|
||||
<span class="route-item__upstream mono">{{ candidate.local_ip }}:{{ candidate.local_port }}(回源 127.0.0.1:{{ vhostPortOf(candidate) }})</span>
|
||||
<div class="route-item__health">
|
||||
<span v-for="h in healthBadges(candidate)" :key="h.label" class="health-badge" :class="h.ok ? 'health-ok' : 'health-bad'" :title="h.detail">{{ h.label }} {{ h.ok ? '✓' : '✗' }}</span>
|
||||
</div>
|
||||
<div class="route-item__traffic">
|
||||
<span class="traffic-metric" title="frps 侧当前连接数">连接 {{ trafficOf(candidate).cur_conns }}</span>
|
||||
<span class="traffic-metric" title="当前上传速率(近两次采样均值)">当前上传 {{ formatRate(trafficOf(candidate).traffic_in_rate) }}</span>
|
||||
<span class="traffic-metric" title="当前下载速率(近两次采样均值)">当前下载 {{ formatRate(trafficOf(candidate).traffic_out_rate) }}</span>
|
||||
<span class="traffic-metric" title="frps 侧当日累计上传">总上传 {{ formatBytes(trafficOf(candidate).traffic_in) }}</span>
|
||||
<span class="traffic-metric" title="frps 侧当日累计下载">总下载 {{ formatBytes(trafficOf(candidate).traffic_out) }}</span>
|
||||
<span v-if="trafficErrors[String(candidate.id)]" class="traffic-metric traffic-metric--error" :title="trafficErrors[String(candidate.id)]">流量采集失败</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="route-item__actions">
|
||||
<n-tag :type="stateMeta(candidate).type" size="small" :bordered="false">{{ stateMeta(candidate).label }}</n-tag>
|
||||
<n-button size="small" quaternary :loading="confLoadingId === candidate.id" @click="viewConf(candidate)">查看配置</n-button>
|
||||
<n-button size="small" @click="openDeploySettings(candidate)">部署设置</n-button>
|
||||
<n-button size="small" type="primary" :loading="routeActionId === candidate.id" @click="deployRoute(candidate)">部署反代</n-button>
|
||||
<n-button v-if="stateOf(candidate) !== 'none'" size="small" type="warning" quaternary :loading="routeActionId === candidate.id" @click="removeRoute(candidate)">移除反代</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-route">暂无可部署的 HTTP/HTTPS 穿透规则——先到「内网穿透」页创建启用的 http 类型规则(填自定义域名)</div>
|
||||
<details v-if="routeDetails.length" class="raw-routes">
|
||||
<summary>VPS 上 agent 管理的路由文件({{ routeDetails.length }})</summary>
|
||||
<div v-for="detail in routeDetails" :key="detail.domain" class="raw-route-row mono">
|
||||
{{ detail.domain }} — {{ detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误' }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<FonuCard class="section-card" title="使用说明">
|
||||
<div class="section-body">
|
||||
<p class="form-hint">部署选项(启用 HTTPS / 强制跳转 / WebSocket / 上传大小 / 代理超时 / IP 白名单与黑名单 / Basic Auth / 仅 TLS 1.3 / 安全响应头)在本页规则行的「部署设置」里配置,点「保存并重新部署」即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释里(点「查看配置」可见)。注意:这些选项需要 VPS 上已升级到新版 agent。</p>
|
||||
</div>
|
||||
</FonuCard>
|
||||
|
||||
<n-modal
|
||||
v-model:show="settingsModal"
|
||||
:mask-closable="false"
|
||||
style="width: 640px"
|
||||
>
|
||||
<div class="proxy-modal">
|
||||
<div class="proxy-modal__header">
|
||||
<h3 class="modal-title">部署设置 — {{ settingsTarget?.name }}</h3>
|
||||
<n-button size="small" quaternary @click="settingsModal = false">取消</n-button>
|
||||
</div>
|
||||
<div class="proxy-modal__tabbar">
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'basic' }" @click="settingsTab = 'basic'">基础</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'security' }" @click="settingsTab = 'security'">安全</button>
|
||||
<button type="button" class="proxy-modal__tab" :class="{ 'proxy-modal__tab--active': settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">高级</button>
|
||||
</div>
|
||||
<div class="proxy-modal__scroll">
|
||||
<n-form v-show="settingsTab === 'basic'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">启用 HTTPS</div>
|
||||
<div class="form-switch-row__hint">证书已部署时监听 443;关闭后仅保留 80 明文入口</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.https_enabled" size="small" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">HTTP 强制跳转 HTTPS</div>
|
||||
<div class="form-switch-row__hint">80 端口访问自动 301 到 HTTPS(需启用 HTTPS)</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.redirect_https" size="small" :disabled="!settingsForm.https_enabled" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">WebSocket 支持</div>
|
||||
<div class="form-switch-row__hint">附加 Upgrade / Connection 头,实时服务(家庭影院、监控等)需要</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.websocket" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="上传大小限制">
|
||||
<n-input v-model:value="settingsForm.client_max_body_size" placeholder="留空用 Nginx 默认,如 50m" />
|
||||
</n-form-item>
|
||||
<n-form-item label="代理读超时">
|
||||
<n-input v-model:value="settingsForm.proxy_read_timeout" placeholder="留空用 Nginx 默认,如 3600s" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
<n-form v-show="settingsTab === 'security'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="panel-title">IP 访问控制</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="IP 白名单(逗号分隔)">
|
||||
<n-input v-model:value="settingsForm.allow_ips_text" placeholder="如 1.2.3.4,10.0.0.0/24,留空不限制" />
|
||||
</n-form-item>
|
||||
<n-form-item label="IP 黑名单(逗号分隔)">
|
||||
<n-input v-model:value="settingsForm.deny_ips_text" placeholder="如 5.6.7.8,留空不限制" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">仅中国大陆 IP</div>
|
||||
<div class="form-switch-row__hint">部署时自动把本地维护的中国 IP 段下发到 VPS;非大陆 IP 返回 403</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.china_only" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-title">认证</div>
|
||||
<div class="field-grid">
|
||||
<n-form-item label="Basic Auth 用户名">
|
||||
<n-input v-model:value="settingsForm.basic_auth_user" placeholder="留空不启用" />
|
||||
</n-form-item>
|
||||
<n-form-item label="Basic Auth 密码">
|
||||
<n-input v-model:value="settingsForm.basic_auth_password" type="password" show-password-on="click" placeholder="留空则首次部署自动生成" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
<div class="panel-title">流量控制</div>
|
||||
<div class="field-grid field-grid--three">
|
||||
<n-form-item label="请求限流(次/秒)">
|
||||
<n-input-number v-model:value="settingsForm.rate_limit_rate" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="突发允许量">
|
||||
<n-input-number v-model:value="settingsForm.rate_limit_burst" :min="0" :max="100000" placeholder="留空按 0" style="width: 100%" />
|
||||
</n-form-item>
|
||||
<n-form-item label="连接数上限(每 IP)">
|
||||
<n-input-number v-model:value="settingsForm.conn_limit_max" :min="0" :max="100000" placeholder="留空不限制" style="width: 100%" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
</n-form>
|
||||
<n-form v-show="settingsTab === 'advanced'" label-placement="top" class="proxy-modal__pane">
|
||||
<div class="form-switch-list">
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">安全响应头</div>
|
||||
<div class="form-switch-row__hint">HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.security_headers" size="small" />
|
||||
</div>
|
||||
<div class="form-switch-row">
|
||||
<div class="form-switch-row__text">
|
||||
<div class="form-switch-row__label">仅 TLS 1.3</div>
|
||||
<div class="form-switch-row__hint">只允许 TLS 1.3 握手(需启用 HTTPS)</div>
|
||||
</div>
|
||||
<n-switch v-model:value="settingsForm.tls13_only" size="small" :disabled="!settingsForm.https_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</n-form>
|
||||
</div>
|
||||
<div class="proxy-modal__footer">
|
||||
<n-button size="small" @click="settingsModal = false">取消</n-button>
|
||||
<n-button size="small" type="primary" :loading="routeActionId === settingsTarget?.id" @click="saveDeploySettings">保存并重新部署</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="confModal"
|
||||
:mask-closable="true"
|
||||
style="width: 720px"
|
||||
>
|
||||
<div class="proxy-modal">
|
||||
<div class="proxy-modal__header">
|
||||
<h3 class="modal-title">反代配置 — {{ confDomain }}</h3>
|
||||
<n-button size="small" quaternary @click="confModal = false">关闭</n-button>
|
||||
</div>
|
||||
<pre class="code-block conf-view">{{ confContent || '加载中…' }}</pre>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
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 { FrpAgentRouteDetail, FrpAgentStatus, FrpProxy, FrpProxyTraffic, FrpServer } from '../api/types'
|
||||
import { formatBytes, formatRate } from '../utils/format'
|
||||
|
||||
const message = useMessage()
|
||||
const servers = ref<FrpServer[]>([])
|
||||
const selectedId = ref<number | null>(null)
|
||||
const loadError = ref('')
|
||||
const loadErrorMsg = ref('')
|
||||
const refreshing = ref(false)
|
||||
const routeCandidates = ref<FrpProxy[]>([])
|
||||
const routeDetails = ref<FrpAgentRouteDetail[]>([])
|
||||
const routeHealth = ref<import('../api/types').FrpAgentRouteHealth[]>([])
|
||||
const proxyTraffic = ref<Record<string, FrpProxyTraffic>>({})
|
||||
const trafficErrors = ref<Record<string, string>>({})
|
||||
const agentStatus = ref<FrpAgentStatus | null>(null)
|
||||
const installingNginx = ref(false)
|
||||
const routeActionId = ref<number | null>(null)
|
||||
const settingsModal = ref(false)
|
||||
const settingsTarget = ref<FrpProxy | null>(null)
|
||||
const settingsTab = ref<'basic' | 'security' | 'advanced'>('basic')
|
||||
const settingsForm = ref({
|
||||
websocket: false,
|
||||
// 存储用反向字段 https_disabled(缺省 = 启用 HTTPS,保持旧数据行为)
|
||||
https_enabled: true,
|
||||
redirect_https: false,
|
||||
client_max_body_size: '',
|
||||
proxy_read_timeout: '',
|
||||
allow_ips_text: '',
|
||||
deny_ips_text: '',
|
||||
basic_auth_user: '',
|
||||
basic_auth_password: '',
|
||||
security_headers: false,
|
||||
tls13_only: false,
|
||||
china_only: false,
|
||||
rate_limit_rate: null as number | null,
|
||||
rate_limit_burst: null as number | null,
|
||||
conn_limit_max: null as number | null,
|
||||
})
|
||||
const confLoadingId = ref<number | null>(null)
|
||||
const confModal = ref(false)
|
||||
const confDomain = ref('')
|
||||
const confContent = ref('')
|
||||
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 })))
|
||||
|
||||
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
|
||||
}
|
||||
await refreshAll()
|
||||
} catch (error) { loadError.value = error instanceof Error ? error.message : '读取服务端失败' }
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
routeCandidates.value = await api.getFrpAgentRouteCandidates(current.id)
|
||||
routeDetails.value = await api.getFrpAgentRoutes(current.id)
|
||||
routeHealth.value = await api.getFrpAgentRouteHealth(current.id)
|
||||
// 流量按规则 ID 聚合(frps dashboard);旧版 frps 会在后端回退为逐规则查询
|
||||
const traffic = await api.listFrpProxiesTraffic()
|
||||
proxyTraffic.value = traffic.items ?? {}
|
||||
trafficErrors.value = traffic.errors ?? {}
|
||||
// agent 状态用于 Nginx 可用性提示(nginx_missing / nginx_ok)
|
||||
try { agentStatus.value = await api.getFrpAgentStatus(current.id) } catch { agentStatus.value = null }
|
||||
} catch (error) { loadErrorMsg.value = error instanceof Error ? error.message : '读取规则失败' }
|
||||
finally { refreshing.value = false }
|
||||
}
|
||||
|
||||
function vhostPortOf(candidate: FrpProxy) {
|
||||
const port = server.value?.options.vhost_http_port || 8080
|
||||
return candidate.type === 'https' ? (server.value?.options.vhost_https_port || 8443) : port
|
||||
}
|
||||
|
||||
type RouteState = 'loaded' | 'written' | 'broken' | 'none'
|
||||
|
||||
function stateOf(candidate: FrpProxy): RouteState {
|
||||
for (const domain of candidate.custom_domains) {
|
||||
const d = routeDetails.value.find((item) => item.domain === domain)
|
||||
if (d) {
|
||||
if (d.loaded) return 'loaded'
|
||||
if (d.syntax_ok) return 'written'
|
||||
return 'broken'
|
||||
}
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function stateMeta(candidate: FrpProxy): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
||||
switch (stateOf(candidate)) {
|
||||
case 'loaded': return { label: '已生效', type: 'success' }
|
||||
case 'written': return { label: '已写入未加载', type: 'warning' }
|
||||
case 'broken': return { label: '语法错误', type: 'error' }
|
||||
default: return { label: '未部署', type: 'default' }
|
||||
}
|
||||
}
|
||||
|
||||
async function deployRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
routeActionId.value = candidate.id
|
||||
try {
|
||||
await api.deployFrpAgentRoute(current.id, candidate.id)
|
||||
message.success('公网反代已部署')
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '部署反代失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
async function removeRoute(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
routeActionId.value = candidate.id
|
||||
try {
|
||||
await api.removeFrpAgentRoute(current.id, candidate.id)
|
||||
message.success('公网反代已移除')
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '移除反代失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
function openDeploySettings(candidate: FrpProxy) {
|
||||
settingsTarget.value = candidate
|
||||
settingsTab.value = 'basic'
|
||||
settingsForm.value = {
|
||||
websocket: candidate.options?.websocket ?? false,
|
||||
https_enabled: !(candidate.options?.https_disabled ?? false),
|
||||
redirect_https: candidate.options?.redirect_https ?? false,
|
||||
client_max_body_size: candidate.options?.client_max_body_size ?? '',
|
||||
proxy_read_timeout: candidate.options?.proxy_read_timeout ?? '',
|
||||
allow_ips_text: (candidate.options?.allow_ips || []).join(','),
|
||||
deny_ips_text: (candidate.options?.deny_ips || []).join(','),
|
||||
basic_auth_user: candidate.options?.basic_auth_user ?? '',
|
||||
basic_auth_password: candidate.options?.basic_auth_password ?? '',
|
||||
security_headers: candidate.options?.security_headers ?? false,
|
||||
tls13_only: candidate.options?.tls13_only ?? false,
|
||||
china_only: candidate.options?.china_only ?? false,
|
||||
rate_limit_rate: candidate.options?.rate_limit_rate ?? null,
|
||||
rate_limit_burst: candidate.options?.rate_limit_burst ?? null,
|
||||
conn_limit_max: candidate.options?.conn_limit_max ?? null,
|
||||
}
|
||||
settingsModal.value = true
|
||||
}
|
||||
|
||||
async function saveDeploySettings() {
|
||||
const target = settingsTarget.value
|
||||
const current = server.value
|
||||
if (!target || !current) return
|
||||
routeActionId.value = target.id
|
||||
try {
|
||||
// 选项存在规则的 options 里(与穿透配置同库),更新后立即重部署生效
|
||||
const options = {
|
||||
...target.options,
|
||||
websocket: settingsForm.value.websocket,
|
||||
https_disabled: !settingsForm.value.https_enabled,
|
||||
redirect_https: settingsForm.value.redirect_https,
|
||||
client_max_body_size: settingsForm.value.client_max_body_size.trim(),
|
||||
proxy_read_timeout: settingsForm.value.proxy_read_timeout.trim(),
|
||||
allow_ips: settingsForm.value.allow_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
deny_ips: settingsForm.value.deny_ips_text.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
basic_auth_user: settingsForm.value.basic_auth_user.trim(),
|
||||
basic_auth_password: settingsForm.value.basic_auth_password,
|
||||
security_headers: settingsForm.value.security_headers,
|
||||
tls13_only: settingsForm.value.tls13_only,
|
||||
china_only: settingsForm.value.china_only,
|
||||
rate_limit_rate: settingsForm.value.rate_limit_rate ?? 0,
|
||||
rate_limit_burst: settingsForm.value.rate_limit_burst ?? 0,
|
||||
conn_limit_max: settingsForm.value.conn_limit_max ?? 0,
|
||||
}
|
||||
await api.updateFrpProxy(target.id, { server_id: target.server_id, name: target.name, type: target.type, local_ip: target.local_ip, local_port: target.local_port, remote_port: target.remote_port ?? null, custom_domains: target.custom_domains, host_header_rewrite: target.host_header_rewrite, options, enabled: target.enabled, remark: target.remark })
|
||||
settingsModal.value = false
|
||||
message.success('部署设置已保存,正在重新部署…')
|
||||
await deployRoute(target)
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '保存部署设置失败') }
|
||||
finally { routeActionId.value = null }
|
||||
}
|
||||
|
||||
// nginxState 汇总 Nginx 状态徽标:未安装 / 配置校验未通过 / 已安装未运行 / 正常。
|
||||
function nginxState(): { label: string; type: 'success' | 'warning' | 'error' | 'default' } {
|
||||
const status = agentStatus.value
|
||||
if (!status) return { label: '未知(未连接 agent)', type: 'default' }
|
||||
if (status.nginx_missing) return { label: '未安装', type: 'error' }
|
||||
if (!status.nginx_ok) return { label: '配置校验未通过', type: 'error' }
|
||||
if (status.nginx_running === false) return { label: '已安装未运行', type: 'warning' }
|
||||
return { label: '正常', type: 'success' }
|
||||
}
|
||||
|
||||
// installNginx 触发 VPS 上一键安装 Nginx(agent 侧识别包管理器并执行,需 root)。
|
||||
async function installNginx() {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
installingNginx.value = true
|
||||
try {
|
||||
const result = await api.installFrpAgentNginx(current.id)
|
||||
if (result.ok) {
|
||||
message.success(`Nginx 安装完成${result.path ? `(${result.path})` : ''}`)
|
||||
} else {
|
||||
message.warning(`安装命令已执行,但 Nginx 仍不可用:${(result.error || '').slice(0, 300)}`)
|
||||
}
|
||||
await refreshAll()
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '一键安装 Nginx 失败') }
|
||||
finally { installingNginx.value = false }
|
||||
}
|
||||
|
||||
// trafficOf 返回该规则的 frps 流量快照;无数据时回落到全零,避免模板出现 undefined。
|
||||
function trafficOf(candidate: FrpProxy): FrpProxyTraffic {
|
||||
return proxyTraffic.value[String(candidate.id)] ?? {
|
||||
traffic_in: 0, traffic_out: 0, cur_conns: 0, traffic_in_rate: 0, traffic_out_rate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 四段健康徽标:DNS 指向 / 证书覆盖 / 隧道注册 / 内网服务
|
||||
function healthBadges(candidate: FrpProxy) {
|
||||
return candidate.custom_domains.map((domain) => {
|
||||
const h = routeHealth.value.find((item) => item.domain === domain)
|
||||
return [
|
||||
{ label: 'DNS', ok: h?.dns_ok ?? false, detail: h?.dns_detail || (h?.dns_ok ? '已指向服务端' : '待检测') },
|
||||
{ label: '证书', ok: h?.cert_ok ?? false, detail: h?.cert_detail || (h?.cert_ok ? '已覆盖' : '未发现证书') },
|
||||
{ label: '隧道', ok: h?.tunnel_ok ?? false, detail: h?.tunnel_detail || (h?.tunnel_ok ? '已注册到 frps' : 'frps 未发现该规则的隧道') },
|
||||
{ label: '服务', ok: h?.service_ok ?? false, detail: h?.service_ok ? '内网服务可达' : '内网服务不可达' },
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
async function viewConf(candidate: FrpProxy) {
|
||||
const current = server.value
|
||||
if (!current) return
|
||||
confLoadingId.value = candidate.id
|
||||
try {
|
||||
const conf = await api.getFrpAgentRouteConf(current.id, candidate.id)
|
||||
confDomain.value = conf.domain
|
||||
confContent.value = conf.content
|
||||
confModal.value = true
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : '读取配置失败') }
|
||||
finally { confLoadingId.value = null }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
timer = window.setInterval(() => { if (server.value && !refreshing.value) void refreshAll() }, 15000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { if (timer) window.clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-card { margin-bottom: var(--fonu-space-4); }
|
||||
.nginx-alert { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); margin-bottom: var(--fonu-space-4); padding: 12px 16px; border: 1px solid #f0c36d; border-radius: var(--fonu-radius-sm); background: rgba(240, 195, 109, 0.12); }
|
||||
.nginx-alert--warn { border-color: #e79b9b; background: rgba(231, 155, 155, 0.12); }
|
||||
.nginx-alert__text { display: grid; gap: 4px; font-size: 13px; color: var(--fonu-text); }
|
||||
.nginx-alert__text code { padding: 1px 5px; border-radius: 4px; background: var(--fonu-bg-muted); font-family: var(--fonu-mono); font-size: 12px; }
|
||||
.nginx-status-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px var(--fonu-space-4); }
|
||||
.nginx-status-item { display: grid; gap: 3px; min-width: 0; }
|
||||
.nginx-status-item__label { font-size: 12px; color: var(--fonu-text-secondary); }
|
||||
.nginx-status-item__value { font-size: 13px; color: var(--fonu-text); overflow-wrap: anywhere; }
|
||||
.nginx-status-error { margin-top: var(--fonu-space-3); color: #dc2626; }
|
||||
.nginx-status-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-3); }
|
||||
.section-body { padding: var(--fonu-space-2) var(--fonu-space-5) 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; }
|
||||
.route-item__health { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.health-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; }
|
||||
.health-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
||||
.health-bad { background: rgba(239, 68, 68, 0.12); color: #dc2626; }
|
||||
.route-item__traffic { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.traffic-metric { font-size: 11px; color: var(--fonu-text-secondary); }
|
||||
.traffic-metric--error { color: #dc2626; }
|
||||
.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; }
|
||||
.raw-routes { margin-top: var(--fonu-space-3); font-size: 12px; color: var(--fonu-text-muted); }
|
||||
.raw-route-row { padding: 2px 0; }
|
||||
.conf-view { max-height: 60vh; }
|
||||
.proxy-modal { background: var(--fonu-surface); border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius); padding: var(--fonu-space-4) var(--fonu-space-5); box-shadow: var(--fonu-shadow-md); }
|
||||
.proxy-modal__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--fonu-space-3); }
|
||||
.proxy-modal__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: var(--fonu-space-4); }
|
||||
.proxy-modal__tabbar { display: flex; gap: var(--fonu-space-5); padding: 0 0 var(--fonu-space-3); margin-bottom: var(--fonu-space-2); border-bottom: 1px solid var(--fonu-border); }
|
||||
.proxy-modal__tab { margin: 0 0 -1px; padding: 8px 2px 10px; border: 0; border-bottom: 2px solid transparent; background: none; color: var(--fonu-text-secondary); font: inherit; font-size: 14px; cursor: pointer; }
|
||||
.proxy-modal__tab:hover { color: var(--fonu-text); }
|
||||
.proxy-modal__tab--active { color: var(--fonu-brand-text); font-weight: 600; border-bottom-color: var(--fonu-brand); }
|
||||
.proxy-modal__scroll { max-height: 60vh; overflow-y: auto; }
|
||||
.proxy-modal__pane { width: 100%; }
|
||||
.form-switch-list { display: grid; gap: 2px; margin-bottom: var(--fonu-space-3); }
|
||||
.form-switch-row { display: flex; align-items: center; justify-content: space-between; gap: var(--fonu-space-4); padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
|
||||
.form-switch-row__label { font-size: 13px; color: var(--fonu-text); }
|
||||
.form-switch-row__hint { margin-top: 2px; font-size: 12px; color: var(--fonu-text-secondary); }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--fonu-space-4); }
|
||||
.field-grid--three { grid-template-columns: 1fr 1fr 1fr; }
|
||||
.panel-title { margin: var(--fonu-space-2) 0; font-size: 13px; font-weight: 600; color: var(--fonu-text-secondary); }
|
||||
.modal-title { margin: 0; font-size: 16px; font-weight: 700; color: var(--fonu-text); }
|
||||
.code-block {
|
||||
margin: 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; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user