mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy): surface node resource diagnostics
This commit is contained in:
@@ -138,7 +138,10 @@
|
||||
>
|
||||
{{ eventTypeLabel(event.event_type) }}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground truncate flex-1">{{ eventDetail(event) }}</span>
|
||||
<span
|
||||
class="text-muted-foreground truncate flex-1"
|
||||
:title="eventTooltip(event)"
|
||||
>{{ eventDetail(event) }}</span>
|
||||
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +149,35 @@
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
硬件与资源
|
||||
</h5>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div
|
||||
v-for="item in resourceItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-[11px] text-muted-foreground truncate">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-medium tabular-nums truncate mt-0.5"
|
||||
:class="item.tone"
|
||||
>
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.hint"
|
||||
class="text-[10px] text-muted-foreground truncate mt-0.5"
|
||||
>
|
||||
{{ item.hint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||
<h5 class="text-xs font-semibold mb-3">
|
||||
实时快照
|
||||
@@ -234,6 +266,8 @@ const metricsSummary = computed(() => metrics.value?.summary ?? null)
|
||||
const bucketItems = computed(() => metrics.value?.items.slice(-24) ?? [])
|
||||
const recentEvents = computed(() => props.state?.events.slice(0, 8) ?? [])
|
||||
const metadata = computed(() => asRecord(detailNode.value.proxy_metadata))
|
||||
const hardwareInfo = computed(() => asRecord(detailNode.value.hardware_info))
|
||||
const resourceUsage = computed(() => asRecord(metadata.value?.resource_usage))
|
||||
const tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
|
||||
|
||||
const loadedText = computed(() => {
|
||||
@@ -300,6 +334,82 @@ const snapshotItems = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const resourceItems = computed(() => {
|
||||
const hardware = hardwareInfo.value
|
||||
const resource = resourceUsage.value
|
||||
const memoryTotalBytes = numberField(resource, 'memory_total_bytes') ?? memoryMbToBytes(numberField(hardware, 'total_memory_mb'))
|
||||
const memoryUsedBytes = numberField(resource, 'memory_used_bytes')
|
||||
const memoryUsedPercent = numberField(resource, 'memory_used_percent') ?? percentFromBytes(memoryUsedBytes, memoryTotalBytes)
|
||||
const processMemoryBytes = numberField(resource, 'process_memory_bytes')
|
||||
const processMemoryPercent = numberField(resource, 'process_memory_percent')
|
||||
const systemCpu = numberField(resource, 'system_cpu_usage_percent')
|
||||
const processCpu = numberField(resource, 'process_cpu_usage_percent')
|
||||
const load1 = numberField(resource, 'load_average_1m')
|
||||
const load5 = numberField(resource, 'load_average_5m')
|
||||
const load15 = numberField(resource, 'load_average_15m')
|
||||
const cpuCores = numberField(hardware, 'cpu_cores')
|
||||
return [
|
||||
{
|
||||
label: '系统 CPU',
|
||||
value: formatUsagePercent(systemCpu),
|
||||
hint: '主机整体',
|
||||
tone: usageTone(systemCpu, 70, 90),
|
||||
},
|
||||
{
|
||||
label: '系统内存',
|
||||
value: formatUsagePercent(memoryUsedPercent),
|
||||
hint: memoryUsedBytes == null || memoryTotalBytes == null
|
||||
? ''
|
||||
: `${formatBytes(memoryUsedBytes)} / ${formatBytes(memoryTotalBytes)}`,
|
||||
tone: usageTone(memoryUsedPercent, 75, 90),
|
||||
},
|
||||
{
|
||||
label: 'Proxy CPU',
|
||||
value: formatUsagePercent(processCpu),
|
||||
hint: '当前进程',
|
||||
tone: usageTone(processCpu, 70, 90),
|
||||
},
|
||||
{
|
||||
label: 'Proxy 内存',
|
||||
value: processMemoryBytes == null ? '-' : formatBytes(processMemoryBytes),
|
||||
hint: processMemoryPercent == null ? '' : formatUsagePercent(processMemoryPercent),
|
||||
tone: usageTone(processMemoryPercent, 5, 15),
|
||||
},
|
||||
{
|
||||
label: '负载',
|
||||
value: formatLoadAverage(load1, load5, load15),
|
||||
hint: cpuCores == null ? '1/5/15m' : `${formatNumber(cpuCores)} 核`,
|
||||
tone: loadTone(load1, cpuCores),
|
||||
},
|
||||
{
|
||||
label: '运行时间',
|
||||
value: formatDurationSeconds(numberField(resource, 'process_uptime_secs')),
|
||||
hint: 'proxy 进程',
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: 'CPU 核心',
|
||||
value: cpuCores == null ? '-' : formatNumber(cpuCores),
|
||||
hint: stringField(hardware, 'os_info') || '',
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: '物理内存',
|
||||
value: memoryTotalBytes == null ? '-' : formatBytes(memoryTotalBytes),
|
||||
hint: '启动采集',
|
||||
tone: '',
|
||||
},
|
||||
{
|
||||
label: 'FD 限制',
|
||||
value: formatOptionalNumber(numberField(hardware, 'fd_limit')),
|
||||
hint: detailNode.value.estimated_max_concurrency == null
|
||||
? ''
|
||||
: `容量 ${formatNumber(detailNode.value.estimated_max_concurrency)}`,
|
||||
tone: '',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const tunnelCounterItems = computed(() => [
|
||||
{ label: '建连尝试', value: formatTunnelNumber('connect_attempts') },
|
||||
{ label: '建连成功', value: formatTunnelNumber('connect_successes') },
|
||||
@@ -352,6 +462,10 @@ function formatNumber(value: number) {
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function formatOptionalNumber(value: number | null) {
|
||||
return value == null ? '-' : formatNumber(value)
|
||||
}
|
||||
|
||||
function formatDecimal(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value)) return '-'
|
||||
if (value === 0) return '0'
|
||||
@@ -364,12 +478,24 @@ function formatPercent(value: number | null) {
|
||||
return `${(value * 100).toFixed(value >= 0.995 || value === 0 ? 0 : 1)}%`
|
||||
}
|
||||
|
||||
function formatUsagePercent(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value)) return '-'
|
||||
if (value >= 100) return `${Math.round(value)}%`
|
||||
if (value >= 10) return `${value.toFixed(0)}%`
|
||||
return `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatMs(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}s`
|
||||
return `${Math.round(value)}ms`
|
||||
}
|
||||
|
||||
function formatDurationSeconds(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
return formatDurationMs(value * 1000)
|
||||
}
|
||||
|
||||
function formatDurationMs(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||
const totalSeconds = Math.floor(value / 1000)
|
||||
@@ -399,6 +525,38 @@ function formatBytes(value: number) {
|
||||
return `${unit === 0 ? Math.round(size) : size.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function formatLoadAverage(one: number | null, five: number | null, fifteen: number | null) {
|
||||
if (one == null && five == null && fifteen == null) return '-'
|
||||
return [one, five, fifteen]
|
||||
.map(value => (value == null || !Number.isFinite(value)) ? '-' : value.toFixed(value >= 10 ? 0 : 2))
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
function memoryMbToBytes(value: number | null) {
|
||||
if (value == null || !Number.isFinite(value) || value <= 0) return null
|
||||
return value * 1024 * 1024
|
||||
}
|
||||
|
||||
function percentFromBytes(value: number | null, total: number | null) {
|
||||
if (value == null || total == null || !Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return null
|
||||
return value * 100 / total
|
||||
}
|
||||
|
||||
function usageTone(value: number | null, warnAt: number, badAt: number) {
|
||||
if (value == null || !Number.isFinite(value)) return ''
|
||||
if (value >= badAt) return 'text-destructive'
|
||||
if (value >= warnAt) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return ''
|
||||
}
|
||||
|
||||
function loadTone(load: number | null, cpuCores: number | null) {
|
||||
if (load == null || cpuCores == null || !Number.isFinite(load) || !Number.isFinite(cpuCores) || cpuCores <= 0) return ''
|
||||
const ratio = load / cpuCores
|
||||
if (ratio >= 1.5) return 'text-destructive'
|
||||
if (ratio >= 1) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null) {
|
||||
if (!iso) return '-'
|
||||
const date = new Date(iso)
|
||||
@@ -473,6 +631,63 @@ function eventTypeVariant(type: string) {
|
||||
|
||||
function eventDetail(event: ProxyNodeEvent) {
|
||||
const metadata = asRecord(event.event_metadata)
|
||||
if (event.event_type === 'tunnel_err') {
|
||||
const category = stringField(metadata, 'category')
|
||||
const message = stringField(metadata, 'message')
|
||||
const summary = (category ? tunnelErrorSummary(category) : '') || stringField(metadata, 'summary') || category
|
||||
if (summary && message) return `${summary}:${message}`
|
||||
return summary || message || event.detail || '-'
|
||||
}
|
||||
return event.detail || stringField(metadata, 'message') || stringField(metadata, 'category') || '-'
|
||||
}
|
||||
|
||||
function eventTooltip(event: ProxyNodeEvent) {
|
||||
const metadata = asRecord(event.event_metadata)
|
||||
const parts = [eventDetail(event)]
|
||||
const category = stringField(metadata, 'category')
|
||||
const action = (category ? tunnelErrorAction(category) : '') || stringField(metadata, 'operator_action')
|
||||
if (action) parts.push(`建议:${action}`)
|
||||
const component = stringField(metadata, 'component')
|
||||
const severity = stringField(metadata, 'severity')
|
||||
if (component || severity) parts.push([component, severity].filter(Boolean).join(' / '))
|
||||
return parts.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function tunnelErrorSummary(category: string) {
|
||||
switch (category) {
|
||||
case 'stale_timeout': return '超时未收到隧道入站帧'
|
||||
case 'ws_write_error': return 'WebSocket 写入失败,对端重置或关闭'
|
||||
case 'ws_ping_error': return 'WebSocket 保活 Ping 发送失败'
|
||||
case 'ws_read_error': return 'WebSocket 读取失败'
|
||||
case 'tunnel_connect_error': return '隧道建连失败'
|
||||
case 'frame_decode_error': return '隧道帧解析失败'
|
||||
case 'stream_dispatch_timeout': return '请求流分发超时'
|
||||
case 'heartbeat_ack_empty': return '心跳确认为空'
|
||||
case 'heartbeat_ack_parse': return '心跳确认解析失败'
|
||||
case 'writer_task_panic': return '隧道写任务异常退出'
|
||||
case 'writer_task_cancelled': return '隧道写任务被取消'
|
||||
case 'dispatcher_error': return '隧道分发器错误'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function tunnelErrorAction(category: string) {
|
||||
switch (category) {
|
||||
case 'stale_timeout': return '检查 gateway/反向代理 idle timeout、跨境链路抖动和 ping/pong 是否可达;网络抖动大时可调高 stale timeout。'
|
||||
case 'ws_write_error': return '检查 gateway 是否重启、负载均衡/NAT/防火墙是否重置长连接,并确认 proxy 是否已自动重连。'
|
||||
case 'ws_ping_error': return '检查中间代理是否清理空闲 WebSocket,或对端是否提前关闭连接。'
|
||||
case 'ws_read_error': return '对照同一时间的 gateway 日志和网络监控,确认是否存在链路中断。'
|
||||
case 'tunnel_connect_error': return '检查 Aether 地址、DNS、TLS、管理 token,以及 AETHER_PROXY_AETHER_PROXY_URL 配置。'
|
||||
case 'frame_decode_error': return '检查 proxy/gateway 版本兼容性,确认中间层没有改写 WebSocket 二进制帧。'
|
||||
case 'stream_dispatch_timeout': return '检查 proxy CPU/内存、并发上限和上游 provider 慢请求。'
|
||||
case 'heartbeat_ack_empty':
|
||||
case 'heartbeat_ack_parse':
|
||||
return '检查 gateway 心跳处理日志和 proxy/gateway 版本兼容性。'
|
||||
case 'writer_task_panic':
|
||||
case 'writer_task_cancelled':
|
||||
return '查看此前一条写入或 ping 错误,确认隧道重连循环仍在运行。'
|
||||
case 'dispatcher_error': return '检查同一时间的请求流和 gateway tunnel 日志。'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user