mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -96,4 +96,14 @@ export const proxyNodesApi = {
|
||||
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
|
||||
},
|
||||
|
||||
async testProxyUrl(data: { proxy_url: string; username?: string; password?: string }): Promise<ProxyNodeTestResult> {
|
||||
const response = await apiClient.post<ProxyNodeTestResult>('/api/admin/proxy-nodes/test-url', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getHmacKey(): Promise<{ proxy_hmac_key: string }> {
|
||||
const response = await apiClient.get<{ proxy_hmac_key: string }>('/api/admin/proxy-nodes/hmac-key')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -92,11 +92,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 格式转换标记(节点下方) -->
|
||||
<div
|
||||
v-if="group.hasConversion"
|
||||
class="conversion-indicator"
|
||||
>
|
||||
{{ group.primary.extra_data?.provider_api_format || '转换' }}
|
||||
</div>
|
||||
|
||||
<!-- 连接线 -->
|
||||
<div
|
||||
v-if="groupIndex < groupedTimeline.length - 1"
|
||||
class="node-line"
|
||||
/>
|
||||
class="node-line-wrapper"
|
||||
>
|
||||
<div
|
||||
class="node-line"
|
||||
:class="{ 'conversion-boundary': groupIndex + 1 === conversionBoundaryIndex }"
|
||||
/>
|
||||
<span
|
||||
v-if="groupIndex + 1 === conversionBoundaryIndex"
|
||||
class="boundary-label"
|
||||
>
|
||||
格式转换
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +201,19 @@
|
||||
<span class="info-label">首字 (TTFB)</span>
|
||||
<span class="info-value mono">{{ formatLatency(currentAttempt.extra_data.first_byte_time_ms) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.extra_data?.needs_conversion"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">格式</span>
|
||||
<span class="info-value">
|
||||
<span class="conversion-badge">格式转换</span>
|
||||
<code
|
||||
v-if="currentAttempt.extra_data?.provider_api_format"
|
||||
class="ml-1.5 text-xs"
|
||||
>{{ currentAttempt.extra_data.provider_api_format }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.key_name || currentAttempt.key_id"
|
||||
class="info-item"
|
||||
@@ -353,6 +385,7 @@ interface NodeGroup {
|
||||
totalLatency: number // 所有尝试的总延迟
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
hasConversion: boolean // 组内是否有格式转换候选
|
||||
}
|
||||
|
||||
// 用量数据类型
|
||||
@@ -478,8 +511,28 @@ const proxyTimingBreakdown = (proxy: Record<string, any>): string => {
|
||||
parts.push(label)
|
||||
}
|
||||
|
||||
parts.push(`DNS ${formatLatency(t.dns_ms)}`)
|
||||
parts.push(`上游 ${formatLatency(t.upstream_ms)}`)
|
||||
const ttfbMs = t.ttfb_ms ?? t.upstream_ms
|
||||
const processingMs = t.upstream_processing_ms ?? (
|
||||
ttfbMs != null && t.connect_ms != null && t.tls_ms != null
|
||||
? Math.max(0, ttfbMs - t.connect_ms - t.tls_ms)
|
||||
: null
|
||||
)
|
||||
|
||||
if (t.dns_ms != null && t.dns_ms > 0) {
|
||||
parts.push(`DNS ${formatLatency(t.dns_ms)}`)
|
||||
}
|
||||
if (t.connect_ms != null && t.connect_ms > 0) {
|
||||
parts.push(`连接 ${formatLatency(t.connect_ms)}`)
|
||||
}
|
||||
if (t.tls_ms != null && t.tls_ms > 0) {
|
||||
parts.push(`TLS ${formatLatency(t.tls_ms)}`)
|
||||
}
|
||||
if (ttfbMs != null && ttfbMs > 0) {
|
||||
parts.push(`TTFB ${formatLatency(ttfbMs)}`)
|
||||
}
|
||||
if (processingMs != null && processingMs > 0) {
|
||||
parts.push(`上游处理 ${formatLatency(Math.round(processingMs))}`)
|
||||
}
|
||||
|
||||
// 计算 Aether→代理 之间无法解释的耗时差
|
||||
if (proxy.ttfb_ms != null && t.total_ms != null) {
|
||||
@@ -535,6 +588,9 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
currentGroup.retryCount++
|
||||
currentGroup.endIndex = index
|
||||
currentGroup.totalLatency += candidate.latency_ms || 0
|
||||
if (candidate.extra_data?.needs_conversion) {
|
||||
currentGroup.hasConversion = true
|
||||
}
|
||||
// 按优先级提升组状态:success > streaming/pending > failed/cancelled/stream_interrupted > skipped > available/unused
|
||||
const statusPriority: Record<string, number> = {
|
||||
available: 0, unused: 0, skipped: 1,
|
||||
@@ -557,7 +613,8 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
retryCount: 0,
|
||||
totalLatency: candidate.latency_ms || 0,
|
||||
startIndex: index,
|
||||
endIndex: index
|
||||
endIndex: index,
|
||||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
}
|
||||
@@ -566,6 +623,16 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
||||
const conversionBoundaryIndex = computed(() => {
|
||||
const groups = groupedTimeline.value
|
||||
if (!groups || groups.length === 0) return -1
|
||||
const idx = groups.findIndex(g => g.hasConversion)
|
||||
// 只有当分界点不在最开头时才有意义(前面有 exact 候选)
|
||||
if (idx <= 0) return -1
|
||||
return idx
|
||||
})
|
||||
|
||||
// 计算链路总耗时(使用成功候选的 latency_ms 字段)
|
||||
// 优先使用 latency_ms,因为它与 Usage.response_time_ms 使用相同的时间基准
|
||||
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
|
||||
@@ -1076,15 +1143,69 @@ const getStatusColorClass = (status: string) => {
|
||||
.node-dot.status-skipped { color: hsl(var(--primary)); }
|
||||
.node-dot.status-available { color: #d1d5db; }
|
||||
|
||||
.node-line {
|
||||
/* 格式转换标记(节点下方) */
|
||||
.conversion-indicator {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.55rem;
|
||||
color: hsl(var(--muted-foreground) / 0.7);
|
||||
white-space: nowrap;
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 1px 4px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
/* 连接线容器 */
|
||||
.node-line-wrapper {
|
||||
position: absolute;
|
||||
right: -64px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 64px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.node-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: hsl(var(--border));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 格式转换分界线 */
|
||||
.node-line.conversion-boundary {
|
||||
background: none;
|
||||
height: 0;
|
||||
border-top: 2px dashed hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
.boundary-label {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
font-size: 0.55rem;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 详情面板中的格式转换标签 */
|
||||
.conversion-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 详情面板 */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user