mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy): add node metrics expansion panel
This commit is contained in:
@@ -48,9 +48,58 @@ export interface ProxyNode {
|
|||||||
|
|
||||||
export interface ProxyNodeEvent {
|
export interface ProxyNodeEvent {
|
||||||
id: number
|
id: number
|
||||||
event_type: 'connected' | 'disconnected' | 'error'
|
event_type: string
|
||||||
detail: string | null
|
detail: string | null
|
||||||
created_at: string
|
event_metadata?: Record<string, unknown> | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProxyNodeMetricsStep = '1m' | '1h'
|
||||||
|
|
||||||
|
export interface ProxyNodeMetricsSummary {
|
||||||
|
samples: number
|
||||||
|
uptime_samples: number
|
||||||
|
uptime_ratio: number | null
|
||||||
|
active_connections_sum: number
|
||||||
|
active_connections_max: number
|
||||||
|
active_connections_avg: number | null
|
||||||
|
heartbeat_rtt_ms_sum: number
|
||||||
|
heartbeat_rtt_ms_max: number
|
||||||
|
heartbeat_rtt_ms_avg: number | null
|
||||||
|
connect_errors_delta: number
|
||||||
|
disconnects_delta: number
|
||||||
|
error_events_delta: number
|
||||||
|
ws_in_bytes_delta: number
|
||||||
|
ws_out_bytes_delta: number
|
||||||
|
ws_in_frames_delta: number
|
||||||
|
ws_out_frames_delta: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyNodeMetricsBucket extends ProxyNodeMetricsSummary {
|
||||||
|
node_id?: string
|
||||||
|
bucket_start_unix_secs: number
|
||||||
|
bucket_start: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyNodeMetricsResponse {
|
||||||
|
step: ProxyNodeMetricsStep
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
items: ProxyNodeMetricsBucket[]
|
||||||
|
summary: ProxyNodeMetricsSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyNodeMetricsQuery {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
step: ProxyNodeMetricsStep
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxyNodeEventQuery {
|
||||||
|
limit?: number
|
||||||
|
from?: number
|
||||||
|
to?: number
|
||||||
|
event_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProxyNodeUpgradeRolloutProbe {
|
export interface ProxyNodeUpgradeRolloutProbe {
|
||||||
@@ -272,8 +321,19 @@ export const proxyNodesApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async listNodeEvents(nodeId: string, limit = 50): Promise<{ items: ProxyNodeEvent[] }> {
|
async listNodeMetrics(nodeId: string, params: ProxyNodeMetricsQuery): Promise<ProxyNodeMetricsResponse> {
|
||||||
const response = await apiClient.get<{ items: ProxyNodeEvent[] }>(`/api/admin/proxy-nodes/${nodeId}/events`, { params: { limit } })
|
const response = await apiClient.get<ProxyNodeMetricsResponse>(`/api/admin/proxy-nodes/${nodeId}/metrics`, { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listFleetMetrics(params: ProxyNodeMetricsQuery): Promise<ProxyNodeMetricsResponse> {
|
||||||
|
const response = await apiClient.get<ProxyNodeMetricsResponse>('/api/admin/proxy-nodes/metrics/fleet', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listNodeEvents(nodeId: string, query: ProxyNodeEventQuery | number = 50): Promise<{ items: ProxyNodeEvent[] }> {
|
||||||
|
const params = typeof query === 'number' ? { limit: query } : query
|
||||||
|
const response = await apiClient.get<{ items: ProxyNodeEvent[] }>(`/api/admin/proxy-nodes/${nodeId}/events`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,6 +131,7 @@
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
||||||
|
<TableHead class="w-[28px] min-w-[28px] max-w-[28px] h-12 p-0 pl-2" />
|
||||||
<TableHead class="w-[160px] h-12 font-semibold">
|
<TableHead class="w-[160px] h-12 font-semibold">
|
||||||
名称
|
名称
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -173,141 +174,177 @@
|
|||||||
<TableHead class="w-[160px] h-12 font-semibold">
|
<TableHead class="w-[160px] h-12 font-semibold">
|
||||||
最后心跳
|
最后心跳
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="w-[80px] h-12 font-semibold text-center">
|
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
||||||
操作
|
操作
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow
|
<template
|
||||||
v-for="node in paginatedNodes"
|
v-for="node in paginatedNodes"
|
||||||
:key="node.id"
|
:key="node.id"
|
||||||
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
>
|
||||||
<TableCell class="py-4">
|
<TableRow
|
||||||
<div class="flex items-center gap-1.5">
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
||||||
<span class="text-sm font-semibold">{{ node.name }}</span>
|
:class="isNodeExpanded(node.id) ? 'bg-muted/20' : ''"
|
||||||
<Badge
|
>
|
||||||
v-if="node.is_manual"
|
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
|
||||||
variant="outline"
|
<button
|
||||||
class="text-[10px] px-1.5 py-0"
|
type="button"
|
||||||
|
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
|
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
|
||||||
|
@click="toggleNodeDetails(node)"
|
||||||
>
|
>
|
||||||
手动
|
<ChevronDown
|
||||||
</Badge>
|
v-if="isNodeExpanded(node.id)"
|
||||||
<Badge
|
class="h-3.5 w-3.5"
|
||||||
v-if="node.tunnel_mode"
|
|
||||||
variant="outline"
|
|
||||||
class="text-[10px] px-1.5 py-0"
|
|
||||||
>
|
|
||||||
Tunnel
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
v-if="nodeSchedulingBadge(node)"
|
|
||||||
:variant="nodeSchedulingBadge(node)!.variant"
|
|
||||||
class="text-[10px] px-1.5 py-0"
|
|
||||||
>
|
|
||||||
{{ nodeSchedulingBadge(node)!.label }}
|
|
||||||
</Badge>
|
|
||||||
<HardwareTooltip :node="node" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4">
|
|
||||||
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4">
|
|
||||||
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
|
||||||
<Badge
|
|
||||||
:variant="statusVariant(node.status)"
|
|
||||||
:title="statusTitle(node)"
|
|
||||||
class="font-medium px-2.5 py-0.5 text-xs"
|
|
||||||
>
|
|
||||||
{{ statusLabel(node) }}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
|
||||||
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
|
||||||
<span
|
|
||||||
class="text-sm tabular-nums"
|
|
||||||
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
|
||||||
>{{ formatFailureRate(node) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
|
||||||
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4 text-center">
|
|
||||||
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="py-4">
|
|
||||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
|
||||||
</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
|
<ChevronRight
|
||||||
v-else
|
v-else
|
||||||
class="h-4 w-4"
|
class="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
</TableCell>
|
||||||
v-if="node.is_manual"
|
<TableCell class="py-4">
|
||||||
variant="ghost"
|
<div class="flex items-center gap-1.5">
|
||||||
size="icon"
|
<span class="text-sm font-semibold">{{ node.name }}</span>
|
||||||
class="h-8 w-8"
|
<Badge
|
||||||
title="编辑"
|
v-if="node.is_manual"
|
||||||
@click="handleEdit(node)"
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
手动
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="node.tunnel_mode"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
Tunnel
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="nodeSchedulingBadge(node)"
|
||||||
|
:variant="nodeSchedulingBadge(node)!.variant"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{{ nodeSchedulingBadge(node)!.label }}
|
||||||
|
</Badge>
|
||||||
|
<HardwareTooltip :node="node" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="py-4">
|
||||||
|
<span class="text-sm text-muted-foreground">{{ formatRegion(node.region) }}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="py-4 text-center">
|
||||||
|
<Badge
|
||||||
|
:variant="statusVariant(node.status)"
|
||||||
|
:title="statusTitle(node)"
|
||||||
|
class="font-medium px-2.5 py-0.5 text-xs"
|
||||||
>
|
>
|
||||||
<SquarePen class="h-4 w-4" />
|
{{ statusLabel(node) }}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button
|
</TableCell>
|
||||||
v-if="!node.is_manual"
|
<TableCell class="py-4 text-center">
|
||||||
variant="ghost"
|
<span class="text-sm tabular-nums">{{ formatNumber(node.total_requests) }}</span>
|
||||||
size="icon"
|
</TableCell>
|
||||||
class="h-8 w-8"
|
<TableCell class="py-4 text-center">
|
||||||
title="远程配置"
|
<span
|
||||||
@click="handleConfig(node)"
|
class="text-sm tabular-nums"
|
||||||
>
|
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
||||||
<Settings class="h-4 w-4" />
|
>{{ formatFailureRate(node) }}</span>
|
||||||
</Button>
|
</TableCell>
|
||||||
<Button
|
<TableCell class="py-4 text-center">
|
||||||
v-if="!node.is_manual"
|
<span class="text-sm tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
||||||
variant="ghost"
|
</TableCell>
|
||||||
size="icon"
|
<TableCell class="py-4 text-center">
|
||||||
class="h-8 w-8"
|
<span class="text-sm tabular-nums">{{ node.is_manual ? '-' : nodeProxyVersion(node) }}</span>
|
||||||
title="连接事件"
|
</TableCell>
|
||||||
@click="handleViewEvents(node)"
|
<TableCell class="py-4">
|
||||||
>
|
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||||
<History class="h-4 w-4" />
|
</TableCell>
|
||||||
</Button>
|
<TableCell class="py-4 text-center">
|
||||||
<Button
|
<div class="flex items-center justify-center gap-0.5">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
class="h-8 w-8"
|
size="icon"
|
||||||
title="删除"
|
class="h-8 w-8"
|
||||||
@click="handleDelete(node)"
|
:title="testingNodes.has(node.id) ? '测试中...' : '测试连通性'"
|
||||||
>
|
:disabled="testingNodes.has(node.id)"
|
||||||
<Trash2 class="h-4 w-4" />
|
@click="handleTest(node)"
|
||||||
</Button>
|
>
|
||||||
</div>
|
<Loader2
|
||||||
</TableCell>
|
v-if="testingNodes.has(node.id)"
|
||||||
</TableRow>
|
class="h-4 w-4 animate-spin"
|
||||||
|
/>
|
||||||
|
<Activity
|
||||||
|
v-else
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="node.is_manual"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="编辑"
|
||||||
|
@click="handleEdit(node)"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
v-if="!node.is_manual"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="连接事件"
|
||||||
|
@click="handleViewEvents(node)"
|
||||||
|
>
|
||||||
|
<History class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
|
title="删除"
|
||||||
|
@click="handleDelete(node)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow
|
||||||
|
v-if="isNodeExpanded(node.id)"
|
||||||
|
class="border-b border-border/40 hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
colspan="11"
|
||||||
|
class="p-0"
|
||||||
|
>
|
||||||
|
<ProxyNodeDataPanel
|
||||||
|
:node="node"
|
||||||
|
:state="nodeDetails[node.id]"
|
||||||
|
@refresh="loadNodeDetails(node)"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
<TableRow v-if="paginatedNodes.length === 0">
|
<TableRow v-if="paginatedNodes.length === 0">
|
||||||
<TableCell
|
<TableCell
|
||||||
colspan="10"
|
colspan="11"
|
||||||
class="py-12 text-center text-muted-foreground text-sm"
|
class="py-12 text-center text-muted-foreground text-sm"
|
||||||
>
|
>
|
||||||
{{ store.loading ? '加载中...' : '暂无代理节点' }}
|
{{ store.loading ? '加载中...' : '暂无代理节点' }}
|
||||||
@@ -327,6 +364,21 @@
|
|||||||
<div class="flex items-start justify-between mb-2">
|
<div class="flex items-start justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 shrink-0"
|
||||||
|
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
|
||||||
|
@click="toggleNodeDetails(node)"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
v-if="isNodeExpanded(node.id)"
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<ChevronRight
|
||||||
|
v-else
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<span class="font-semibold text-sm">{{ node.name }}</span>
|
<span class="font-semibold text-sm">{{ node.name }}</span>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="node.is_manual"
|
v-if="node.is_manual"
|
||||||
@@ -390,7 +442,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -439,6 +491,16 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isNodeExpanded(node.id)"
|
||||||
|
class="mt-4 -mx-4 sm:-mx-5"
|
||||||
|
>
|
||||||
|
<ProxyNodeDataPanel
|
||||||
|
:node="node"
|
||||||
|
:state="nodeDetails[node.id]"
|
||||||
|
@refresh="loadNodeDetails(node)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="paginatedNodes.length === 0"
|
v-if="paginatedNodes.length === 0"
|
||||||
@@ -779,6 +841,7 @@ import {
|
|||||||
proxyNodesApi,
|
proxyNodesApi,
|
||||||
type ProxyNode,
|
type ProxyNode,
|
||||||
type ProxyNodeEvent,
|
type ProxyNodeEvent,
|
||||||
|
type ProxyNodeMetricsResponse,
|
||||||
type ProxyNodeRemoteConfig,
|
type ProxyNodeRemoteConfig,
|
||||||
type ProxyNodeSchedulingState,
|
type ProxyNodeSchedulingState,
|
||||||
type ProxyNodeTestResult,
|
type ProxyNodeTestResult,
|
||||||
@@ -808,10 +871,11 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
|
||||||
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History } from 'lucide-vue-next'
|
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { formatRegion } from '@/utils/region'
|
import { formatRegion } from '@/utils/region'
|
||||||
import HardwareTooltip from './components/HardwareTooltip.vue'
|
import HardwareTooltip from './components/HardwareTooltip.vue'
|
||||||
|
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
|
||||||
|
|
||||||
const { success, error: toastError } = useToast()
|
const { success, error: toastError } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
const { confirmDanger } = useConfirm()
|
||||||
@@ -860,6 +924,18 @@ const eventsNode = ref<ProxyNode | null>(null)
|
|||||||
const nodeEvents = ref<ProxyNodeEvent[]>([])
|
const nodeEvents = ref<ProxyNodeEvent[]>([])
|
||||||
const loadingEvents = ref(false)
|
const loadingEvents = ref(false)
|
||||||
|
|
||||||
|
interface ProxyNodeDetailState {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
node: ProxyNode | null
|
||||||
|
metrics: ProxyNodeMetricsResponse | null
|
||||||
|
events: ProxyNodeEvent[]
|
||||||
|
loadedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedNodeIds = ref(new Set<string>())
|
||||||
|
const nodeDetails = ref<Record<string, ProxyNodeDetailState>>({})
|
||||||
|
|
||||||
// 测试连通性
|
// 测试连通性
|
||||||
const testingNodes = ref(new Set<string>())
|
const testingNodes = ref(new Set<string>())
|
||||||
const testingUrl = ref(false)
|
const testingUrl = ref(false)
|
||||||
@@ -1131,12 +1207,82 @@ async function handleTest(node: ProxyNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createNodeDetailState(): ProxyNodeDetailState {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
node: null,
|
||||||
|
metrics: null,
|
||||||
|
events: [],
|
||||||
|
loadedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNodeDetailState(nodeId: string, patch: Partial<ProxyNodeDetailState>) {
|
||||||
|
nodeDetails.value = {
|
||||||
|
...nodeDetails.value,
|
||||||
|
[nodeId]: {
|
||||||
|
...(nodeDetails.value[nodeId] ?? createNodeDetailState()),
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNodeExpanded(nodeId: string) {
|
||||||
|
return expandedNodeIds.value.has(nodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNodeDetails(node: ProxyNode) {
|
||||||
|
const next = new Set(expandedNodeIds.value)
|
||||||
|
if (next.has(node.id)) {
|
||||||
|
next.delete(node.id)
|
||||||
|
expandedNodeIds.value = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.add(node.id)
|
||||||
|
expandedNodeIds.value = next
|
||||||
|
|
||||||
|
const detailState = nodeDetails.value[node.id]
|
||||||
|
if (!detailState?.loadedAt && !detailState?.loading) {
|
||||||
|
void loadNodeDetails(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNodeDetails(node: ProxyNode) {
|
||||||
|
updateNodeDetailState(node.id, { loading: true, error: null })
|
||||||
|
const to = Math.floor(Date.now() / 1000)
|
||||||
|
const from = to - 24 * 60 * 60
|
||||||
|
const eventsFrom = to - 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [detail, metrics, events] = await Promise.all([
|
||||||
|
proxyNodesApi.getNode(node.id),
|
||||||
|
proxyNodesApi.listNodeMetrics(node.id, { from, to, step: '1h' }),
|
||||||
|
proxyNodesApi.listNodeEvents(node.id, { limit: 8, from: eventsFrom, to }),
|
||||||
|
])
|
||||||
|
updateNodeDetailState(node.id, {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
node: detail.node,
|
||||||
|
metrics,
|
||||||
|
events: events.items,
|
||||||
|
loadedAt: Date.now(),
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
updateNodeDetailState(node.id, {
|
||||||
|
loading: false,
|
||||||
|
error: parseApiError(err, '加载节点数据失败'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleViewEvents(node: ProxyNode) {
|
async function handleViewEvents(node: ProxyNode) {
|
||||||
eventsNode.value = node
|
eventsNode.value = node
|
||||||
showEventsDialog.value = true
|
showEventsDialog.value = true
|
||||||
loadingEvents.value = true
|
loadingEvents.value = true
|
||||||
try {
|
try {
|
||||||
const res = await proxyNodesApi.listNodeEvents(node.id, 50)
|
const res = await proxyNodesApi.listNodeEvents(node.id, { limit: 50 })
|
||||||
nodeEvents.value = res.items
|
nodeEvents.value = res.items
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toastError(parseApiError(err, '加载事件失败'))
|
toastError(parseApiError(err, '加载事件失败'))
|
||||||
|
|||||||
478
frontend/src/views/admin/components/ProxyNodeDataPanel.vue
Normal file
478
frontend/src/views/admin/components/ProxyNodeDataPanel.vue
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
<template>
|
||||||
|
<div class="px-4 sm:px-6 py-4 bg-muted/15 border-t border-border/40">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="text-sm font-semibold">
|
||||||
|
节点数据
|
||||||
|
</h4>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
最近 24 小时
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
|
{{ loadedText }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2 text-xs self-start sm:self-auto"
|
||||||
|
:disabled="state?.loading"
|
||||||
|
@click="$emit('refresh')"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
class="h-3.5 w-3.5 mr-1"
|
||||||
|
:class="state?.loading ? 'animate-spin' : ''"
|
||||||
|
/>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="state?.loading && !state.metrics"
|
||||||
|
class="py-8 flex items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
加载节点数据...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="state?.error"
|
||||||
|
class="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive flex items-start gap-2"
|
||||||
|
>
|
||||||
|
<AlertTriangle class="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
<span>{{ state.error }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="item in summaryStats"
|
||||||
|
:key="item.label"
|
||||||
|
class="rounded-lg border border-border/50 bg-background/70 px-3 py-2 min-w-0"
|
||||||
|
>
|
||||||
|
<div class="text-[11px] text-muted-foreground truncate">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-sm font-semibold 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>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(280px,0.9fr)]">
|
||||||
|
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||||
|
<div class="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-xs font-semibold">
|
||||||
|
在线率采样
|
||||||
|
</h5>
|
||||||
|
<p class="text-[11px] text-muted-foreground mt-0.5">
|
||||||
|
1h 桶,颜色随在线率和错误数变化
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-muted-foreground tabular-nums shrink-0">{{ formatNumber(bucketItems.length) }} 点</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="bucketItems.length > 0"
|
||||||
|
class="h-20 flex items-end gap-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="bucket in bucketItems"
|
||||||
|
:key="bucket.bucket_start_unix_secs"
|
||||||
|
class="flex-1 min-w-[4px] rounded-t-sm"
|
||||||
|
:class="bucketBarClass(bucket)"
|
||||||
|
:style="{ height: bucketBarHeight(bucket) }"
|
||||||
|
:title="bucketTitle(bucket)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无采样数据
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-lg border border-border/50 bg-background/70 p-3 min-w-0">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<h5 class="text-xs font-semibold">
|
||||||
|
最近事件
|
||||||
|
</h5>
|
||||||
|
<span class="text-[11px] text-muted-foreground tabular-nums">{{ recentEvents.length }}/8</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="recentEvents.length === 0"
|
||||||
|
class="h-20 rounded-md bg-muted/30 flex items-center justify-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无关键事件
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="space-y-1.5 max-h-28 overflow-y-auto pr-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="event in recentEvents"
|
||||||
|
:key="event.id"
|
||||||
|
class="flex items-center gap-2 text-xs min-w-0"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
:variant="eventTypeVariant(event.event_type)"
|
||||||
|
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||||
|
>
|
||||||
|
{{ eventTypeLabel(event.event_type) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-muted-foreground truncate flex-1">{{ eventDetail(event) }}</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at || null) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</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 snapshotItems"
|
||||||
|
: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">
|
||||||
|
{{ item.value }}
|
||||||
|
</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">
|
||||||
|
隧道计数器
|
||||||
|
</h5>
|
||||||
|
<div
|
||||||
|
v-if="tunnelMetrics"
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in tunnelCounterItems"
|
||||||
|
: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">
|
||||||
|
{{ item.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-md bg-muted/30 py-4 text-center text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无隧道计数器
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { AlertTriangle, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||||
|
import { Badge, Button } from '@/components/ui'
|
||||||
|
import type {
|
||||||
|
ProxyNode,
|
||||||
|
ProxyNodeEvent,
|
||||||
|
ProxyNodeMetricsBucket,
|
||||||
|
ProxyNodeMetricsResponse,
|
||||||
|
} from '@/api/proxy-nodes'
|
||||||
|
|
||||||
|
interface ProxyNodeDetailState {
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
node: ProxyNode | null
|
||||||
|
metrics: ProxyNodeMetricsResponse | null
|
||||||
|
events: ProxyNodeEvent[]
|
||||||
|
loadedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
node: ProxyNode
|
||||||
|
state?: ProxyNodeDetailState | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
refresh: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const detailNode = computed(() => props.state?.node ?? props.node)
|
||||||
|
const metrics = computed(() => props.state?.metrics ?? null)
|
||||||
|
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 tunnelMetrics = computed(() => asRecord(metadata.value?.tunnel_metrics))
|
||||||
|
|
||||||
|
const loadedText = computed(() => {
|
||||||
|
if (props.state?.loading) return '正在刷新采样数据'
|
||||||
|
if (!props.state?.loadedAt) return '展开后自动读取 24 小时指标和最近关键事件'
|
||||||
|
return `数据刷新于 ${formatLoadedAt(props.state.loadedAt)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const summaryStats = computed(() => {
|
||||||
|
const summary = metricsSummary.value
|
||||||
|
const totalBytes = (summary?.ws_in_bytes_delta ?? 0) + (summary?.ws_out_bytes_delta ?? 0)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '在线率',
|
||||||
|
value: formatPercent(summary?.uptime_ratio ?? null),
|
||||||
|
hint: `${formatNumber(summary?.uptime_samples ?? 0)}/${formatNumber(summary?.samples ?? 0)} 样本`,
|
||||||
|
tone: uptimeTone(summary?.uptime_ratio ?? null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '心跳 RTT',
|
||||||
|
value: formatMs(summary?.heartbeat_rtt_ms_avg ?? null),
|
||||||
|
hint: `峰值 ${formatMs(summary?.heartbeat_rtt_ms_max ?? null)}`,
|
||||||
|
tone: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '并发峰值',
|
||||||
|
value: formatNumber(summary?.active_connections_max ?? 0),
|
||||||
|
hint: `均值 ${formatDecimal(summary?.active_connections_avg ?? null)}`,
|
||||||
|
tone: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '断开',
|
||||||
|
value: formatNumber(summary?.disconnects_delta ?? 0),
|
||||||
|
hint: '24h delta',
|
||||||
|
tone: (summary?.disconnects_delta ?? 0) > 0 ? 'text-yellow-600 dark:text-yellow-400' : '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '连接错误',
|
||||||
|
value: formatNumber(summary?.connect_errors_delta ?? 0),
|
||||||
|
hint: '24h delta',
|
||||||
|
tone: (summary?.connect_errors_delta ?? 0) > 0 ? 'text-destructive' : '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'WS 流量',
|
||||||
|
value: formatBytes(totalBytes),
|
||||||
|
hint: `${formatNumber((summary?.ws_in_frames_delta ?? 0) + (summary?.ws_out_frames_delta ?? 0))} 帧`,
|
||||||
|
tone: '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const snapshotItems = computed(() => {
|
||||||
|
const node = detailNode.value
|
||||||
|
return [
|
||||||
|
{ label: '当前并发', value: formatNumber(node.active_connections ?? 0) },
|
||||||
|
{ label: '心跳间隔', value: `${node.heartbeat_interval ?? '-'}s` },
|
||||||
|
{ label: '最后心跳', value: formatTime(node.last_heartbeat_at) },
|
||||||
|
{ label: '隧道连接', value: node.tunnel_connected ? '已连接' : '未连接' },
|
||||||
|
{ label: '连接时间', value: formatTime(node.tunnel_connected_at) },
|
||||||
|
{ label: '容量估算', value: node.estimated_max_concurrency == null ? '-' : formatNumber(node.estimated_max_concurrency) },
|
||||||
|
{ label: '配置版本', value: `v${node.config_version}` },
|
||||||
|
{ label: '注册来源', value: node.registered_by || '-' },
|
||||||
|
{ label: '代理版本', value: stringField(metadata.value, 'version') || '-' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const tunnelCounterItems = computed(() => [
|
||||||
|
{ label: '建连尝试', value: formatTunnelNumber('connect_attempts') },
|
||||||
|
{ label: '建连成功', value: formatTunnelNumber('connect_successes') },
|
||||||
|
{ label: '建连错误', value: formatTunnelNumber('connect_errors') },
|
||||||
|
{ label: '断开次数', value: formatTunnelNumber('disconnects') },
|
||||||
|
{ label: '最近连上', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_connected_at_unix_secs')) },
|
||||||
|
{ label: '最近断开', value: formatUnixSecs(numberField(tunnelMetrics.value, 'last_disconnected_at_unix_secs')) },
|
||||||
|
{ label: '最近在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'last_connected_duration_ms')) },
|
||||||
|
{ label: '累计在线', value: formatDurationMs(numberField(tunnelMetrics.value, 'connected_duration_total_ms')) },
|
||||||
|
{ label: '心跳发送', value: formatTunnelNumber('heartbeat_sent') },
|
||||||
|
{ label: '心跳确认', value: formatTunnelNumber('heartbeat_ack') },
|
||||||
|
{ label: '最近 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_last_ms')) },
|
||||||
|
{ label: '平均 RTT', value: formatMs(numberField(tunnelMetrics.value, 'heartbeat_rtt_avg_ms')) },
|
||||||
|
{ label: 'WS 入站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_in_bytes') ?? 0) },
|
||||||
|
{ label: 'WS 出站', value: formatBytes(numberField(tunnelMetrics.value, 'ws_out_bytes') ?? 0) },
|
||||||
|
{ label: '入站帧', value: formatTunnelNumber('ws_in_frames') },
|
||||||
|
{ label: '出站帧', value: formatTunnelNumber('ws_out_frames') },
|
||||||
|
])
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberField(record: Record<string, unknown> | null, key: string): number | null {
|
||||||
|
const value = record?.[key]
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (Number.isFinite(parsed)) return parsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown> | null, key: string): string | null {
|
||||||
|
const value = record?.[key]
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTunnelNumber(key: string) {
|
||||||
|
return formatNumber(numberField(tunnelMetrics.value, key) ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return '-'
|
||||||
|
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
|
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
||||||
|
return String(Math.round(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDecimal(value: number | null) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '-'
|
||||||
|
if (value === 0) return '0'
|
||||||
|
if (value < 10) return value.toFixed(1)
|
||||||
|
return formatNumber(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number | null) {
|
||||||
|
if (value == null || !Number.isFinite(value)) return '-'
|
||||||
|
return `${(value * 100).toFixed(value >= 0.995 || value === 0 ? 0 : 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 formatDurationMs(value: number | null) {
|
||||||
|
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||||
|
const totalSeconds = Math.floor(value / 1000)
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`
|
||||||
|
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||||
|
if (totalMinutes < 60) return `${totalMinutes}m`
|
||||||
|
const totalHours = Math.floor(totalMinutes / 60)
|
||||||
|
if (totalHours < 24) return `${totalHours}h ${totalMinutes % 60}m`
|
||||||
|
const days = Math.floor(totalHours / 24)
|
||||||
|
return `${days}d ${totalHours % 24}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnixSecs(value: number | null) {
|
||||||
|
if (value == null || !Number.isFinite(value) || value <= 0) return '-'
|
||||||
|
return formatTime(new Date(value * 1000).toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let size = value
|
||||||
|
let unit = 0
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unit += 1
|
||||||
|
}
|
||||||
|
return `${unit === 0 ? Math.round(size) : size.toFixed(1)} ${units[unit]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string | null) {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return '-'
|
||||||
|
const diff = (Date.now() - date.getTime()) / 1000
|
||||||
|
if (diff >= 0 && diff < 60) return '刚刚'
|
||||||
|
if (diff >= 0 && diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
||||||
|
if (diff >= 0 && diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLoadedAt(value: number) {
|
||||||
|
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketBarHeight(bucket: ProxyNodeMetricsBucket) {
|
||||||
|
if (bucket.samples <= 0 || bucket.uptime_ratio == null) return '10%'
|
||||||
|
return `${Math.max(10, Math.round(bucket.uptime_ratio * 100))}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketBarClass(bucket: ProxyNodeMetricsBucket) {
|
||||||
|
if (bucket.samples <= 0) return 'bg-muted'
|
||||||
|
if (bucket.error_events_delta > 0 || bucket.connect_errors_delta > 0) return 'bg-destructive/80'
|
||||||
|
if ((bucket.uptime_ratio ?? 0) < 0.98 || bucket.disconnects_delta > 0) return 'bg-yellow-500/80'
|
||||||
|
return 'bg-primary/80'
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketTitle(bucket: ProxyNodeMetricsBucket) {
|
||||||
|
return [
|
||||||
|
formatBucketTime(bucket.bucket_start),
|
||||||
|
`在线率 ${formatPercent(bucket.uptime_ratio)}`,
|
||||||
|
`RTT ${formatMs(bucket.heartbeat_rtt_ms_avg)}`,
|
||||||
|
`断开 ${formatNumber(bucket.disconnects_delta)}`,
|
||||||
|
`错误 ${formatNumber(bucket.connect_errors_delta + bucket.error_events_delta)}`,
|
||||||
|
].join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucketTime(value: string | null) {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return '-'
|
||||||
|
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimeTone(value: number | null) {
|
||||||
|
if (value == null) return ''
|
||||||
|
if (value < 0.95) return 'text-destructive'
|
||||||
|
if (value < 0.99) return 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
return 'text-primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypeLabel(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'connected': return '连接'
|
||||||
|
case 'disconnected': return '断开'
|
||||||
|
case 'error': return '错误'
|
||||||
|
case 'tunnel_err': return '隧道错误'
|
||||||
|
default: return type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTypeVariant(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'connected': return 'success' as const
|
||||||
|
case 'disconnected':
|
||||||
|
case 'error':
|
||||||
|
case 'tunnel_err':
|
||||||
|
return 'destructive' as const
|
||||||
|
default: return 'secondary' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDetail(event: ProxyNodeEvent) {
|
||||||
|
const metadata = asRecord(event.event_metadata)
|
||||||
|
return event.detail || stringField(metadata, 'message') || stringField(metadata, 'category') || '-'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user