mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: aether-proxy 远程配置下发、连通性测试与 setup TUI
- 后端新增远程配置管理 API (PUT /config) 和连通性测试 API (POST /test) - 前端新增远程配置编辑对话框和节点连通性测试按钮 - aether-proxy 支持通过心跳接收并热加载远程配置 (端口白名单、日志级别、心跳间隔、时间戳容差) - aether-proxy 新增 TOML 配置文件支持和交互式 setup TUI - aether-proxy 心跳 404 时自动重注册节点 - plain proxy 响应改为流式传输,减少内存缓冲 - 新增 remote_config 和 config_version 数据库字段及迁移
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface ProxyNodeRemoteConfig {
|
||||
allowed_ports?: number[]
|
||||
log_level?: string
|
||||
heartbeat_interval?: number
|
||||
timestamp_tolerance?: number
|
||||
}
|
||||
|
||||
export interface ProxyNode {
|
||||
id: string
|
||||
name: string
|
||||
@@ -12,6 +19,9 @@ export interface ProxyNode {
|
||||
proxy_url?: string
|
||||
proxy_username?: string
|
||||
proxy_password?: string // 脱敏后的密码
|
||||
// 远程配置(aether-proxy 节点)
|
||||
remote_config: ProxyNodeRemoteConfig | null
|
||||
config_version: number
|
||||
registered_by: string | null
|
||||
last_heartbeat_at: string | null
|
||||
heartbeat_interval: number
|
||||
@@ -45,6 +55,13 @@ export interface ManualProxyNodeUpdateRequest {
|
||||
region?: string
|
||||
}
|
||||
|
||||
export interface ProxyNodeTestResult {
|
||||
success: boolean
|
||||
latency_ms: number | null
|
||||
exit_ip: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const proxyNodesApi = {
|
||||
async listProxyNodes(params?: { status?: string; skip?: number; limit?: number }): Promise<ProxyNodeListResponse> {
|
||||
const response = await apiClient.get<ProxyNodeListResponse>('/api/admin/proxy-nodes', { params })
|
||||
@@ -65,4 +82,14 @@ export const proxyNodesApi = {
|
||||
const response = await apiClient.delete<{ message: string; node_id: string; cleared_system_proxy: boolean }>(`/api/admin/proxy-nodes/${nodeId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async testNode(nodeId: string): Promise<ProxyNodeTestResult> {
|
||||
const response = await apiClient.post<ProxyNodeTestResult>(`/api/admin/proxy-nodes/${nodeId}/test`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateNodeConfig(nodeId: string, data: ProxyNodeRemoteConfig): Promise<{ node_id: string; config_version: number; remote_config: ProxyNodeRemoteConfig; node: ProxyNode }> {
|
||||
const response = await apiClient.put<{ node_id: string; config_version: number; remote_config: ProxyNodeRemoteConfig; node: ProxyNode }>(`/api/admin/proxy-nodes/${nodeId}/config`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,6 +152,17 @@
|
||||
</TableCell>
|
||||
<TableCell class="py-4 text-center">
|
||||
<div class="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
|
||||
:disabled="testingNodes.has(node.id)"
|
||||
@click="handleTest(node)"
|
||||
>
|
||||
<Loader2 v-if="testingNodes.has(node.id)" class="h-4 w-4 animate-spin" />
|
||||
<Activity v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
@@ -162,6 +173,16 @@
|
||||
>
|
||||
<SquarePen class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="远程配置"
|
||||
@click="handleConfig(node)"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -225,6 +246,17 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
:disabled="testingNodes.has(node.id)"
|
||||
@click="handleTest(node)"
|
||||
>
|
||||
<Loader2 v-if="testingNodes.has(node.id)" class="h-3 w-3 mr-1 animate-spin" />
|
||||
<Activity v-else class="h-3 w-3 mr-1" />
|
||||
{{ testingNodes.has(node.id) ? '测试中' : '测试' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="node.is_manual"
|
||||
variant="ghost"
|
||||
@@ -235,6 +267,16 @@
|
||||
<SquarePen class="h-3 w-3 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!node.is_manual"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs"
|
||||
@click="handleConfig(node)"
|
||||
>
|
||||
<Settings class="h-3 w-3 mr-1" />
|
||||
配置
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -338,6 +380,77 @@
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 远程配置对话框 (aether-proxy 节点) -->
|
||||
<Dialog
|
||||
:model-value="showConfigDialog"
|
||||
title="远程配置"
|
||||
description="修改后将在下次心跳时自动下发到 aether-proxy 节点"
|
||||
:icon="Settings"
|
||||
size="md"
|
||||
@update:model-value="handleConfigDialogClose"
|
||||
>
|
||||
<form class="space-y-4" @submit.prevent>
|
||||
<div class="space-y-1.5">
|
||||
<Label>允许的端口</Label>
|
||||
<Input
|
||||
v-model="configForm.allowed_ports"
|
||||
placeholder="80, 443, 8080, 8443"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">逗号分隔的目标端口白名单</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>日志级别</Label>
|
||||
<Select v-model="configForm.log_level">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="trace">trace</SelectItem>
|
||||
<SelectItem value="debug">debug</SelectItem>
|
||||
<SelectItem value="info">info</SelectItem>
|
||||
<SelectItem value="warn">warn</SelectItem>
|
||||
<SelectItem value="error">error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>心跳间隔 (秒)</Label>
|
||||
<Input
|
||||
v-model="configForm.heartbeat_interval"
|
||||
type="number"
|
||||
min="5"
|
||||
max="600"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>时间戳容差 (秒)</Label>
|
||||
<Input
|
||||
v-model="configForm.timestamp_tolerance"
|
||||
type="number"
|
||||
min="10"
|
||||
max="3600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="configNode" class="text-xs text-muted-foreground">
|
||||
配置版本: v{{ configNode.config_version }}
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleConfigDialogClose(false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="savingConfig"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
{{ savingConfig ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -346,7 +459,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { proxyNodesApi, type ProxyNode } from '@/api/proxy-nodes'
|
||||
import { proxyNodesApi, type ProxyNode, type ProxyNodeRemoteConfig } from '@/api/proxy-nodes'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -370,7 +483,7 @@ import {
|
||||
Dialog,
|
||||
} from '@/components/ui'
|
||||
|
||||
import { Search, Trash2, Plus, SquarePen } from 'lucide-vue-next'
|
||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
|
||||
|
||||
const { success, error: toastError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -393,6 +506,20 @@ const addForm = ref({
|
||||
region: '',
|
||||
})
|
||||
|
||||
// 远程配置对话框 (aether-proxy 节点)
|
||||
const showConfigDialog = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const configNode = ref<ProxyNode | null>(null)
|
||||
const configForm = ref({
|
||||
allowed_ports: '',
|
||||
log_level: 'info',
|
||||
heartbeat_interval: '30',
|
||||
timestamp_tolerance: '300',
|
||||
})
|
||||
|
||||
// 测试连通性
|
||||
const testingNodes = ref(new Set<string>())
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
let filtered = [...store.nodes]
|
||||
|
||||
@@ -492,6 +619,62 @@ async function handleAddManualNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfig(node: ProxyNode) {
|
||||
configNode.value = node
|
||||
const rc: ProxyNodeRemoteConfig = node.remote_config ?? {}
|
||||
configForm.value = {
|
||||
allowed_ports: rc.allowed_ports?.join(', ') || '',
|
||||
log_level: rc.log_level || 'info',
|
||||
heartbeat_interval: String(rc.heartbeat_interval || node.heartbeat_interval || 30),
|
||||
timestamp_tolerance: String(rc.timestamp_tolerance || 300),
|
||||
}
|
||||
showConfigDialog.value = true
|
||||
}
|
||||
|
||||
function handleConfigDialogClose(open: boolean) {
|
||||
if (!open) {
|
||||
showConfigDialog.value = false
|
||||
configNode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
if (!configNode.value) return
|
||||
savingConfig.value = true
|
||||
try {
|
||||
const data: Partial<ProxyNodeRemoteConfig> = {}
|
||||
const portsInput = configForm.value.allowed_ports.trim()
|
||||
if (portsInput) {
|
||||
data.allowed_ports = portsInput
|
||||
.split(',')
|
||||
.map((s: string) => parseInt(s.trim()))
|
||||
.filter((n: number) => !isNaN(n) && n >= 1 && n <= 65535)
|
||||
} else if (configNode.value.remote_config?.allowed_ports) {
|
||||
// 输入清空 → 显式发送空数组以清除已有端口白名单
|
||||
data.allowed_ports = []
|
||||
}
|
||||
if (configForm.value.log_level) {
|
||||
data.log_level = configForm.value.log_level
|
||||
}
|
||||
const hb = parseInt(configForm.value.heartbeat_interval)
|
||||
if (!isNaN(hb) && hb >= 5) {
|
||||
data.heartbeat_interval = hb
|
||||
}
|
||||
const tt = parseInt(configForm.value.timestamp_tolerance)
|
||||
if (!isNaN(tt) && tt >= 10) {
|
||||
data.timestamp_tolerance = tt
|
||||
}
|
||||
await proxyNodesApi.updateNodeConfig(configNode.value.id, data)
|
||||
success('远程配置已保存,将在下次心跳时生效')
|
||||
handleConfigDialogClose(false)
|
||||
await store.fetchNodes()
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(node: ProxyNode) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要删除代理节点 "${node.name}" (${node.ip}:${node.port}) 吗?`,
|
||||
@@ -513,6 +696,26 @@ async function handleDelete(node: ProxyNode) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(node: ProxyNode) {
|
||||
if (testingNodes.value.has(node.id)) return
|
||||
|
||||
testingNodes.value.add(node.id)
|
||||
try {
|
||||
const result = await proxyNodesApi.testNode(node.id)
|
||||
if (result.success) {
|
||||
const parts = [`延迟: ${result.latency_ms}ms`]
|
||||
if (result.exit_ip) parts.push(`出口IP: ${result.exit_ip}`)
|
||||
success(`连通性测试通过,${parts.join(',')}`)
|
||||
} else {
|
||||
toastError(`连通性测试失败: ${result.error || '未知错误'}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.error?.message || '测试请求失败')
|
||||
} finally {
|
||||
testingNodes.value.delete(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
switch (status) {
|
||||
case 'online': return 'success' as const
|
||||
|
||||
Reference in New Issue
Block a user