feat(cloudflare): 完成隧道接入、运维与 P0 体验优化

This commit is contained in:
2026-10-01 11:32:54 +08:00
parent 36d8b1e464
commit 83cc9d504b
48 changed files with 5383 additions and 201 deletions
+35 -2
View File
@@ -43,6 +43,13 @@ import type {
CfTunnelPayload,
CfAppSettings,
CfAppSettingsInput,
CfDriftResult,
CfDiagnosticsResult,
CfAccessStatus,
CfZone,
CfTunnelRoutes,
CfPreflightResult,
CfRouteTestResult,
} from './types'
export function asList<T>(value: T[] | null | undefined): T[] {
@@ -495,20 +502,46 @@ export const api = {
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/stop`, { method: 'POST' }),
restartCfTunnel: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/restart`, { method: 'POST' }),
refreshCfCredentials: (id: number) =>
request<CfTunnel>(`/api/cloudflare/tunnels/${id}/credentials/refresh`, { method: 'POST' }),
getCfTunnelStatus: (id: number) =>
request<CfRuntimeStatus>(`/api/cloudflare/tunnels/${id}/status`),
getCfTunnelLogs: (id: number, lines = 200) =>
request<{ output: string }>(`/api/cloudflare/tunnels/${id}/logs?lines=${lines}`),
getCfTunnelConfig: (id: number) =>
request<{ content: string }>(`/api/cloudflare/tunnels/${id}/config`),
getCfTunnelRoutes: (id: number) =>
request<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`),
replaceCfTunnelRoutes: (id: number, routes: Array<{ hostname: string; path: string; service: string }>) =>
request<CfTunnelRoutes>(`/api/cloudflare/tunnels/${id}/routes`, {
method: 'PUT',
body: JSON.stringify({ routes }),
}),
getCfTunnelDrift: (id: number) => request<CfDriftResult>(`/api/cloudflare/tunnels/${id}/drift`),
syncCfTunnelRoutes: (id: number) =>
request<{ ok: boolean }>(`/api/cloudflare/tunnels/${id}/sync`, { method: 'POST' }),
getCfTunnelDiagnostics: (id: number) =>
request<CfDiagnosticsResult>(`/api/cloudflare/tunnels/${id}/diagnostics`),
getCfBinary: () => request<{ version: string; mirrors: CfMirror[] }>('/api/cloudflare/binary'),
downloadCfBinary: (mirror: string) =>
downloadCfBinary: (mirror: string, version = '') =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/download', {
method: 'POST',
body: JSON.stringify({ mirror }),
body: JSON.stringify({ mirror, version }),
}),
getLatestCfBinary: () => request<{ version: string }>('/api/cloudflare/binary/latest'),
rollbackCfBinary: () =>
request<{ ok: boolean; version: string }>('/api/cloudflare/binary/rollback', { method: 'POST' }),
getCfIngressCandidates: () =>
request<{ hostnames: string[] }>('/api/cloudflare/ingress-candidates'),
getCfZones: () => request<{ zones: CfZone[] }>('/api/cloudflare/zones'),
getCfAccessStatus: (hostname: string) =>
request<CfAccessStatus>(`/api/cloudflare/access-status?hostname=${encodeURIComponent(hostname)}`),
getCfPreflight: () => request<CfPreflightResult>('/api/cloudflare/preflight'),
testCfRoute: (service: string) =>
request<CfRouteTestResult>('/api/cloudflare/routes/test', {
method: 'POST',
body: JSON.stringify({ service }),
}),
getCfSettings: () => request<CfAppSettings>('/api/cloudflare/settings'),
saveCfSettings: (payload: CfAppSettingsInput) =>
request<CfAppSettings>('/api/cloudflare/settings', { method: 'PUT', body: JSON.stringify(payload) }),
+82
View File
@@ -50,6 +50,9 @@ export interface ProxyRule {
name: string
sort_order: number
security: ProxySecurityConfig
exits: string[]
cf_tunnel_id: number
sync_warning?: string
created_at: string
updated_at: string
}
@@ -108,6 +111,8 @@ export interface ProxySavePayload {
enabled?: boolean
name?: string
security?: ProxySecurityPayload
exits?: string[]
cf_tunnel_id?: number
}
export interface AuthStatus {
@@ -165,6 +170,10 @@ export interface DashboardStatus {
mem_total_bytes: number
disk_used_bytes: number
disk_total_bytes: number
cloudflare_count: number
cloudflare_online: number
cloudflare_status: string
cloudflare_message?: string
}
export interface DDNSDomainRecord {
@@ -1024,9 +1033,19 @@ export interface MetricsHistory {
export interface StatusPageRoute {
domain: string
state: string
source?: 'frp' | 'cloudflare' | string
uptime_pct: number
down_minutes: number
unknown_minutes: number
incidents: StatusPageIncident[]
}
export interface StatusPageIncident {
from: string
to?: string
reason?: string
detail?: string
minutes: number
}
export interface StatusPagePayload {
@@ -1064,6 +1083,7 @@ export interface CfTunnel {
last_error?: string
metrics_port?: number
auto_start: boolean
managed: boolean
hostnames?: string[]
origin_service?: string
dns_warning?: string
@@ -1090,6 +1110,11 @@ export interface CfMirror {
base: string
}
export interface CfZone {
id: string
name: string
}
export interface CfTunnelPayload {
name: string
mode: string
@@ -1098,6 +1123,7 @@ export interface CfTunnelPayload {
origin_service?: string
network: CfNetworkSettings
auto_start: boolean
managed: boolean
}
export interface CfAppSettings {
@@ -1119,3 +1145,59 @@ export interface CfAppSettingsInput {
mirror: string
network: CfNetworkSettings
}
export interface CfDriftResult {
tunnel_id: number
managed: boolean
has_drift: boolean
items: string[]
}
export interface CfRouteView {
id?: number
hostname: string
path: string
service: string
source: 'rule' | 'config' | string
}
export interface CfTunnelRoutes {
managed: boolean
routes: CfRouteView[]
catch_all: string
}
export interface CfDiagnosticCheck {
name: string
status: 'ok' | 'warning' | 'error' | string
message: string
}
export interface CfDiagnosticsResult {
tunnel_id: number
generated_at: string
checks: CfDiagnosticCheck[]
}
export interface CfPreflightCheck {
name: string
status: 'ok' | 'error' | string
message: string
action?: string
}
export interface CfPreflightResult {
ready: boolean
checks: CfPreflightCheck[]
}
export interface CfRouteTestResult {
ok: boolean
message: string
latency_ms: number
}
export interface CfAccessStatus {
enabled: boolean
name?: string
}
+1 -1
View File
@@ -201,8 +201,8 @@ const menuDefs = [
{ label: '反向代理', key: 'proxies', icon: SwapHorizontalOutline },
{ label: '内网穿透', key: 'frp', icon: GitNetworkOutline },
{ label: '公网服务端', key: 'frp-agent', icon: HardwareChipOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '公网反代', key: 'public-proxy', icon: ShieldCheckmarkOutline },
{ label: 'Cloudflare 隧道', key: 'cloudflare', icon: CloudOutline },
{ label: '日志中心', key: 'logs', icon: DocumentTextOutline },
{ label: '系统设置', key: 'settings', icon: CogOutline },
{ label: '示例页面', key: 'example', icon: FlashOutline },
+7
View File
@@ -10,6 +10,7 @@ import LogsView from '../views/LogsView.vue'
import SettingsView from '../views/SettingsView.vue'
import FrpView from '../views/FrpView.vue'
import CloudflareView from '../views/CloudflareView.vue'
import CloudflareTunnelConfigView from '../views/CloudflareTunnelConfigView.vue'
import AgentView from '../views/AgentView.vue'
import ProxyRemoteView from '../views/ProxyRemoteView.vue'
import StatusPageView from '../views/StatusPageView.vue'
@@ -70,6 +71,12 @@ const router = createRouter({
component: CloudflareView,
meta: { title: 'Cloudflare 隧道', description: '通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘' },
},
{
path: 'cloudflare/:id/config',
name: 'cloudflare-tunnel-config',
component: CloudflareTunnelConfigView,
meta: { title: 'Cloudflare 隧道配置', description: '配置隧道、查看副本指标与已发布路由' },
},
{
path: 'public-proxy',
name: 'public-proxy',
@@ -0,0 +1,714 @@
<template>
<PageHeader :title="`配置 · ${tunnel?.name || 'Cloudflare 隧道'}`" description="配置隧道、查看副本指标与已发布路由">
<template #actions>
<n-button @click="router.push('/cloudflare')">
<template #icon><n-icon :component="ArrowBackOutline" /></template>
返回列表
</n-button>
</template>
</PageHeader>
<LoadError v-if="loadError" :message="loadError" @retry="load" />
<template v-else-if="tunnel">
<div class="stats-row">
<StatCard label="活动副本" :value="status?.connections ?? 0" sub="cloudflared HA 连接" tone="blue">
<template #icon><n-icon :component="GitNetworkOutline" /></template>
</StatCard>
<StatCard label="路由" :value="routeData?.routes.length ?? 0" sub="已发布应用" tone="green">
<template #icon><n-icon :component="LayersOutline" /></template>
</StatCard>
<StatCard label="状态" :sub="status?.transport || tunnel.mode" tone="teal">
<template #icon><n-icon :component="PulseOutline" /></template>
<template #value><StatusBadge :kind="statusKind(runtimeState)" :text="statusText(runtimeState)" /></template>
</StatCard>
<StatCard label="运行时间" :value="status?.running ? '运行中' : '未运行'" :sub="status?.metrics_port ? `metrics :${status.metrics_port}` : '无 metrics 端口'" tone="brand" value-small>
<template #icon><n-icon :component="TimeOutline" /></template>
</StatCard>
</div>
<FonuCard class="section-card" title="基础配置" subtitle="手工隧道可在这里修改;托管隧道由反向代理规则派生">
<div class="section-body">
<n-alert v-if="tunnel.managed" type="info" :bordered="false">
这是自动托管隧道。暴露域名与回源请到
<router-link to="/proxies">反向代理</router-link>
的 Cloudflare 出口中维护。
</n-alert>
<div v-else class="config-basic-action">
<n-button type="primary" @click="basicVisible = true">编辑基础配置</n-button>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="路由" subtitle="流量通过以下路由到应用程序和服务">
<div class="section-body">
<div class="topology-canvas" :style="{ height: `${topologyHeight}px` }">
<div class="topology-graph">
<div
v-for="(route, index) in routeData?.routes || []"
:key="'domain-' + route.hostname"
class="topology-domain"
:style="{ top: `${routeY(index)}px` }"
>
<div class="topology-node-copy">
<span class="topology-domain__name mono">{{ route.hostname }}</span>
</div>
<n-tag size="small" type="info" :bordered="false" round>已发布的应用程序</n-tag>
</div>
<n-button
v-if="!tunnel.managed"
class="topology-add"
size="small"
:style="{ top: `${addRouteY}px` }"
@click="openAddRoute"
>
<template #icon><n-icon :component="AddOutline" /></template>
添加路由
</n-button>
<div class="topology-exit" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="CloudOutline" />
<div class="topology-node-copy">
<strong>Cloudflare</strong>
</div>
</div>
<div class="topology-tunnel" :style="{ top: `${centerY - 22}px` }">
<n-icon :component="GitNetworkOutline" />
<div class="topology-node-copy">
<strong>{{ tunnel.name }}</strong>
</div>
</div>
<div
v-for="(service, index) in topologyServices"
:key="'service-' + service"
class="topology-service"
:style="{ top: `${serviceY(index)}px` }"
>
<n-icon :component="ServerOutline" />
<div class="topology-node-copy">
<span class="mono">{{ service }}</span>
</div>
</div>
<svg class="topology-lines" :viewBox="`0 0 ${TOPOLOGY_WIDTH} ${topologyHeight}`">
<template v-for="(route, index) in routeData?.routes || []" :key="'line-' + route.hostname">
<path :d="domainToExitPath(routeY(index) + 22)" />
<path :d="exitToTunnelPath" />
<path :d="tunnelToServicePath(serviceY(topologyServices.indexOf(route.service)) + 22)" />
</template>
<path v-if="!tunnel.managed" :d="domainToExitPath(addRouteY + 17)" />
</svg>
</div>
</div>
<div v-if="routeData?.routes.length" class="routes-table">
<div class="routes-table__head">
<span>顺序</span>
<span>目标</span>
<span>路径</span>
<span>服务</span>
<span>来源</span>
<span>操作</span>
</div>
<div v-for="(route, index) in routeData.routes" :key="route.hostname + route.service" class="routes-table__row">
<span>{{ index + 1 }}</span>
<span class="mono">{{ route.hostname }}</span>
<span class="mono">{{ route.path }}</span>
<span class="mono">{{ route.service }}</span>
<n-tag size="small" :bordered="false" round>{{ route.source === 'rule' ? '反向代理规则' : 'config.yml' }}</n-tag>
<div class="routes-table__actions">
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === 0" @click="moveRoute(index, -1)">上移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed || index === (routeData?.routes.length ?? 1) - 1" @click="moveRoute(index, 1)">下移</n-button>
<n-button size="tiny" quaternary :disabled="tunnel.managed" @click="copyRoute(route)">复制</n-button>
<n-button size="tiny" quaternary @click="testRoute(route)">测试</n-button>
<n-button size="tiny" :disabled="tunnel.managed" @click="openEditRoute(route, index)">编辑</n-button>
<n-popconfirm :disabled="tunnel.managed" @positive-click="deleteRoute(index)">
<template #trigger>
<n-button size="tiny" type="error" quaternary :disabled="tunnel.managed">删除</n-button>
</template>
删除路由 {{ route.hostname }}?保存后会同步到 Cloudflare。
</n-popconfirm>
</div>
</div>
</div>
<EmptyState
v-else
title="还没有已发布路由"
description="手工隧道可在基础配置中添加域名和源服务,托管隧道请到反向代理规则维护。"
>
<template v-if="!tunnel.managed" #action>
<n-button type="primary" @click="openAddRoute">添加路由</n-button>
</template>
</EmptyState>
<div class="routes-catchall">
<span>全部捕获规则:</span>
<strong class="mono">{{ routeData?.catch_all || 'http_status:404' }}</strong>
</div>
</div>
</FonuCard>
<FonuCard class="section-card" title="运行控制" subtitle="进程操作会立即作用在本机 cloudflared">
<div class="section-body">
<n-space>
<n-button type="success" :disabled="runtimeState === 'connected'" @click="runAction('启动')">启动</n-button>
<n-button type="warning" :disabled="runtimeState !== 'connected'" @click="runAction('停止')">停止</n-button>
<n-button @click="runAction('重启')">重启</n-button>
</n-space>
</div>
</FonuCard>
</template>
<n-modal v-model:show="basicVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="基础配置">
<n-form label-placement="top" class="config-form">
<n-form-item label="名称">
<n-input v-model:value="form.name" />
</n-form-item>
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="form.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="form.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ basicHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="form.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="form.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
<div class="config-form__footer">
<n-switch v-model:value="form.autoStart" />
<span>随 Fonu 自动启动</span>
</div>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="basicVisible = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">保存配置</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="routeVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" :title="routeEditingIndex === null ? '添加路由' : '编辑路由'">
<n-form label-placement="top" class="config-form">
<div class="config-form__grid">
<n-form-item label="子域名(可选)">
<n-input v-model:value="routeForm.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域">
<n-select
v-model:value="routeForm.domain"
:options="zoneOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ routeHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="config-origin">
<n-select v-model:value="routeForm.type" :options="originTypeOptions" class="config-origin__type" />
<span class="config-origin__scheme">://</span>
<n-input v-model:value="routeForm.target" placeholder="192.168.11.30:8001" />
</div>
</n-form-item>
</n-form>
<template #footer>
<div class="config-modal__footer">
<n-button @click="routeVisible = false">取消</n-button>
<n-button type="primary" :loading="routeSaving" @click="saveRoute">保存路由</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NAlert, NButton, NForm, NFormItem, NIcon, NInput, NModal, NPopconfirm, NSelect, NSpace, NSwitch, NTag, useMessage } from 'naive-ui'
import { AddOutline, ArrowBackOutline, CloudOutline, GitNetworkOutline, LayersOutline, PulseOutline, ServerOutline, TimeOutline } from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { CfRouteView, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfTunnelRoutes, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import EmptyState from '../components/EmptyState.vue'
import LoadError from '../components/LoadError.vue'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
import StatusBadge from '../components/StatusBadge.vue'
import type { StatusKind } from '../utils/status'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const tunnel = ref<CfTunnel | null>(null)
const status = ref<CfRuntimeStatus | null>(null)
const routeData = ref<CfTunnelRoutes | null>(null)
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const basicVisible = ref(false)
const routeVisible = ref(false)
const routeSaving = ref(false)
const routeEditingIndex = ref<number | null>(null)
const routeForm = ref({ subdomain: '', domain: '', type: 'http', target: '' })
const loadError = ref('')
const form = ref({ name: '', subdomain: '', domain: '', type: 'http', target: '', autoStart: false })
const TOPOLOGY_WIDTH = 820
const TOPOLOGY_ROW_HEIGHT = 54
const TOPOLOGY_START_Y = 38
const tunnelID = computed(() => Number(route.params.id))
const runtimeState = computed(() => status.value?.status || tunnel.value?.status || 'stopped')
const zoneOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const topologyServices = computed(() => {
const values = new Set((routeData.value?.routes || []).map((route) => route.service))
return [...values]
})
const addRouteY = computed(() => TOPOLOGY_START_Y + (routeData.value?.routes.length ?? 0) * TOPOLOGY_ROW_HEIGHT)
const topologyHeight = computed(() => Math.max(240, Math.max(addRouteY.value + 70, topologyServices.value.length * TOPOLOGY_ROW_HEIGHT + TOPOLOGY_START_Y + 30)))
const centerY = computed(() => topologyHeight.value / 2)
function routeY(index: number) {
return TOPOLOGY_START_Y + index * TOPOLOGY_ROW_HEIGHT
}
function serviceY(index: number) {
const count = Math.max(1, topologyServices.value.length)
const total = (count - 1) * TOPOLOGY_ROW_HEIGHT
return topologyHeight.value / 2 - total / 2 + index * TOPOLOGY_ROW_HEIGHT - 22
}
function domainToExitPath(sourceY: number) {
return `M 220 ${sourceY} C 250 ${sourceY}, 265 ${centerY.value}, 292 ${centerY.value}`
}
function exitToTunnelPath() {
return `M 392 ${centerY.value} L 470 ${centerY.value}`
}
function tunnelToServicePath(targetY: number) {
return `M 578 ${centerY.value} C 610 ${centerY.value}, 625 ${targetY}, 660 ${targetY}`
}
async function load() {
if (!Number.isFinite(tunnelID.value) || tunnelID.value <= 0) {
loadError.value = '隧道 ID 无效'
return
}
loading.value = true
loadError.value = ''
try {
const [item, runtime, routes, zoneResult] = await Promise.all([
api.getCfTunnel(tunnelID.value),
api.getCfTunnelStatus(tunnelID.value),
api.getCfTunnelRoutes(tunnelID.value),
api.getCfZones().catch(() => ({ zones: [] as CfZone[] })),
])
tunnel.value = item
status.value = runtime
routeData.value = routes
zones.value = asList(zoneResult.zones)
loadForm(item)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '加载隧道配置失败'
} finally {
loading.value = false
}
}
function loadForm(item: CfTunnel) {
const hostname = item.hostnames?.[0] || ''
const origin = item.origin_service || 'http://127.0.0.1:80'
const [type, target = ''] = origin.split('://')
const zone = zones.value
.filter((entry) => hostname === entry.name || hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
form.value = {
name: item.name,
subdomain: zone ? hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || hostname,
type: type || 'http',
target: target || '127.0.0.1:80',
autoStart: item.auto_start,
}
}
async function save() {
if (!tunnel.value || tunnel.value.managed) return
const hostname = manualHostname()
if (!hostname || !form.value.target.trim()) {
message.warning('请填写域名和源服务')
return
}
const payload: CfTunnelPayload = {
name: form.value.name.trim(),
mode: tunnel.value.mode,
token: '',
hostnames: [hostname],
origin_service: `${form.value.type}://${form.value.target.trim().replace(/^[a-z]+:\/\//i, '')}`,
network: tunnel.value.network,
auto_start: form.value.autoStart,
managed: false,
}
saving.value = true
try {
const result = await api.updateCfTunnel(tunnel.value.id, payload)
tunnel.value = result
if (result.dns_warning) message.warning(result.dns_warning)
else message.success('配置已保存')
basicVisible.value = false
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
function openAddRoute() {
routeEditingIndex.value = null
routeForm.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
routeVisible.value = true
}
function openEditRoute(route: CfRouteView, index: number) {
const [type, target = ''] = route.service.split('://')
const zone = zones.value
.filter((entry) => route.hostname === entry.name || route.hostname.endsWith('.' + entry.name))
.sort((a, b) => b.name.length - a.name.length)[0]
routeEditingIndex.value = index
routeForm.value = {
subdomain: zone ? route.hostname.slice(0, -(zone.name.length + 1)) : '',
domain: zone?.name || route.hostname,
type: type || 'http',
target,
}
routeVisible.value = true
}
async function saveRoute() {
if (!tunnel.value || !routeData.value) return
const hostname = routeHostname()
const target = routeForm.value.target.trim().replace(/^[a-z]+:\/\//i, '')
if (!hostname || !target) {
message.warning('请填写目标域名和源服务')
return
}
const next = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
const item = { hostname, path: '*', service: `${routeForm.value.type}://${target}` }
if (routeEditingIndex.value === null) next.push(item)
else next[routeEditingIndex.value] = item
routeSaving.value = true
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
routeVisible.value = false
message.success('路由已保存')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存路由失败')
} finally {
routeSaving.value = false
}
}
async function deleteRoute(index: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const next = routeData.value.routes
.filter((_, routeIndex) => routeIndex !== index)
.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, next)
message.success('路由已删除')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '删除路由失败')
}
}
async function moveRoute(index: number, delta: number) {
if (!tunnel.value || !routeData.value || tunnel.value.managed) return
const target = index + delta
if (target < 0 || target >= routeData.value.routes.length) return
const routes = routeData.value.routes.map((route) => ({
hostname: route.hostname,
path: route.path || '*',
service: route.service,
}))
;[routes[index], routes[target]] = [routes[target], routes[index]]
try {
routeData.value = await api.replaceCfTunnelRoutes(tunnel.value.id, routes)
message.success('路由顺序已更新')
} catch (error) {
message.error(error instanceof Error ? error.message : '调整顺序失败')
}
}
function copyRoute(route: CfRouteView) {
openAddRoute()
const [type, target = ''] = route.service.split('://')
routeForm.value = {
subdomain: '',
domain: zones.value.find((zone) => route.hostname.endsWith('.' + zone.name))?.name || route.hostname,
type: type || 'http',
target,
}
}
async function testRoute(route: CfRouteView) {
try {
const result = await api.testCfRoute(route.service)
if (result.ok) message.success(`${route.hostname}:${result.message},${result.latency_ms} ms`)
else message.error(`${route.hostname}:${result.message}`)
} catch (error) {
message.error(error instanceof Error ? error.message : '测试失败')
}
}
function routeHostname() {
const subdomain = routeForm.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = routeForm.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function basicHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return (subdomain ? `${subdomain}.${domain}` : domain).toLowerCase()
}
function manualHostname() {
const subdomain = form.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = form.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
async function runAction(action: '启动' | '停止' | '重启') {
if (!tunnel.value) return
try {
if (action === '启动') await api.startCfTunnel(tunnel.value.id)
if (action === '停止') await api.stopCfTunnel(tunnel.value.id)
if (action === '重启') await api.restartCfTunnel(tunnel.value.id)
message.success(`${action}指令已下发`)
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : `${action}失败`)
}
}
function statusText(value: string) {
return ({
connected: '已连接',
starting: '启动中',
reconnecting: '重连中',
failed: '失败',
stopped: '已停止',
} as Record<string, string>)[value] || value || '未知'
}
function statusKind(value: string): StatusKind {
if (value === 'connected') return 'success'
if (value === 'starting' || value === 'reconnecting') return 'warning'
if (value === 'failed') return 'error'
return 'unknown'
}
onMounted(load)
</script>
<style scoped>
.stats-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--fonu-space-4);
margin-bottom: var(--fonu-space-4);
}
.section-card + .section-card { margin-top: var(--fonu-space-4); }
.config-basic-action { display: flex; justify-content: flex-end; }
.topology-canvas {
position: relative;
width: 100%;
margin-bottom: var(--fonu-space-4);
overflow-x: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background-color: var(--fonu-bg);
background-image: radial-gradient(var(--fonu-border) 0.8px, transparent 0.8px);
background-size: 10px 10px;
}
.topology-graph {
position: relative;
width: 820px;
height: 100%;
margin: 0 auto;
}
.topology-domain {
position: absolute;
left: 0;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: 220px;
height: 44px;
}
.topology-domain__name {
max-width: 125px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fonu-brand-text);
text-decoration: underline;
}
.topology-node-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.topology-lines {
position: absolute;
inset: 0;
z-index: 1;
width: 820px;
height: 100%;
pointer-events: none;
}
.topology-lines path {
fill: none;
stroke: var(--fonu-border);
stroke-width: 1.5;
}
.topology-add {
position: absolute;
left: 0;
z-index: 2;
width: 150px;
}
.topology-exit,
.topology-tunnel {
position: absolute;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border: 1px solid var(--fonu-border);
border-radius: 999px;
background: var(--fonu-surface);
box-shadow: var(--fonu-shadow);
white-space: nowrap;
}
.topology-exit { left: 292px; min-width: 100px; }
.topology-tunnel { left: 470px; min-width: 108px; }
.topology-service {
position: absolute;
left: 660px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
width: 150px;
height: 44px;
padding: 7px 8px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-surface);
color: var(--fonu-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
.topology-service :deep(.n-icon) { flex: none; color: var(--fonu-brand); }
.config-form { max-width: 780px; }
.config-form__grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.config-origin {
display: grid;
grid-template-columns: 128px auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 100%;
}
.config-origin__type { width: 128px; }
.config-origin__scheme {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
color: var(--fonu-text-muted);
white-space: nowrap;
}
.config-form__footer { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.config-form__spacer { flex: 1; }
.config-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
}
.hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.hostname-preview strong { color: var(--fonu-text); }
.routes-table { overflow-x: auto; }
.routes-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fonu-space-3);
margin-bottom: var(--fonu-space-3);
}
.routes-table__head,
.routes-table__row {
display: grid;
grid-template-columns: 64px minmax(180px, 1fr) 90px minmax(220px, 1.2fr) 130px 360px;
gap: 12px;
align-items: center;
min-width: 1080px;
padding: 10px 0;
}
.routes-table__actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.routes-table__head { color: var(--fonu-text-muted); font-size: 12px; border-bottom: 1px solid var(--fonu-border); }
.routes-table__row { border-bottom: 1px solid var(--fonu-border); }
.routes-table__row:last-child { border-bottom: none; }
.routes-empty { padding: 20px 0; color: var(--fonu-text-muted); }
.routes-catchall { margin-top: 12px; padding: 10px 12px; background: var(--fonu-bg); border-radius: var(--fonu-radius-sm); font-size: 13px; }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.config-form__grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.stats-row { grid-template-columns: 1fr; }
}
</style>
+452 -62
View File
@@ -1,6 +1,7 @@
<template>
<PageHeader title="Cloudflare 隧道" description="通过 cloudflared 把内网 Web 服务发布到 Cloudflare 边缘,无需公网 IP">
<template #actions>
<n-button :loading="preflightLoading" @click="openPreflight">接入向导</n-button>
<n-button :loading="settingsLoading" @click="openSettings">应用配置</n-button>
<n-button type="primary" @click="openCreate">新建隧道</n-button>
</template>
@@ -9,43 +10,72 @@
<div class="cf-global" :class="{ 'cf-global--off': !appSettings.enabled }">
<div class="cf-global__text">
<strong>全局托管</strong>
<span>{{ appSettings.enabled ? '已启用:隧道进程由 Fonu 托管,auto_start 的隧道会在应用启动时恢复' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
<span>{{ appSettings.enabled ? '已启用:开启时和下次启动 Fonu 时都会启动全部隧道' : '已停用:所有隧道已停止,应用重启后也不会自动拉起' }}</span>
</div>
<n-switch :value="appSettings.enabled" :loading="globalLoading" @update:value="toggleGlobal" />
</div>
<n-alert v-if="binaryVersion" type="info" :bordered="false" class="cf-alert">
已安装:<span class="mono">{{ binaryVersion }}</span>
已安装:<span class="mono">{{ displayBinaryVersion(binaryVersion) }}</span>
</n-alert>
<n-alert v-else type="warning" :bordered="false" class="cf-alert">
尚未安装 cloudflared。请先点右上角「cloudflared 二进制」下载,再创建隧道。
</n-alert>
<n-alert type="info" :bordered="false" class="cf-alert">
Cloudflare Tunnel 适合 NAS 面板、相册和小型 Web 服务。免费套餐不适合持续大文件或视频分发,请不要把它当作大流量媒体出口。
</n-alert>
<FonuCard title="隧道列表" subtitle="token 模式默认使用 Fonu 生成的本地 ingress;云端规则模式请在 Cloudflare 面板管理">
<FonuCard title="隧道列表" subtitle="托管隧道从「反向代理」规则的 Cloudflare 出口自动派生;手工隧道仍在 Cloudflare 面板管理">
<div v-if="!tunnels.length" class="cf-empty">还没有隧道,点「新建隧道」开始。</div>
<div v-for="item in tunnels" :key="item.id" class="cf-row">
<div class="cf-row__main">
<strong>{{ item.name }}</strong>
<StatusBadge :kind="statusKind(item.status)" :text="statusText(item.status)" />
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
<div v-else class="cf-table">
<div class="cf-table__head">
<span>隧道</span>
<span>状态</span>
<span>副本</span>
<span>版本</span>
<span>路由</span>
<span>调和</span>
<span>操作</span>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openEdit(item)">编辑</n-button>
<n-button v-if="item.status !== 'connected' && item.status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
<div v-for="item in tunnels" :key="item.id" class="cf-table__row">
<div class="cf-table__tunnel">
<strong>{{ item.name }}</strong>
<div class="cf-table__meta">
<n-tag v-if="item.managed" size="small" type="info" :bordered="false" round>自动托管</n-tag>
<span class="cf-meta mono">{{ modeText(item.mode) }}</span>
<span class="cf-meta mono">{{ item.tunnel_id || '—' }}</span>
<span v-if="item.metrics_port" class="cf-meta mono">metrics :{{ item.metrics_port }}</span>
</div>
</div>
<div>
<StatusBadge
:kind="statusKind(runtimeStatus(item).status || item.status)"
:text="statusText(runtimeStatus(item).status || item.status)"
/>
</div>
<div class="cf-table__number">{{ replicaCount(item) }}</div>
<div class="cf-meta mono">{{ displayBinaryVersion(binaryVersion) || '—' }}</div>
<div><n-tag size="small" :bordered="false" round>{{ routeCount(item) }} 条</n-tag></div>
<div>
<n-tag size="small" :type="driftTagType(item)" :bordered="false" round>{{ driftText(item) }}</n-tag>
</div>
<div class="cf-row__actions">
<n-button size="small" @click="openDetail(item)">详情</n-button>
<n-button size="small" @click="openConfig(item)">配置</n-button>
<n-button v-if="runtimeStatus(item).status !== 'connected' && runtimeStatus(item).status !== 'starting'" size="small" type="success" :disabled="!appSettings.enabled" @click="startTunnel(item)">启动</n-button>
<n-button v-else size="small" type="warning" @click="stopTunnel(item)">停止</n-button>
<n-button size="small" @click="restartTunnel(item)">重启</n-button>
<n-button v-if="driftByTunnel[item.id]?.has_drift" size="small" type="warning" @click="repairTunnel(item)">修复</n-button>
<n-popconfirm @positive-click="removeTunnel(item)">
<template #trigger><n-button size="small" type="error" quaternary>删除</n-button></template>
将停止该隧道并删除本地配置、凭据与日志。继续?
</n-popconfirm>
</div>
</div>
</div>
</FonuCard>
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" :title="editing ? '编辑隧道' : '新建隧道'">
<n-modal v-model:show="editorVisible" preset="card" :style="{ width: 'min(720px, 96vw)' }" title="新建隧道">
<div class="cf-modal__body">
<n-alert type="info" :bordered="false" class="cf-mode-alert">
隧道由 Cloudflare API 创建;Fonu 负责本地配置、进程、日志与健康。
@@ -56,16 +86,47 @@
<n-divider title-placement="left">基本信息</n-divider>
<n-form label-placement="top">
<n-form-item label="名称"><n-input v-model:value="form.name" placeholder="例如 home-nas" /></n-form-item>
</n-form>
<n-divider title-placement="left">暴露规则</n-divider>
<n-form label-placement="top">
<n-form-item label="暴露域名">
<n-select v-model:value="form.hostnames" multiple filterable tag :options="hostnameOptions" placeholder="从本机反代规则选择,或直接输入域名" />
</n-form-item>
<n-form-item label="回源 service">
<n-input v-model:value="form.origin_service" placeholder="http://127.0.0.1:80" />
<n-form-item label="自动托管">
<div class="cf-managed-field">
<n-switch v-model:value="form.managed" />
<span>启用后,暴露域名与回源由「反向代理」规则中的 Cloudflare 出口自动生成。</span>
</div>
</n-form-item>
</n-form>
<template v-if="!form.managed">
<n-divider title-placement="left">暴露规则</n-divider>
<n-form label-placement="top">
<div class="cf-rule-row">
<n-form-item label="子域名(可选)" class="cf-rule-row__subdomain">
<n-input v-model:value="manualRule.subdomain" placeholder="例如 999" />
</n-form-item>
<n-form-item label="域" class="cf-rule-row__domain">
<n-select
v-model:value="manualRule.domain"
:options="domainOptions"
filterable
tag
placeholder="从 Cloudflare Zone 选择"
/>
</n-form-item>
</div>
<div class="cf-hostname-preview">
<span>完整主机名:</span>
<strong class="mono">{{ manualHostname() || '未选择域' }}</strong>
</div>
<n-form-item label="服务">
<div class="cf-origin-row">
<n-select v-model:value="manualRule.type" :options="originTypeOptions" class="cf-origin-row__type" />
<span class="cf-origin-row__scheme">://</span>
<n-input v-model:value="manualRule.target" placeholder="192.168.11.30:8001" />
</div>
<p class="cf-hint">路由流量的源服务,例如:192.168.11.30:8001、localhost:8080、192.168.1.2:3306</p>
</n-form-item>
</n-form>
</template>
<n-alert v-else type="info" :bordered="false" class="cf-mode-alert">
保存托管隧道后,请到「反向代理」编辑服务规则,在出口中选择 Cloudflare 并指定该隧道。
</n-alert>
<n-divider title-placement="left">运行</n-divider>
<n-form label-placement="top">
<n-form-item label="随 Fonu 自动启动"><n-switch v-model:value="form.auto_start" /></n-form-item>
@@ -85,7 +146,7 @@
<div class="cf-detail__stats">
<div class="cf-stat"><label>状态</label><StatusBadge :kind="statusKind(detailStatus?.status || detailTunnel.status)" :text="statusText(detailStatus?.status || detailTunnel.status)" /></div>
<div class="cf-stat"><label>连接数</label><strong>{{ detailStatus?.connections ?? 0 }}</strong></div>
<div class="cf-stat"><label>延迟</label><strong>{{ (detailStatus?.latency_ms ?? 0).toFixed(0) }} ms</strong></div>
<div class="cf-stat"><label>延迟</label><strong>{{ detailStatus?.transport === 'http2' ? '—' : `${(detailStatus?.latency_ms ?? 0).toFixed(0)} ms` }}</strong></div>
<div class="cf-stat"><label>传输协议</label><strong>{{ detailStatus?.transport || '—' }}</strong></div>
<div class="cf-stat"><label>metrics 端口</label><strong>{{ detailStatus?.metrics_port || '—' }}</strong></div>
</div>
@@ -99,6 +160,31 @@
<div class="cf-code-toolbar"><n-button size="tiny" :loading="detailLoading" @click="refreshDetail">刷新</n-button></div>
<pre class="cf-pre">{{ detailLogs || '暂无日志' }}</pre>
</n-tab-pane>
<n-tab-pane name="diagnostics" tab="漂移与诊断">
<div class="cf-code-toolbar">
<n-button size="tiny" :loading="driftLoading" @click="loadDrift">检测漂移</n-button>
<n-button size="tiny" :disabled="!detailDrift?.has_drift" :loading="driftSyncing" @click="syncDrift">以本地覆盖</n-button>
<n-button size="tiny" :loading="diagnosticsLoading" @click="loadDiagnostics">运行诊断</n-button>
<n-button size="tiny" :loading="accessLoading" @click="loadAccessStatus">检查 Access</n-button>
<n-button size="tiny" :loading="credentialLoading" @click="refreshCredentials">刷新凭据</n-button>
</div>
<n-alert v-if="detailDrift" :type="detailDrift.has_drift ? 'warning' : 'success'" :bordered="false" class="cf-mode-alert">
<template v-if="detailDrift.has_drift">
发现 {{ detailDrift.items.length }} 项云端漂移:
<div v-for="item in detailDrift.items" :key="item" class="mono">{{ item }}</div>
</template>
<template v-else>本地规则与 Cloudflare 云端配置一致。</template>
</n-alert>
<div v-if="detailDiagnostics" class="cf-diagnostics">
<div v-for="check in detailDiagnostics.checks" :key="check.name" class="cf-diagnostic-row">
<n-tag size="small" :type="diagnosticTag(check.status)" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
</div>
</div>
<n-alert v-if="detailAccess" type="info" :bordered="false" class="cf-mode-alert">
{{ detailAccess.enabled ? `已启用 Cloudflare Access${detailAccess.name ? ':' + detailAccess.name : ''}` : '未检测到 Cloudflare Access 保护。Fonu 只做只读检测,不代配策略。' }}
</n-alert>
</n-tab-pane>
</n-tabs>
</div>
<template #footer>
@@ -113,7 +199,8 @@
<div class="cf-modal__body">
<n-divider title-placement="left">程序安装</n-divider>
<div class="cf-binary">
<div><label>当前版本</label><strong class="mono">{{ binaryVersion || '未安装' }}</strong></div>
<div><label>当前版本</label><strong class="mono">{{ displayBinaryVersion(binaryVersion) || '未安装' }}</strong></div>
<div><label>检测到的最新版本</label><strong class="mono">{{ latestBinaryVersion || '未检查' }}</strong></div>
<div><label>目标文件</label><strong class="mono">cloudflared</strong></div>
</div>
<n-form label-placement="top">
@@ -122,7 +209,9 @@
</n-form-item>
</n-form>
<div class="cf-code-toolbar">
<n-button size="small" :loading="binaryChecking" @click="checkLatestBinary">检查更新</n-button>
<n-button size="small" :loading="binaryLoading" @click="downloadBinary">下载并安装</n-button>
<n-button size="small" :loading="binaryRollbackLoading" @click="rollbackBinary">回滚上一版</n-button>
</div>
<p class="cf-hint">下载后会校验文件大小与可执行性,并原子替换现有二进制。国内环境推荐 gh-proxy 镜像。</p>
@@ -147,7 +236,7 @@
<n-alert v-if="tokenTestResult" :type="tokenTestOk ? 'success' : 'error'" :bordered="false" class="cf-mode-alert">
{{ tokenTestResult }}
</n-alert>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限。</p>
<p class="cf-hint">Fonu 使用 Account ID + API Token 调用 Cloudflare API 创建和管理隧道与 DNS。API Token 需要 Account: Cloudflare Tunnel: Edit、Zone: Zone: Read、Zone: DNS: Edit 权限,且 Zone Resources 要包含所有要发布的根域名。</p>
<n-divider title-placement="left">默认网络设置</n-divider>
<n-form label-placement="top">
@@ -164,13 +253,33 @@
</div>
</template>
</n-modal>
<n-modal v-model:show="preflightVisible" preset="card" :style="{ width: 'min(620px, 96vw)' }" title="Cloudflare 接入向导">
<div class="cf-preflight">
<div v-for="check in preflight?.checks || []" :key="check.name" class="cf-preflight__row">
<n-tag size="small" :type="check.status === 'ok' ? 'success' : 'error'" :bordered="false">{{ check.name }}</n-tag>
<span>{{ check.message }}</span>
<n-button v-if="check.action === 'settings' && check.status !== 'ok'" size="tiny" @click="openSettings">去配置</n-button>
</div>
<n-alert v-if="preflight" :type="preflight.ready ? 'success' : 'warning'" :bordered="false" class="cf-mode-alert">
{{ preflight.ready ? '预检通过,可以创建隧道或发布服务。' : '还有项目未完成,请按上面的提示处理。' }}
</n-alert>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button :loading="preflightLoading" @click="loadPreflight">重新检查</n-button>
<n-button type="primary" @click="preflightVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NAlert, NButton, NDivider, NForm, NFormItem, NInput, NInputNumber, NModal, NPopconfirm, NSelect, NSwitch, NTabPane, NTabs, useMessage } from 'naive-ui'
import { api, asList } from '../api/client'
import type { CfAppSettings, CfAppSettingsInput, CfMirror, CfRuntimeStatus, CfTunnel, CfTunnelPayload } from '../api/types'
import type { CfAccessStatus, CfAppSettings, CfAppSettingsInput, CfDiagnosticsResult, CfDriftResult, CfMirror, CfPreflightResult, CfRuntimeStatus, CfTunnel, CfTunnelPayload, CfZone } from '../api/types'
import FonuCard from '../components/FonuCard.vue'
import PageHeader from '../components/PageHeader.vue'
import StatusBadge from '../components/StatusBadge.vue'
@@ -179,13 +288,19 @@ import { copyText } from '../utils/clipboard'
import type { StatusKind } from '../utils/status'
const message = useMessage()
const router = useRouter()
const tunnels = ref<CfTunnel[]>([])
const runtimeStatuses = ref<Record<number, CfRuntimeStatus>>({})
const driftByTunnel = ref<Record<number, CfDriftResult>>({})
const binaryVersion = ref('')
const latestBinaryVersion = ref('')
const mirrors = ref<CfMirror[]>([])
const candidates = ref<string[]>([])
const zones = ref<CfZone[]>([])
const loading = ref(false)
const saving = ref(false)
const binaryLoading = ref(false)
const binaryChecking = ref(false)
const binaryRollbackLoading = ref(false)
const globalLoading = ref(false)
const settingsVisible = ref(false)
const settingsLoading = ref(false)
@@ -206,14 +321,25 @@ const appSettings = ref<CfAppSettings>({
const settingsForm = ref<CfAppSettingsInput>({ account_id: '', token: '', api_token: '', mirror: 'official', network: emptyNetwork() })
const editorVisible = ref(false)
const detailVisible = ref(false)
const editing = ref<CfTunnel | null>(null)
const detailTunnel = ref<CfTunnel | null>(null)
const detailStatus = ref<CfRuntimeStatus | null>(null)
const detailConfig = ref('')
const detailLogs = ref('')
const detailLoading = ref(false)
const detailDrift = ref<CfDriftResult | null>(null)
const detailDiagnostics = ref<CfDiagnosticsResult | null>(null)
const driftLoading = ref(false)
const driftSyncing = ref(false)
const diagnosticsLoading = ref(false)
const detailAccess = ref<CfAccessStatus | null>(null)
const accessLoading = ref(false)
const credentialLoading = ref(false)
const preflightVisible = ref(false)
const preflightLoading = ref(false)
const preflight = ref<CfPreflightResult | null>(null)
const form = ref<CfTunnelPayload>(emptyForm())
const manualRule = ref({ subdomain: '', domain: '', type: 'http', target: '127.0.0.1:80' })
const protocolOptions = [
{ label: 'HTTP/2(国内推荐)', value: 'http2' },
@@ -229,7 +355,13 @@ const proxyOptions = [
{ label: '跟随系统环境变量', value: 'system' },
{ label: '禁用代理', value: 'disabled' },
]
const hostnameOptions = computed(() => candidates.value.map((host) => ({ label: host, value: host })))
const originTypeOptions = [
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCP', value: 'tcp' },
{ label: 'SSH', value: 'ssh' },
]
const domainOptions = computed(() => zones.value.map((zone) => ({ label: zone.name, value: zone.name })))
const mirrorOptions = computed(() => {
const list = mirrors.value.length ? mirrors.value : [{ id: 'official', name: 'GitHub 官方源', base: '' }]
return list.map((item) => ({ label: item.name, value: item.id }))
@@ -243,6 +375,7 @@ function emptyForm(): CfTunnelPayload {
origin_service: 'http://127.0.0.1:80',
network: emptyNetwork(),
auto_start: false,
managed: true,
}
}
@@ -254,6 +387,26 @@ async function load() {
loading.value = true
try {
tunnels.value = asList(await api.listCfTunnels())
const pairs = await Promise.all(
tunnels.value.map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelStatus(tunnel.id)] as const
} catch {
return [tunnel.id, { running: false, status: tunnel.status, connections: 0, latency_ms: 0, bandwidth_in: 0, bandwidth_out: 0, transport: '' }] as const
}
}),
)
runtimeStatuses.value = Object.fromEntries(pairs)
const driftPairs = await Promise.all(
tunnels.value.filter((tunnel) => tunnel.managed).map(async (tunnel) => {
try {
return [tunnel.id, await api.getCfTunnelDrift(tunnel.id)] as const
} catch {
return [tunnel.id, { tunnel_id: tunnel.id, managed: true, has_drift: false, items: [] }] as const
}
}),
)
driftByTunnel.value = Object.fromEntries(driftPairs)
} catch (error) {
message.error(error instanceof Error ? error.message : '读取隧道列表失败')
} finally {
@@ -261,6 +414,48 @@ async function load() {
}
}
function runtimeStatus(item: CfTunnel): CfRuntimeStatus {
return runtimeStatuses.value[item.id] ?? {
running: false,
status: item.status,
connections: 0,
latency_ms: 0,
bandwidth_in: 0,
bandwidth_out: 0,
transport: '',
}
}
function replicaCount(item: CfTunnel): number {
return runtimeStatus(item).connections || 0
}
function routeCount(item: CfTunnel): number {
return item.hostnames?.length || 0
}
function driftText(item: CfTunnel): string {
if (!item.managed) return '手工'
const drift = driftByTunnel.value[item.id]
if (!drift) return '未知'
return drift.has_drift ? '有漂移' : '已同步'
}
function driftTagType(item: CfTunnel): 'success' | 'warning' | 'default' {
if (!item.managed) return 'default'
return driftByTunnel.value[item.id]?.has_drift ? 'warning' : 'success'
}
async function repairTunnel(item: CfTunnel) {
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则修复云端配置')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '修复失败')
}
}
async function toggleGlobal(value: boolean) {
globalLoading.value = true
try {
@@ -275,6 +470,22 @@ async function toggleGlobal(value: boolean) {
}
}
async function openPreflight() {
preflightVisible.value = true
await loadPreflight()
}
async function loadPreflight() {
preflightLoading.value = true
try {
preflight.value = await api.getCfPreflight()
} catch (error) {
message.error(error instanceof Error ? error.message : '预检失败')
} finally {
preflightLoading.value = false
}
}
async function loadBinary() {
try {
const result = await api.getCfBinary()
@@ -285,32 +496,41 @@ async function loadBinary() {
}
}
async function loadCandidates() {
async function loadZones() {
try {
candidates.value = (await api.getCfIngressCandidates()).hostnames || []
const result = await api.getCfZones()
zones.value = asList(result.zones)
} catch {
candidates.value = []
zones.value = []
}
}
function openCreate() {
editing.value = null
function resetManualRule() {
manualRule.value = { subdomain: '', domain: zones.value[0]?.name || '', type: 'http', target: '127.0.0.1:80' }
}
function manualHostname() {
const subdomain = manualRule.value.subdomain.trim().replace(/^\.+|\.+$/g, '')
const domain = manualRule.value.domain.trim().replace(/^\.+|\.+$/g, '')
if (!domain) return ''
return subdomain ? `${subdomain}.${domain}` : domain
}
function manualOriginService() {
const type = manualRule.value.type || 'http'
const target = manualRule.value.target.trim().replace(/^[a-z]+:\/\//i, '')
return `${type}://${target}`
}
async function openCreate() {
if (!zones.value.length) await loadZones()
form.value = emptyForm()
resetManualRule()
editorVisible.value = true
}
function openEdit(item: CfTunnel) {
editing.value = item
form.value = {
name: item.name,
mode: item.mode,
token: '',
hostnames: [...(item.hostnames || [])],
origin_service: item.origin_service || 'http://127.0.0.1:80',
network: emptyNetwork(),
auto_start: item.auto_start,
}
editorVisible.value = true
function openConfig(item: CfTunnel) {
void router.push(`/cloudflare/${item.id}/config`)
}
async function saveTunnel() {
@@ -322,21 +542,28 @@ async function saveTunnel() {
message.warning('请先在「应用配置」中填写 Account ID 与 API Token')
return
}
if (!form.value.hostnames.length) {
message.warning('请至少选择一个暴露域名')
return
if (!form.value.managed) {
const hostname = manualHostname()
if (!hostname) {
message.warning('请选择或填写域')
return
}
if (!manualRule.value.target.trim()) {
message.warning('请填写源服务 URL')
return
}
form.value.hostnames = [hostname]
form.value.origin_service = manualOriginService()
}
const payload: CfTunnelPayload = {
...form.value,
mode: editing.value ? form.value.mode : 'account-local',
mode: 'account-local',
token: '',
network: { ...appSettings.value.network },
}
saving.value = true
try {
const result = editing.value
? await api.updateCfTunnel(editing.value.id, payload)
: await api.createCfTunnel(payload)
const result = await api.createCfTunnel(payload)
editorVisible.value = false
if (result.dns_warning) {
message.warning(result.dns_warning)
@@ -385,6 +612,9 @@ async function runAction(label: string, fn: () => Promise<unknown>) {
async function openDetail(item: CfTunnel) {
detailTunnel.value = item
detailDrift.value = null
detailDiagnostics.value = null
detailAccess.value = null
detailVisible.value = true
await refreshDetail()
}
@@ -409,6 +639,85 @@ async function refreshDetail() {
}
}
async function loadDrift() {
const item = detailTunnel.value
if (!item) return
driftLoading.value = true
try {
detailDrift.value = await api.getCfTunnelDrift(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '漂移检测失败')
} finally {
driftLoading.value = false
}
}
async function syncDrift() {
const item = detailTunnel.value
if (!item) return
driftSyncing.value = true
try {
await api.syncCfTunnelRoutes(item.id)
message.success('已按本地规则覆盖云端配置')
await Promise.all([loadDrift(), refreshDetail(), load()])
} catch (error) {
message.error(error instanceof Error ? error.message : '同步失败')
} finally {
driftSyncing.value = false
}
}
async function loadDiagnostics() {
const item = detailTunnel.value
if (!item) return
diagnosticsLoading.value = true
try {
detailDiagnostics.value = await api.getCfTunnelDiagnostics(item.id)
} catch (error) {
message.error(error instanceof Error ? error.message : '诊断失败')
} finally {
diagnosticsLoading.value = false
}
}
async function loadAccessStatus() {
const item = detailTunnel.value
const hostname = item?.hostnames?.[0]
if (!hostname) {
message.warning('该隧道暂无暴露域名')
return
}
accessLoading.value = true
try {
detailAccess.value = await api.getCfAccessStatus(hostname)
} catch (error) {
message.error(error instanceof Error ? error.message : 'Access 检测失败,请确认 Token 具备 Access: Apps: Read 权限')
} finally {
accessLoading.value = false
}
}
async function refreshCredentials() {
const item = detailTunnel.value
if (!item) return
credentialLoading.value = true
try {
detailTunnel.value = await api.refreshCfCredentials(item.id)
message.success('隧道凭据已刷新')
await load()
} catch (error) {
message.error(error instanceof Error ? error.message : '刷新凭据失败')
} finally {
credentialLoading.value = false
}
}
function diagnosticTag(status: string) {
if (status === 'ok') return 'success'
if (status === 'warning') return 'warning'
return 'error'
}
async function copyConfig() {
if (!detailConfig.value) return
try {
@@ -420,6 +729,7 @@ async function copyConfig() {
}
async function openSettings() {
preflightVisible.value = false
settingsVisible.value = true
await loadAppSettings()
}
@@ -463,6 +773,7 @@ async function saveSettings() {
const result = await api.saveCfSettings(settingsForm.value)
appSettings.value = result
settingsForm.value = { account_id: result.account_id || '', token: '', api_token: '', mirror: result.mirror, network: { ...result.network } }
await loadZones()
message.success('应用配置已保存')
} catch (error) {
message.error(error instanceof Error ? error.message : '保存应用配置失败')
@@ -474,7 +785,7 @@ async function saveSettings() {
async function downloadBinary() {
binaryLoading.value = true
try {
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official')
const result = await api.downloadCfBinary(settingsForm.value.mirror || 'official', latestBinaryVersion.value)
binaryVersion.value = result.version || ''
message.success('cloudflared 已下载并安装')
} catch (error) {
@@ -484,6 +795,41 @@ async function downloadBinary() {
}
}
async function checkLatestBinary() {
binaryChecking.value = true
try {
const result = await api.getLatestCfBinary()
latestBinaryVersion.value = result.version || ''
if (binaryVersion.value.includes(latestBinaryVersion.value)) {
message.success('当前已是最新版本')
} else {
message.info(`发现新版本 ${latestBinaryVersion.value}`)
}
} catch (error) {
message.error(error instanceof Error ? error.message : '检查更新失败')
} finally {
binaryChecking.value = false
}
}
async function rollbackBinary() {
binaryRollbackLoading.value = true
try {
const result = await api.rollbackCfBinary()
binaryVersion.value = result.version || ''
latestBinaryVersion.value = ''
message.success('已回滚 cloudflared')
} catch (error) {
message.error(error instanceof Error ? error.message : '回滚失败')
} finally {
binaryRollbackLoading.value = false
}
}
function displayBinaryVersion(version: string) {
return version.match(/\d{4}\.\d+\.\d+/)?.[0] || version
}
function modeText(mode: string) {
if (mode === 'account-local') return 'API 管理'
if (mode === 'token-remote') return '云端规则'
@@ -512,7 +858,7 @@ onMounted(() => {
void load()
void loadBinary()
void loadAppSettings()
void loadCandidates()
void loadZones()
})
useVisibilityPolling(() => load(), 5000)
@@ -536,6 +882,26 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-global__text span { color: var(--fonu-text-secondary); }
.cf-alert { margin-bottom: var(--fonu-space-4); }
.cf-empty { padding: 24px 0; color: var(--fonu-text-muted); }
.cf-table { width: 100%; overflow-x: auto; }
.cf-table__head,
.cf-table__row {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) 110px 70px minmax(120px, 0.8fr) 80px 90px minmax(340px, 1.8fr);
gap: 12px;
align-items: center;
min-width: 980px;
padding: 10px 0;
}
.cf-table__head {
color: var(--fonu-text-muted);
font-size: 12px;
border-bottom: 1px solid var(--fonu-border);
}
.cf-table__row { border-bottom: 1px solid var(--fonu-border); }
.cf-table__row:last-child { border-bottom: none; }
.cf-table__tunnel { min-width: 0; }
.cf-table__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.cf-table__number { font-size: 14px; font-weight: 600; }
.cf-row {
display: flex;
align-items: center;
@@ -551,6 +917,30 @@ useVisibilityPolling(refreshDetail, 3000, { enabled: detailVisible })
.cf-modal__body { max-height: 68vh; overflow: auto; padding-right: 4px; }
.cf-modal__footer { display: flex; justify-content: flex-end; gap: 8px; }
.cf-mode-alert { margin-bottom: 4px; }
.cf-preflight { display: grid; gap: 10px; }
.cf-preflight__row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); }
.cf-preflight__row span { flex: 1; min-width: 0; color: var(--fonu-text-secondary); font-size: 12.5px; }
.cf-managed-field { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--fonu-text-muted); }
.cf-rule-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; }
.cf-rule-row__subdomain,
.cf-rule-row__domain { min-width: 0; }
.cf-hostname-preview {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--fonu-space-4);
padding: 10px 12px;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
}
.cf-hostname-preview span { color: var(--fonu-text-muted); font-size: 12px; }
.cf-hostname-preview strong { color: var(--fonu-text); }
.cf-origin-row { display: flex; align-items: center; width: 100%; gap: 8px; }
.cf-origin-row__type { width: 126px; flex: none; }
.cf-origin-row__scheme { color: var(--fonu-text-muted); flex: none; }
.cf-diagnostics { display: grid; gap: 8px; margin-top: 10px; }
.cf-diagnostic-row { display: flex; align-items: flex-start; gap: 10px; font-size: 12.5px; line-height: 1.5; }
.cf-detail__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 12px; }
.cf-stat { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm); background: var(--fonu-bg); }
.cf-stat label { font-size: 12px; color: var(--fonu-text-muted); }
+9
View File
@@ -746,6 +746,15 @@ const healthItems = computed(() => [
icon: GlobeOutline,
tone: 'green',
},
{
name: 'Cloudflare 隧道',
desc: '无公网 IP 的 Cloudflare 出口',
meta: status.value?.cloudflare_message || '未创建隧道',
status: status.value?.cloudflare_status,
label: status.value?.cloudflare_status === 'ok' ? '运行中' : status.value?.cloudflare_status === 'warning' ? '部分在线' : status.value?.cloudflare_status === 'none' ? '未配置' : '需关注',
icon: CloudOutline,
tone: 'blue',
},
])
const accessColumns = computed<DataTableColumns<AccessLogEntry>>(() => [
+101 -3
View File
@@ -66,6 +66,26 @@
/>
</n-tab-pane>
<n-tab-pane name="cloudflare" tab="Cloudflare 隧道">
<LogToolbar v-model:keyword="cfKeyword" @refresh="loadCfLogs" />
<div class="remote-nginx-bar">
<n-select
v-model:value="cfTunnelId"
:options="cfTunnelOptions"
placeholder="选择 Cloudflare 隧道"
size="small"
class="remote-nginx-bar__server"
/>
<n-select v-model:value="cfLines" :options="cfLineOptions" size="small" style="width: 110px" />
</div>
<pre v-if="filteredCfLogLines.length" class="cf-log-pre">{{ filteredCfLogLines.join('\n') }}</pre>
<EmptyState
v-else-if="!cfLoading"
title="暂无 Cloudflare 隧道日志"
:description="cfError || '选择隧道并启动 cloudflared 后,连接与错误日志会显示在这里。'"
/>
</n-tab-pane>
<n-tab-pane name="remote-nginx" tab="公网 Nginx">
<LogToolbar v-model:keyword="nginxKeyword" @refresh="loadRemoteNginxLogs" />
<div class="remote-nginx-bar">
@@ -178,7 +198,7 @@ import {
} from 'naive-ui'
import { api, asList } from '../api/client'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import type { AccessLogEntry, ProxyRule, SystemLogEntry } from '../api/types'
import type { AccessLogEntry, CfTunnel, ProxyRule, SystemLogEntry } from '../api/types'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
import LoadError from '../components/LoadError.vue'
@@ -282,7 +302,7 @@ const LogToolbar = defineComponent({
const message = useMessage()
const route = useRoute()
const LOG_TABS = ['system', 'frp', 'remote-nginx', 'access', 'nginx'] as const
const LOG_TABS = ['system', 'frp', 'cloudflare', 'remote-nginx', 'access', 'nginx'] as const
type LogTab = (typeof LOG_TABS)[number]
function resolveTab(queryTab: unknown): LogTab {
@@ -408,6 +428,13 @@ const frpKeyword = ref('')
const frpLevel = ref('')
const frpPage = ref(1)
const loadingFrp = ref(false)
const cfTunnels = ref<CfTunnel[]>([])
const cfTunnelId = ref<number | null>(null)
const cfLines = ref(200)
const cfKeyword = ref('')
const cfRawLog = ref('')
const cfLoading = ref(false)
const cfError = ref('')
const frpPageSize = 50
const frpLevelText: Record<string, string> = { I: '信息', W: '警告', E: '错误', D: '调试' }
// 工具条的级别选项值是 INFO / WARN / ERROR,而 frpc 的级别是单字母 I/W/E/D,需转换后再比较
@@ -512,6 +539,60 @@ function frpRowClassName(row: FrpLogEntry) {
return 'log-row'
}
const cfTunnelOptions = computed(() =>
cfTunnels.value.map((tunnel) => ({
label: `${tunnel.name}${tunnel.status === 'connected' ? '(已连接)' : ''}`,
value: tunnel.id,
})),
)
const cfLineOptions = [
{ label: '200 行', value: 200 },
{ label: '500 行', value: 500 },
{ label: '2000 行', value: 2000 },
]
const filteredCfLogLines = computed(() => {
const keyword = cfKeyword.value.trim().toLowerCase()
const lines = cfRawLog.value ? cfRawLog.value.split('\n') : []
if (!keyword) return lines
return lines.filter((line) => line.toLowerCase().includes(keyword))
})
async function loadCfTunnels() {
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
if (cfTunnelId.value === null || !cfTunnels.value.some((tunnel) => tunnel.id === cfTunnelId.value)) {
cfTunnelId.value = cfTunnels.value[0]?.id ?? null
}
}
async function loadCfLogs() {
if (cfTunnelId.value === null) {
cfRawLog.value = ''
cfError.value = '还没有 Cloudflare 隧道'
return
}
cfLoading.value = true
cfError.value = ''
try {
const result = await api.getCfTunnelLogs(cfTunnelId.value, cfLines.value)
cfRawLog.value = result.output || ''
} catch (error) {
cfRawLog.value = ''
cfError.value = error instanceof Error ? error.message : '读取 Cloudflare 日志失败'
} finally {
cfLoading.value = false
}
}
watch([cfTunnelId, cfLines], () => {
if (cfTunnelId.value !== null) void loadCfLogs()
})
// 公网 Nginx 日志(VPS 侧,需 agent ≥ 0.10.0):路径由 agent 从 nginx -T 解析,前端只展示原始行
const nginxTargets = ref<import('../api/types').FrpServer[]>([])
const nginxTargetId = ref<number | null>(null)
@@ -780,7 +861,7 @@ async function loadSystemLogs() {
async function loadAll() {
pageError.value = ''
try {
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs()])
await Promise.all([loadAccess(), loadErrorLogs(), loadSystemLogs(), loadFrpLogs(), loadCfTunnels()])
} catch (error) {
pageError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
}
@@ -815,6 +896,9 @@ watch(tab, (name) => {
if (name === 'access') loadAccess()
else if (name === 'nginx') loadErrorLogs()
else if (name === 'system') loadSystemLogs()
else if (name === 'cloudflare') {
void loadCfTunnels().then(() => loadCfLogs())
}
})
onMounted(() => {
@@ -929,6 +1013,20 @@ onMounted(() => {
margin-bottom: var(--fonu-space-2);
}
.cf-log-pre {
max-height: 620px;
margin: 0 var(--fonu-space-5);
padding: var(--fonu-space-4);
overflow: auto;
border: 1px solid var(--fonu-border);
border-radius: var(--fonu-radius-sm);
background: var(--fonu-bg);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.remote-nginx-bar__server {
width: 200px;
}
+201 -17
View File
@@ -104,7 +104,7 @@
:data="tableRules"
:bordered="false"
size="small"
:scroll-x="canReorder ? 1460 : 1420"
:scroll-x="canReorder ? 1610 : 1570"
:row-key="(r: ProxyRule) => r.id"
:row-props="rowProps"
/>
@@ -130,6 +130,28 @@
</div>
</template>
<n-modal v-model:show="discoveryVisible" preset="card" :style="{ width: 'min(680px, 96vw)' }" title="发现本机服务">
<div class="discovery-list">
<div v-for="item in discoveryResults" :key="item.platform + item.port" class="discovery-item">
<div class="discovery-item__main">
<strong>{{ item.name }}</strong>
<span class="mono">{{ item.upstream }}</span>
<n-tag size="small" :type="item.detected ? 'success' : 'default'" :bordered="false">
{{ item.detected ? '检测到服务' : '端口未开放' }}
</n-tag>
</div>
<n-button size="small" type="primary" :disabled="!item.detected" @click="publishDiscovered(item)">
发布
</n-button>
</div>
</div>
<template #footer>
<div class="cf-modal__footer">
<n-button @click="discoveryVisible = false">关闭</n-button>
</div>
</template>
</n-modal>
<n-modal v-model:show="showDetailPanel" :mask-closable="true" transform-origin="center">
<div v-if="selectedRule" class="proxy-detail-modal">
<div class="proxy-detail-modal__header">
@@ -206,6 +228,10 @@
</n-tag>
</span>
</div>
<div class="overview-strip__item">
<span class="overview-strip__label">出口</span>
<span class="overview-strip__value">{{ exitLabel(selectedRule) }}</span>
</div>
</div>
<div class="overview-security">
@@ -453,6 +479,33 @@
</div>
</n-form-item>
<n-form-item label="出口">
<div class="field-stack">
<div class="exit-options">
<n-checkbox v-model:checked="form.exit_local">本机 Nginx</n-checkbox>
<n-checkbox v-model:checked="form.exit_cloudflare">Cloudflare 隧道</n-checkbox>
</div>
<p class="field-hint">可多选;关闭本机出口后,该规则只通过 Cloudflare 隧道访问。</p>
</div>
</n-form-item>
<n-form-item v-if="form.exit_cloudflare" label="Cloudflare 隧道">
<div class="field-stack">
<n-select
v-model:value="form.cf_tunnel_id"
:options="cfTunnelOptions"
placeholder="选择自动托管隧道"
/>
<p v-if="cfTunnelOptions.length === 0" class="field-hint field-hint--warning">
还没有自动托管隧道,请先到
<router-link to="/cloudflare">Cloudflare 隧道</router-link>
新建并开启「自动托管」。
</p>
<p class="field-hint">域名的根 Zone 必须已加入当前 Cloudflare 账号,且 API Token 的 Zone Resources 包含该域名。</p>
<p v-if="!form.exit_local" class="field-hint">仅走 Cloudflare 出口时,HTTPS 由 Cloudflare 边缘提供,不需要在 Fonu 申请本机证书。</p>
</div>
</n-form-item>
<div class="form-switch-list form-switch-list--compact">
<div class="form-switch-row">
<div class="form-switch-row__text">
@@ -859,7 +912,7 @@ import {
WarningOutline,
} from '@vicons/ionicons5'
import { api, asList } from '../api/client'
import type { ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import type { CfTunnel, DiscoveredService, ProxyClientConn, ProxyRule, ProxySavePayload, ProxyTraffic } from '../api/types'
import { useVisibilityPolling } from '../composables/useVisibilityPolling'
import EmptyState from '../components/EmptyState.vue'
import FonuCard from '../components/FonuCard.vue'
@@ -876,6 +929,9 @@ import { renderTableRowActions } from '../utils/tableActions'
const message = useMessage()
const dialog = useDialog()
const rules = ref<ProxyRule[]>([])
const cfTunnels = ref<CfTunnel[]>([])
const discoveryVisible = ref(false)
const discoveryResults = ref<DiscoveredService[]>([])
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
@@ -930,6 +986,15 @@ const httpsOptions = [
{ label: 'HTTP', value: 'off' },
]
const cfTunnelOptions = computed(() =>
cfTunnels.value
.filter((tunnel) => tunnel.managed)
.map((tunnel) => ({
label: `${tunnel.name}${tunnel.status === 'connected' ? '(已连接)' : ''}`,
value: tunnel.id,
})),
)
const defaultSecurityForm = () => ({
ip_blacklist_text: '',
ip_blacklist_mode: false,
@@ -960,6 +1025,9 @@ const form = reactive({
http_redirect: true,
enabled: true,
name: '',
exit_local: true,
exit_cloudflare: false,
cf_tunnel_id: null as number | null,
...defaultSecurityForm(),
})
@@ -1374,6 +1442,17 @@ function ruleTitle(rule: ProxyRule): string {
return ruleName(rule)
}
function ruleExits(rule: ProxyRule): string[] {
return rule.exits?.length ? rule.exits : ['local']
}
function exitLabel(rule: ProxyRule): string {
const labels: string[] = []
if (ruleExits(rule).includes('local')) labels.push('本机 Nginx')
if (ruleExits(rule).includes('cloudflare')) labels.push('Cloudflare')
return labels.join(' + ') || '未设置'
}
function listenLabel(rule: ProxyRule): string {
const stacks = []
if (rule.listen_ipv4) stacks.push('IPv4')
@@ -1399,7 +1478,14 @@ function hostAccessUrl(rule: ProxyRule, hostPart: string): string {
}
function ruleAccessUrls(rule: ProxyRule): string[] {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
const exits = ruleExits(rule)
if (exits.includes('local')) {
return ruleHosts(rule).map((host) => hostAccessUrl(rule, host))
}
if (exits.includes('cloudflare')) {
return ruleHosts(rule).map((host) => `https://${host.replace(/:\d+$/, '')}`)
}
return []
}
import { copyText } from '../utils/clipboard'
@@ -1548,6 +1634,26 @@ function renderProtocol(row: ProxyRule): VNode {
return h('div', { class: 'proto-tags' }, tags)
}
function renderExits(row: ProxyRule): VNode {
const exits = ruleExits(row)
return h(
'div',
{ class: 'proto-tags' },
exits.map((exit) =>
h(
NTag,
{
size: 'small',
type: exit === 'cloudflare' ? 'info' : 'default',
bordered: false,
round: true,
},
() => (exit === 'cloudflare' ? 'Cloudflare' : '本机'),
),
),
)
}
function rowProps(row: ProxyRule) {
return {
class: showDetailPanel.value && selectedRuleId.value === row.id ? 'proxy-row--active' : '',
@@ -1599,6 +1705,12 @@ const columns = computed<DataTableColumns<ProxyRule>>(() => {
width: 88,
render: (row) => renderProtocol(row),
},
{
title: '出口',
key: 'exits',
width: 150,
render: (row) => renderExits(row),
},
{
title: '安全',
key: 'security',
@@ -1825,6 +1937,11 @@ async function load() {
loadError.value = ''
try {
rules.value = asList(await api.listProxies())
try {
cfTunnels.value = asList(await api.listCfTunnels())
} catch {
cfTunnels.value = []
}
} catch (error) {
loadError.value = error instanceof Error ? error.message : '请检查 Fonu 服务是否正常运行'
} finally {
@@ -1842,9 +1959,19 @@ function resetForm() {
form.http_redirect = true
form.enabled = true
form.name = ''
form.exit_local = true
form.exit_cloudflare = false
form.cf_tunnel_id = null
Object.assign(form, defaultSecurityForm())
}
function loadExitsToForm(rule?: ProxyRule | null) {
const exits = rule?.exits?.length ? rule.exits : ['local']
form.exit_local = exits.includes('local')
form.exit_cloudflare = exits.includes('cloudflare')
form.cf_tunnel_id = rule?.cf_tunnel_id || null
}
function loadSecurityToForm(rule?: ProxyRule | null) {
const sec = rule?.security ?? {}
form.ip_blacklist_text = (sec.ip_blacklist ?? []).join('\n')
@@ -1923,6 +2050,16 @@ function buildPayload(): ProxySavePayload {
if (!form.listen_ipv4 && !form.listen_ipv6) {
throw new Error('至少需要启用 IPv4 或 IPv6 监听')
}
if (!form.exit_local && !form.exit_cloudflare) {
throw new Error('至少需要选择一个出口')
}
if (form.exit_cloudflare && !form.cf_tunnel_id) {
throw new Error('请选择 Cloudflare 隧道')
}
const exits = [
...(form.exit_local ? ['local'] : []),
...(form.exit_cloudflare ? ['cloudflare'] : []),
]
return {
upstream: form.upstream,
listen_port: form.listen_port,
@@ -1934,6 +2071,8 @@ function buildPayload(): ProxySavePayload {
enabled: form.enabled,
name: form.name.trim(),
security: buildSecurityPayload(),
exits,
cf_tunnel_id: form.exit_cloudflare ? form.cf_tunnel_id! : 0,
}
}
@@ -1941,15 +2080,9 @@ async function scanServices() {
scanning.value = true
try {
const items = await api.scanDiscovery('127.0.0.1')
const found = items.find((i) => i.detected)
if (found) {
resetForm()
form.upstream = found.upstream
showModal.value = true
message.success(`检测到 ${found.name},请填写前端域名`)
} else {
message.info('未检测到常见服务')
}
discoveryResults.value = asList(items)
discoveryVisible.value = true
if (!items.some((item) => item.detected)) message.info('未检测到常见服务,可检查端口或手动填写目标地址')
} catch (error) {
message.error(error instanceof Error ? error.message : '扫描失败')
} finally {
@@ -1957,6 +2090,18 @@ async function scanServices() {
}
}
function publishDiscovered(item: DiscoveredService) {
discoveryVisible.value = false
openCreate()
form.name = item.name
form.upstream = item.upstream
if (cfTunnelOptions.value.length > 0) {
form.exit_cloudflare = true
form.cf_tunnel_id = Number(cfTunnelOptions.value[0].value)
}
message.info('已填入服务信息,请填写域名后保存')
}
function openCreate() {
editing.value = null
formTab.value = 'basic'
@@ -1973,7 +2118,11 @@ async function toggleRuleEnabled(row: ProxyRule, enabled: boolean) {
try {
const updated = await api.updateProxy(row.id, { enabled })
rules.value = rules.value.map((rule) => (rule.id === updated.id ? updated : rule))
message.success(enabled ? '已启用' : '已停用')
if (updated.sync_warning) {
message.warning(updated.sync_warning)
} else {
message.success(enabled ? '已启用' : '已停用')
}
} catch (error) {
rules.value = rules.value.map((rule) => (rule.id === row.id ? { ...rule, enabled: prev } : rule))
const msg = error instanceof Error ? error.message : '更新失败'
@@ -2002,6 +2151,7 @@ function openDuplicate(rule: ProxyRule) {
enabled: rule.enabled,
name: duplicateName(rule),
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
formTab.value = 'basic'
@@ -2024,6 +2174,7 @@ function openEdit(rule: ProxyRule, tab: 'basic' | 'security' | 'nginx' = 'basic'
enabled: rule.enabled,
name: rule.name ?? '',
})
loadExitsToForm(rule)
loadSecurityToForm(rule)
syncSecurityExpanded()
if (tab === 'nginx') {
@@ -2097,11 +2248,13 @@ async function save() {
return
}
if (editing.value) {
await api.updateProxy(editing.value.id, payload)
message.success('规则已保存')
const result = await api.updateProxy(editing.value.id, payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已保存')
} else {
await api.createProxy(payload)
message.success('规则已创建')
const result = await api.createProxy(payload)
if (result.sync_warning) message.warning(result.sync_warning)
else message.success('规则已创建')
}
showModal.value = false
await load()
@@ -3287,6 +3440,37 @@ useVisibilityPolling(loadClients, 5000, { enabled: clientsPolling })
line-height: 1.5;
}
.field-hint--warning { color: var(--fonu-warning); }
.exit-options {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.discovery-list {
display: grid;
gap: 8px;
}
.discovery-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);
}
.discovery-item__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.proxy-modal__pane :deep(.n-form-item .n-form-item-blank) {
display: block;
width: 100%;
+21 -4
View File
@@ -19,10 +19,21 @@
<p v-else-if="error" class="status-page__error">{{ error }}</p>
<div v-if="page && page.routes.length" class="status-page__list">
<div v-for="route in page.routes" :key="route.domain" class="status-row">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__domain mono">{{ route.domain }}</span>
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
<div v-for="route in page.routes" :key="route.domain" class="status-route">
<div class="status-row">
<StatusBadge :kind="kindOf(route.state)" :text="stateText(route.state)" />
<span class="status-row__domain mono">{{ route.domain }}</span>
<span v-if="route.source" class="status-row__source">{{ route.source === 'cloudflare' ? 'Cloudflare' : '公网反代' }}</span>
<span class="status-row__uptime mono">{{ route.uptime_pct.toFixed(1) }}%(24h)</span>
</div>
<div v-if="route.incidents?.length" class="incident-list">
<div v-for="incident in route.incidents.slice(0, 3)" :key="incident.from" class="incident-row">
<span class="incident-row__dot" />
<span class="mono">{{ formatDate(incident.from) }} 起</span>
<span>{{ incident.minutes }} 分钟</span>
<span>{{ incident.detail || incident.reason || '服务不可用' }}</span>
</div>
</div>
</div>
</div>
<p v-else-if="page && !error" class="status-page__hint">还没有巡检记录。</p>
@@ -107,7 +118,13 @@ onMounted(load)
border: 1px solid var(--fonu-border); border-radius: var(--fonu-radius-sm);
}
.status-row__domain { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.status-row__source { flex: none; font-size: 11.5px; color: var(--fonu-text-muted); }
.status-row__uptime { flex: none; font-size: 12.5px; color: var(--fonu-text-secondary); }
.status-route { border-bottom: 1px solid var(--fonu-border); }
.status-route:last-child { border-bottom: none; }
.incident-list { display: grid; gap: 5px; padding: 0 var(--fonu-space-3) 10px; }
.incident-row { display: flex; align-items: center; gap: 10px; color: var(--fonu-text-muted); font-size: 12px; flex-wrap: wrap; }
.incident-row__dot { width: 6px; height: 6px; border-radius: 50%; background: var(--fonu-error); }
.status-page__hint { margin: var(--fonu-space-3) 0 0; font-size: 12.5px; color: var(--fonu-text-muted); }
.status-page__hint--foot { margin-top: var(--fonu-space-5); }
</style>