feat(cloudflare): 完成隧道接入、运维与 P0 体验优化
This commit is contained in:
+35
-2
@@ -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) }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user