mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 代理 timing 细分指标与前端展示优化
- aether-proxy 新增 auth_ms/body_read_ms/body_parse_ms/body_size 计时字段 - 链路追踪面板代理耗时改为分阶段展示,密钥和代理信息改为堆叠布局 - 硬件信息从 Tooltip 改为 Popover,新增 FD Limit 展示 - 新增区域代码格式化工具函数,统一显示区域中文名称
This commit is contained in:
@@ -52,9 +52,11 @@ pub async fn handle_delegate(
|
|||||||
warn!(error = %e, "delegate auth failed");
|
warn!(error = %e, "delegate auth failed");
|
||||||
return error_response(401, "authentication_failed", &e.to_string());
|
return error_response(401, "authentication_failed", &e.to_string());
|
||||||
}
|
}
|
||||||
|
let auth_ms = total_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// Read and parse request body (limit to 10 MB to prevent OOM)
|
// Read and parse request body (limit to 10 MB to prevent OOM)
|
||||||
const MAX_BODY: usize = 10 * 1024 * 1024;
|
const MAX_BODY: usize = 10 * 1024 * 1024;
|
||||||
|
let body_read_start = Instant::now();
|
||||||
let body_bytes = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
let body_bytes = match Limited::new(req.into_body(), MAX_BODY).collect().await {
|
||||||
Ok(collected) => collected.to_bytes(),
|
Ok(collected) => collected.to_bytes(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -62,7 +64,10 @@ pub async fn handle_delegate(
|
|||||||
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
|
return error_response(413, "payload_too_large", "request body exceeds 10MB limit");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let body_read_ms = body_read_start.elapsed().as_millis() as u64;
|
||||||
|
let body_size = body_bytes.len() as u64;
|
||||||
|
|
||||||
|
let body_parse_start = Instant::now();
|
||||||
let delegate_req: DelegateRequest = match serde_json::from_slice(&body_bytes) {
|
let delegate_req: DelegateRequest = match serde_json::from_slice(&body_bytes) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -70,6 +75,7 @@ pub async fn handle_delegate(
|
|||||||
return error_response(400, "bad_request", &format!("invalid JSON: {}", e));
|
return error_response(400, "bad_request", &format!("invalid JSON: {}", e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let body_parse_ms = body_parse_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// Target filter: validate the upstream URL against allowed ports and private IP checks
|
// Target filter: validate the upstream URL against allowed ports and private IP checks
|
||||||
let parsed_url = match Url::parse(&delegate_req.url) {
|
let parsed_url = match Url::parse(&delegate_req.url) {
|
||||||
@@ -168,6 +174,10 @@ pub async fn handle_delegate(
|
|||||||
|
|
||||||
// Inject proxy timing header for Aether to parse
|
// Inject proxy timing header for Aether to parse
|
||||||
let timing = serde_json::json!({
|
let timing = serde_json::json!({
|
||||||
|
"auth_ms": auth_ms,
|
||||||
|
"body_read_ms": body_read_ms,
|
||||||
|
"body_parse_ms": body_parse_ms,
|
||||||
|
"body_size": body_size,
|
||||||
"dns_ms": dns_ms,
|
"dns_ms": dns_ms,
|
||||||
"upstream_ms": upstream_ms,
|
"upstream_ms": upstream_ms,
|
||||||
"total_ms": total_ms,
|
"total_ms": total_ms,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
:key="node.id"
|
:key="node.id"
|
||||||
:value="node.id"
|
:value="node.id"
|
||||||
>
|
>
|
||||||
{{ node.name }}{{ node.region ? ` · ${node.region}` : '' }} ({{ node.ip }}:{{ node.port }})
|
{{ node.name }}{{ node.region ? ` · ${formatRegion(node.region, '')}` : '' }} ({{ node.ip }}:{{ node.port }})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
|
import { formatRegion } from '@/utils/region'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
|
|||||||
@@ -187,15 +187,12 @@
|
|||||||
class="info-item"
|
class="info-item"
|
||||||
>
|
>
|
||||||
<span class="info-label">密钥</span>
|
<span class="info-label">密钥</span>
|
||||||
<span class="info-value">
|
<span class="info-value info-value-stacked">
|
||||||
<span class="key-name">{{ currentAttempt.key_name || '未知' }}</span>
|
<span class="key-name">{{ currentAttempt.key_name || '未知' }}</span>
|
||||||
<template v-if="currentAttempt.key_preview">
|
<code
|
||||||
<Separator
|
v-if="currentAttempt.key_preview"
|
||||||
orientation="vertical"
|
class="key-preview"
|
||||||
class="h-3 mx-2"
|
>{{ currentAttempt.key_preview }}</code>
|
||||||
/>
|
|
||||||
<code class="key-preview">{{ currentAttempt.key_preview }}</code>
|
|
||||||
</template>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -203,20 +200,26 @@
|
|||||||
class="info-item"
|
class="info-item"
|
||||||
>
|
>
|
||||||
<span class="info-label">代理</span>
|
<span class="info-label">代理</span>
|
||||||
<span class="info-value">
|
<span class="info-value info-value-stacked">
|
||||||
<span>{{ currentAttempt.extra_data.proxy.node_name || currentAttempt.extra_data.proxy.url || '未知' }}</span>
|
<span class="proxy-name">
|
||||||
|
{{ currentAttempt.extra_data.proxy.node_name || currentAttempt.extra_data.proxy.url || '未知' }}
|
||||||
<span
|
<span
|
||||||
v-if="currentAttempt.extra_data.proxy.source === 'system'"
|
v-if="currentAttempt.extra_data.proxy.source === 'system'"
|
||||||
class="text-xs text-muted-foreground ml-1"
|
class="text-xs text-muted-foreground ml-1"
|
||||||
>(系统)</span>
|
>(系统)</span>
|
||||||
|
</span>
|
||||||
|
<span class="proxy-detail">
|
||||||
<span
|
<span
|
||||||
v-if="currentAttempt.extra_data.proxy.ttfb_ms != null"
|
v-if="currentAttempt.extra_data.proxy.ttfb_ms != null"
|
||||||
class="text-xs text-muted-foreground ml-1"
|
class="text-xs text-muted-foreground"
|
||||||
>{{ formatLatency(currentAttempt.extra_data.proxy.ttfb_ms) }}</span>
|
>{{ formatLatency(currentAttempt.extra_data.proxy.ttfb_ms) }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="currentAttempt.extra_data.proxy.timing"
|
v-if="currentAttempt.extra_data.proxy.timing"
|
||||||
class="text-xs text-muted-foreground ml-1"
|
class="text-xs text-muted-foreground"
|
||||||
>(DNS {{ formatLatency(currentAttempt.extra_data.proxy.timing.dns_ms) }} / 上游 {{ formatLatency(currentAttempt.extra_data.proxy.timing.upstream_ms) }})</span>
|
>(<!--
|
||||||
|
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
|
||||||
|
-->)</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -335,7 +338,6 @@ import { ref, watch, computed } from 'vue'
|
|||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Skeleton from '@/components/ui/skeleton.vue'
|
import Skeleton from '@/components/ui/skeleton.vue'
|
||||||
import Separator from '@/components/ui/separator.vue'
|
|
||||||
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight, ExternalLink } from 'lucide-vue-next'
|
||||||
import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/requestTrace'
|
import { requestTraceApi, type RequestTrace, type CandidateRecord } from '@/api/requestTrace'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
@@ -450,6 +452,33 @@ const formatLatency = (ms: number | undefined | null): string => {
|
|||||||
return `${ms}ms`
|
return `${ms}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代理 timing 分阶段展示
|
||||||
|
const proxyTimingBreakdown = (proxy: Record<string, any>): string => {
|
||||||
|
const t = proxy.timing
|
||||||
|
if (!t) return ''
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
// 新版细分字段(aether-proxy 新版本上报)
|
||||||
|
if (t.body_read_ms != null && t.body_read_ms > 0) {
|
||||||
|
parts.push(`读取 ${formatLatency(t.body_read_ms)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`DNS ${formatLatency(t.dns_ms)}`)
|
||||||
|
parts.push(`上游 ${formatLatency(t.upstream_ms)}`)
|
||||||
|
|
||||||
|
// 计算 Aether→代理 之间无法解释的耗时差
|
||||||
|
if (proxy.ttfb_ms != null && t.total_ms != null) {
|
||||||
|
const gap = proxy.ttfb_ms - t.total_ms
|
||||||
|
if (gap > 500) {
|
||||||
|
// 超过 500ms 的差值才显示,排除正常网络 RTT
|
||||||
|
parts.push(`传输 ${formatLatency(Math.round(gap))}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
// 候选时间线(按实际执行顺序排序)
|
// 候选时间线(按实际执行顺序排序)
|
||||||
const timeline = computed<CandidateRecord[]>(() => {
|
const timeline = computed<CandidateRecord[]>(() => {
|
||||||
if (!trace.value) return []
|
if (!trace.value) return []
|
||||||
@@ -1256,13 +1285,20 @@ const getStatusColorClass = (status: string) => {
|
|||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 两行堆叠布局 */
|
||||||
|
.info-value-stacked {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Key 信息 */
|
/* Key 信息 */
|
||||||
.key-name {
|
.key-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key-preview {
|
.key-preview {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
background: hsl(var(--muted));
|
background: hsl(var(--muted));
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -1270,6 +1306,17 @@ const getStatusColorClass = (status: string) => {
|
|||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 代理信息 */
|
||||||
|
.proxy-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* 能力标签 */
|
/* 能力标签 */
|
||||||
.capability-tags {
|
.capability-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
23
frontend/src/utils/region.ts
Normal file
23
frontend/src/utils/region.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const regionLabels: Record<string, string> = {
|
||||||
|
US: 'US(美国)',
|
||||||
|
SG: 'SG(新加坡)',
|
||||||
|
JP: 'JP(日本)',
|
||||||
|
KR: 'KR(韩国)',
|
||||||
|
HK: 'HK(中国香港)',
|
||||||
|
TW: 'TW(中国台湾)',
|
||||||
|
DE: 'DE(德国)',
|
||||||
|
GB: 'GB(英国)',
|
||||||
|
FR: 'FR(法国)',
|
||||||
|
NL: 'NL(荷兰)',
|
||||||
|
AU: 'AU(澳大利亚)',
|
||||||
|
CA: 'CA(加拿大)',
|
||||||
|
IN: 'IN(印度)',
|
||||||
|
BR: 'BR(巴西)',
|
||||||
|
RU: 'RU(俄罗斯)',
|
||||||
|
CN: 'CN(中国)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRegion(region: string | null, fallback = '-'): string {
|
||||||
|
if (!region) return fallback
|
||||||
|
return regionLabels[region.toUpperCase()] || region
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
<code class="text-xs text-muted-foreground">{{ node.is_manual ? (node.proxy_url || `${node.ip}:${node.port}`) : `${node.ip}:${node.port}` }}</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4">
|
<TableCell class="py-4">
|
||||||
<span class="text-sm text-muted-foreground">{{ node.region || '-' }}</span>
|
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-4 text-center">
|
<TableCell class="py-4 text-center">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -286,7 +286,7 @@
|
|||||||
<div class="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-3">
|
<div class="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-3">
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-foreground/60">区域</span>
|
<span class="block text-foreground/60">区域</span>
|
||||||
<span>{{ node.region || '-' }}</span>
|
<span>{{ formatRegion(node.region) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="block text-foreground/60">连接</span>
|
<span class="block text-foreground/60">连接</span>
|
||||||
@@ -565,6 +565,7 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
|
||||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
|
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings } from 'lucide-vue-next'
|
||||||
|
import { formatRegion } from '@/utils/region'
|
||||||
import HardwareTooltip from './components/HardwareTooltip.vue'
|
import HardwareTooltip from './components/HardwareTooltip.vue'
|
||||||
|
|
||||||
const { success, error: toastError } = useToast()
|
const { success, error: toastError } = useToast()
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ProxyNode } from '@/api/proxy-nodes'
|
import type { ProxyNode } from '@/api/proxy-nodes'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Popover,
|
||||||
TooltipContent,
|
PopoverContent,
|
||||||
TooltipProvider,
|
PopoverTrigger,
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Cpu } from 'lucide-vue-next'
|
import { Cpu } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -24,14 +23,19 @@ function formatNumber(n: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider v-if="!node.is_manual && node.hardware_info">
|
<Popover v-if="!node.is_manual && node.hardware_info">
|
||||||
<Tooltip>
|
<PopoverTrigger as-child>
|
||||||
<TooltipTrigger as-child>
|
<button
|
||||||
<Cpu class="h-3.5 w-3.5 text-muted-foreground cursor-default" />
|
type="button"
|
||||||
</TooltipTrigger>
|
class="inline-flex items-center justify-center rounded-sm p-0.5 hover:bg-muted/60 transition-colors"
|
||||||
<TooltipContent
|
>
|
||||||
|
<Cpu class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
side="right"
|
side="right"
|
||||||
class="text-xs space-y-0.5"
|
:side-offset="8"
|
||||||
|
class="w-auto p-3 text-xs space-y-1"
|
||||||
>
|
>
|
||||||
<div v-if="node.hardware_info.cpu_cores">
|
<div v-if="node.hardware_info.cpu_cores">
|
||||||
CPU: {{ node.hardware_info.cpu_cores }} cores
|
CPU: {{ node.hardware_info.cpu_cores }} cores
|
||||||
@@ -45,7 +49,9 @@ function formatNumber(n: number) {
|
|||||||
<div v-if="node.estimated_max_concurrency">
|
<div v-if="node.estimated_max_concurrency">
|
||||||
Max Concurrency: ~{{ formatNumber(node.estimated_max_concurrency) }}
|
Max Concurrency: ~{{ formatNumber(node.estimated_max_concurrency) }}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
<div v-if="node.hardware_info.fd_limit">
|
||||||
</Tooltip>
|
FD Limit: {{ formatNumber(node.hardware_info.fd_limit) }}
|
||||||
</TooltipProvider>
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user