feat: delegate 客户端替换为 hyper 原生实现,新增代理管理功能

aether-proxy:
- 用 hyper-util Client + 自定义 InstrumentedConnector 替换 reqwest delegate 客户端
- 支持 HTTP/HTTPS 自动 TLS,ALPN h2 协商,connect/tls 分阶段计时
- ConnectTiming 通过 hyper extensions 传递,上游响应细分 connect_ms/tls_ms/ttfb_ms
- upgrade 命令在非 root 下跳过 systemd restart 并提示手动操作

后端:
- 新增 /admin/proxy-nodes/test-url 接口,支持直接测试代理 URL 连通性
- 新增 /admin/proxy-nodes/hmac-key 接口,获取 HMAC Key 供部署使用
- 提取 _test_proxy_connectivity 公共函数,消除 test_node 中的重复代码
- candidate_resolver 在 extra_data 中输出 needs_conversion/provider_api_format
- stats_aggregator 小时聚合增加 IntegrityError 冲突重试

前端:
- 请求时间线组件展示代理 timing 细分(DNS/连接/TLS/TTFB/上游处理)
- 请求时间线增加格式转换分界标记和 conversion badge
- ProxyNodes 页面新增代理 URL 测试和 HMAC Key 复制功能
- HardwareTooltip 从 Popover 改为 Tooltip 组件
This commit is contained in:
fawney19
2026-02-11 23:24:59 +08:00
parent 943ca79951
commit 1c2a8119c0
16 changed files with 813 additions and 196 deletions

View File

@@ -96,14 +96,24 @@
</Select>
<div class="h-4 w-px bg-border" />
<Button
size="sm"
class="h-8 text-xs"
@click="showAddDialog = true"
variant="ghost"
size="icon"
class="h-8 w-8"
title="复制 HMAC Key"
@click="copyHmacKey"
>
<Plus class="w-3.5 h-3.5 mr-1" />
手动添加
<Copy class="w-3.5 h-3.5" />
</Button>
<div class="h-4 w-px bg-border" />
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="手动添加"
@click="showAddDialog = true"
>
<Plus class="w-3.5 h-3.5" />
</Button>
<RefreshButton
:loading="store.loading"
@click="refresh"
@@ -429,18 +439,29 @@
</form>
<template #footer>
<Button
variant="outline"
@click="handleDialogClose(false)"
>
取消
</Button>
<Button
:disabled="addingNode || !addForm.name || !addForm.proxy_url"
@click="editingNode ? handleUpdateManualNode() : handleAddManualNode()"
>
{{ addingNode ? (editingNode ? '保存中...' : '添加中...') : (editingNode ? '保存' : '添加') }}
</Button>
<div class="flex items-center justify-between w-full">
<Button
variant="outline"
:disabled="testingUrl || !addForm.proxy_url"
@click="handleTestUrl"
>
{{ testingUrl ? '测试中...' : '测试' }}
</Button>
<div class="flex items-center gap-2">
<Button
variant="outline"
@click="handleDialogClose(false)"
>
取消
</Button>
<Button
:disabled="addingNode || !addForm.name || !addForm.proxy_url"
@click="editingNode ? handleUpdateManualNode() : handleAddManualNode()"
>
{{ addingNode ? (editingNode ? '保存中...' : '添加中...') : (editingNode ? '保存' : '添加') }}
</Button>
</div>
</div>
</template>
</Dialog>
@@ -539,6 +560,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { useConfirm } from '@/composables/useConfirm'
import { proxyNodesApi, type ProxyNode, type ProxyNodeRemoteConfig } from '@/api/proxy-nodes'
@@ -564,11 +586,12 @@ import {
Dialog,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, Copy } from 'lucide-vue-next'
import { formatRegion } from '@/utils/region'
import HardwareTooltip from './components/HardwareTooltip.vue'
const { success, error: toastError } = useToast()
const { copyToClipboard } = useClipboard()
const { confirmDanger } = useConfirm()
const store = useProxyNodesStore()
@@ -602,6 +625,7 @@ const configForm = ref({
// 测试连通性
const testingNodes = ref(new Set<string>())
const testingUrl = ref(false)
const filteredNodes = computed(() => {
let filtered = [...store.nodes]
@@ -638,6 +662,38 @@ async function refresh() {
await store.fetchNodes()
}
async function handleTestUrl() {
if (!addForm.value.proxy_url || testingUrl.value) return
testingUrl.value = true
try {
const result = await proxyNodesApi.testProxyUrl({
proxy_url: addForm.value.proxy_url,
username: addForm.value.username || undefined,
password: addForm.value.password || undefined,
})
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 {
testingUrl.value = false
}
}
async function copyHmacKey() {
try {
const { proxy_hmac_key } = await proxyNodesApi.getHmacKey()
await copyToClipboard(proxy_hmac_key)
} catch (err: any) {
toastError(err.response?.data?.error?.message || err.response?.data?.detail || '获取 HMAC Key 失败')
}
}
function handleEdit(node: ProxyNode) {
editingNode.value = node
addForm.value = {

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import type { ProxyNode } from '@/api/proxy-nodes'
import {
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { Cpu } from 'lucide-vue-next'
import { computed } from 'vue'
@@ -76,36 +77,37 @@ function formatNumber(n: number) {
</script>
<template>
<Popover v-if="showHardwareInfo">
<PopoverTrigger as-child>
<button
type="button"
title="Hardware info"
aria-label="Hardware info"
class="inline-flex items-center justify-center rounded-sm p-0.5 hover:bg-muted/60 transition-colors"
>
<Cpu class="h-3.5 w-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
side="right"
:side-offset="8"
class="w-auto p-3 text-xs space-y-1"
>
<div
v-if="hardwareRows.length === 0"
class="text-muted-foreground"
>
No hardware info reported.
</div>
<template v-else>
<div
v-for="row in hardwareRows"
:key="row.label"
<TooltipProvider v-if="showHardwareInfo">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
aria-label="硬件信息"
class="inline-flex items-center justify-center rounded-sm p-0.5 hover:bg-muted/60 transition-colors"
>
{{ row.label }}: {{ row.value }}
<Cpu class="h-3.5 w-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
:side-offset="8"
class="w-auto px-3 py-2 text-xs space-y-1"
>
<div
v-if="hardwareRows.length === 0"
class="text-muted-foreground"
>
No hardware info reported.
</div>
</template>
</PopoverContent>
</Popover>
<template v-else>
<div
v-for="row in hardwareRows"
:key="row.label"
>
{{ row.label }}: {{ row.value }}
</div>
</template>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>