2026-02-07 12:24:42 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="space-y-6 pb-8">
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
variant="default"
|
|
|
|
|
|
class="overflow-hidden"
|
|
|
|
|
|
>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
<!-- 标题和筛选器 -->
|
|
|
|
|
|
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
|
|
|
|
|
|
<!-- 移动端 -->
|
|
|
|
|
|
<div class="flex flex-col gap-3 sm:hidden">
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
代理节点
|
|
|
|
|
|
</h3>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<div class="flex items-center gap-2">
|
2026-03-02 12:58:41 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="h-7 text-xs"
|
|
|
|
|
|
@click="showBatchUpgradeDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
升级
|
|
|
|
|
|
</Button>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 text-xs"
|
2026-05-14 19:35:16 +08:00
|
|
|
|
@click="openAddDialog"
|
2026-02-07 14:30:46 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3 h-3 mr-1" />
|
|
|
|
|
|
添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<RefreshButton
|
|
|
|
|
|
:loading="store.loading"
|
|
|
|
|
|
@click="refresh"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
2026-04-19 13:46:21 +08:00
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
|
|
|
|
<div class="relative min-w-0 basis-full">
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索..."
|
|
|
|
|
|
class="w-full pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
|
<Select v-model="filterStatus">
|
|
|
|
|
|
<SelectTrigger class="w-full h-8 text-xs border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="状态" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="online">
|
|
|
|
|
|
在线
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="offline">
|
|
|
|
|
|
离线
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 桌面端 -->
|
|
|
|
|
|
<div class="hidden sm:flex items-center justify-between gap-4">
|
|
|
|
|
|
<h3 class="text-base font-semibold">
|
|
|
|
|
|
代理节点
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<div class="relative">
|
|
|
|
|
|
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground z-10 pointer-events-none" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="搜索..."
|
|
|
|
|
|
class="w-48 pl-8 pr-3 h-8 text-sm bg-background/50 border-border/60"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-19 13:46:21 +08:00
|
|
|
|
<div class="h-4 w-px bg-border" />
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<div class="xl:hidden">
|
|
|
|
|
|
<Select v-model="filterStatus">
|
|
|
|
|
|
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
|
|
|
|
|
<SelectValue placeholder="全部状态" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">
|
|
|
|
|
|
全部状态
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="online">
|
|
|
|
|
|
在线
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="offline">
|
|
|
|
|
|
离线
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
2026-04-19 13:46:21 +08:00
|
|
|
|
<div class="h-4 w-px bg-border" />
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
2026-03-02 12:58:41 +08:00
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-8 text-xs"
|
|
|
|
|
|
@click="showBatchUpgradeDialog = true"
|
|
|
|
|
|
>
|
|
|
|
|
|
批量升级
|
|
|
|
|
|
</Button>
|
2026-02-11 23:24:59 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
class="h-8 w-8"
|
|
|
|
|
|
title="手动添加"
|
2026-05-14 19:35:16 +08:00
|
|
|
|
@click="openAddDialog"
|
2026-02-11 23:24:59 +08:00
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5" />
|
|
|
|
|
|
</Button>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
<RefreshButton
|
|
|
|
|
|
:loading="store.loading"
|
|
|
|
|
|
@click="refresh"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 桌面端表格 -->
|
|
|
|
|
|
<div class="hidden xl:block overflow-x-auto">
|
|
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow class="border-b border-border/60 hover:bg-transparent">
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<TableHead class="w-[28px] min-w-[28px] max-w-[28px] h-12 p-0 pl-2" />
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<TableHead class="w-[160px] h-12 font-semibold">
|
|
|
|
|
|
名称
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[180px] h-12 font-semibold">
|
|
|
|
|
|
地址
|
|
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[100px] h-12 font-semibold">
|
|
|
|
|
|
区域
|
|
|
|
|
|
</TableHead>
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<SortableTableHead
|
|
|
|
|
|
class="w-[90px] h-12 font-semibold text-center"
|
|
|
|
|
|
column-key="status"
|
|
|
|
|
|
:sortable="false"
|
|
|
|
|
|
align="center"
|
|
|
|
|
|
:filter-active="filterStatus !== 'all'"
|
|
|
|
|
|
filter-title="筛选状态"
|
|
|
|
|
|
filter-content-class="w-36 p-1 rounded-2xl border-border bg-card text-foreground shadow-2xl backdrop-blur-xl"
|
|
|
|
|
|
>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
状态
|
2026-05-01 02:14:49 +08:00
|
|
|
|
<template #filter="{ close }">
|
|
|
|
|
|
<TableFilterMenu
|
|
|
|
|
|
v-model="filterStatus"
|
|
|
|
|
|
:options="proxyNodeStatusFilterOptions"
|
|
|
|
|
|
@select="close"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</SortableTableHead>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<TableHead class="w-[100px] h-12 font-semibold text-center">
|
2026-02-28 14:44:44 +08:00
|
|
|
|
总请求
|
2026-02-08 13:33:08 +08:00
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[100px] h-12 font-semibold text-center">
|
2026-02-28 14:44:44 +08:00
|
|
|
|
失败率
|
2026-02-08 13:33:08 +08:00
|
|
|
|
</TableHead>
|
|
|
|
|
|
<TableHead class="w-[100px] h-12 font-semibold text-center">
|
|
|
|
|
|
延迟
|
|
|
|
|
|
</TableHead>
|
2026-03-02 12:58:41 +08:00
|
|
|
|
<TableHead class="w-[120px] h-12 font-semibold text-center">
|
|
|
|
|
|
版本
|
|
|
|
|
|
</TableHead>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<TableHead class="w-[160px] h-12 font-semibold">
|
|
|
|
|
|
最后心跳
|
|
|
|
|
|
</TableHead>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<TableHead class="w-[140px] h-12 font-semibold text-center">
|
2026-02-08 13:33:08 +08:00
|
|
|
|
操作
|
|
|
|
|
|
</TableHead>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<template
|
2026-02-07 12:24:42 +08:00
|
|
|
|
v-for="node in paginatedNodes"
|
|
|
|
|
|
:key="node.id"
|
|
|
|
|
|
>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<TableRow
|
|
|
|
|
|
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
|
|
|
|
|
|
:class="isNodeExpanded(node.id) ? 'bg-muted/20' : ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
<TableCell class="w-[28px] min-w-[28px] max-w-[28px] p-0 pl-2 text-center">
|
|
|
|
|
|
<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-1"
|
|
|
|
|
|
:title="isNodeExpanded(node.id) ? '收起数据' : '展开数据'"
|
|
|
|
|
|
@click="toggleNodeDetails(node)"
|
2026-02-07 14:30:46 +08:00
|
|
|
|
>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<ChevronDown
|
|
|
|
|
|
v-if="isNodeExpanded(node.id)"
|
|
|
|
|
|
class="h-3.5 w-3.5"
|
2026-02-08 13:33:08 +08:00
|
|
|
|
/>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<ChevronRight
|
2026-02-08 13:33:08 +08:00
|
|
|
|
v-else
|
2026-05-08 23:20:50 +08:00
|
|
|
|
class="h-3.5 w-3.5"
|
2026-02-08 13:33:08 +08:00
|
|
|
|
/>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
<TableCell class="py-4">
|
|
|
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
|
|
|
<span class="text-sm font-semibold">{{ node.name }}</span>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="node.is_manual"
|
|
|
|
|
|
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"
|
2026-02-07 14:30:46 +08:00
|
|
|
|
>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
{{ 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
|
|
|
|
|
|
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>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
<TableRow v-if="paginatedNodes.length === 0">
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<TableCell
|
2026-05-08 23:20:50 +08:00
|
|
|
|
colspan="11"
|
2026-02-08 13:33:08 +08:00
|
|
|
|
class="py-12 text-center text-muted-foreground text-sm"
|
|
|
|
|
|
>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
{{ store.loading ? '加载中...' : '暂无代理节点' }}
|
|
|
|
|
|
</TableCell>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 移动端卡片列表 -->
|
|
|
|
|
|
<div class="xl:hidden divide-y divide-border/40">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="node in paginatedNodes"
|
|
|
|
|
|
:key="node.id"
|
|
|
|
|
|
class="p-4 sm:p-5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="flex items-start justify-between mb-2">
|
|
|
|
|
|
<div>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<div class="flex items-center gap-1.5">
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<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>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<span class="font-semibold text-sm">{{ node.name }}</span>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="node.is_manual"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
手动
|
|
|
|
|
|
</Badge>
|
2026-02-25 21:59:29 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="node.tunnel_mode"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
Tunnel
|
|
|
|
|
|
</Badge>
|
2026-04-12 16:02:38 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
v-if="nodeSchedulingBadge(node)"
|
|
|
|
|
|
:variant="nodeSchedulingBadge(node)!.variant"
|
|
|
|
|
|
class="text-[10px] px-1.5 py-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ nodeSchedulingBadge(node)!.label }}
|
|
|
|
|
|
</Badge>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<HardwareTooltip :node="node" />
|
2026-02-07 14:30:46 +08:00
|
|
|
|
</div>
|
2026-02-25 21:59:29 +08:00
|
|
|
|
<code class="text-xs text-muted-foreground">{{ nodeAddress(node) }}</code>
|
2026-03-02 12:58:41 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!node.is_manual"
|
|
|
|
|
|
class="text-[11px] text-muted-foreground mt-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
版本: {{ nodeProxyVersion(node) }}
|
|
|
|
|
|
</div>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
:variant="statusVariant(node.status)"
|
2026-04-18 21:13:37 +08:00
|
|
|
|
:title="statusTitle(node)"
|
2026-02-08 13:33:08 +08:00
|
|
|
|
class="text-xs"
|
|
|
|
|
|
>
|
2026-04-18 21:13:37 +08:00
|
|
|
|
{{ statusLabel(node) }}
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
2026-02-28 14:44:44 +08:00
|
|
|
|
<div class="grid grid-cols-4 gap-2 text-xs text-muted-foreground mb-3">
|
2026-02-07 12:24:42 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<span class="block text-foreground/60">区域</span>
|
2026-02-11 15:33:01 +08:00
|
|
|
|
<span>{{ formatRegion(node.region) }}</span>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-02-28 14:44:44 +08:00
|
|
|
|
<span class="block text-foreground/60">总请求</span>
|
|
|
|
|
|
<span class="tabular-nums">{{ formatNumber(node.total_requests) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span class="block text-foreground/60">失败率</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="tabular-nums"
|
|
|
|
|
|
:class="failureRate(node) > 5 ? 'text-destructive font-medium' : ''"
|
|
|
|
|
|
>{{ formatFailureRate(node) }}</span>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span class="block text-foreground/60">延迟</span>
|
|
|
|
|
|
<span class="tabular-nums">{{ node.avg_latency_ms != null ? `${node.avg_latency_ms.toFixed(0)}ms` : '-' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex items-center justify-between">
|
|
|
|
|
|
<span class="text-xs text-muted-foreground">{{ formatTime(node.last_heartbeat_at) }}</span>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<div class="flex flex-wrap items-center justify-end gap-1">
|
2026-02-07 19:20:09 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-xs"
|
|
|
|
|
|
:disabled="testingNodes.has(node.id)"
|
|
|
|
|
|
@click="handleTest(node)"
|
|
|
|
|
|
>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<Loader2
|
|
|
|
|
|
v-if="testingNodes.has(node.id)"
|
|
|
|
|
|
class="h-3 w-3 mr-1 animate-spin"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Activity
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="h-3 w-3 mr-1"
|
|
|
|
|
|
/>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
{{ testingNodes.has(node.id) ? '测试中' : '测试' }}
|
|
|
|
|
|
</Button>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="node.is_manual"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-xs"
|
|
|
|
|
|
@click="handleEdit(node)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<SquarePen class="h-3 w-3 mr-1" />
|
|
|
|
|
|
编辑
|
|
|
|
|
|
</Button>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
v-if="!node.is_manual"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-xs"
|
|
|
|
|
|
@click="handleConfig(node)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings class="h-3 w-3 mr-1" />
|
|
|
|
|
|
配置
|
|
|
|
|
|
</Button>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="h-7 px-2 text-xs"
|
|
|
|
|
|
@click="handleDelete(node)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 class="h-3 w-3 mr-1" />
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
2026-05-08 23:20:50 +08:00
|
|
|
|
<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>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="paginatedNodes.length === 0"
|
|
|
|
|
|
class="p-8 text-center text-muted-foreground text-sm"
|
|
|
|
|
|
>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
{{ store.loading ? '加载中...' : '暂无代理节点' }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 分页 -->
|
|
|
|
|
|
<Pagination
|
|
|
|
|
|
:current="currentPage"
|
|
|
|
|
|
:total="filteredNodes.length"
|
|
|
|
|
|
:page-size="pageSize"
|
|
|
|
|
|
cache-key="proxy-nodes-page-size"
|
|
|
|
|
|
@update:current="currentPage = $event"
|
|
|
|
|
|
@update:page-size="pageSize = $event"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<!-- 手动添加/编辑代理节点对话框 -->
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="showAddDialog"
|
2026-05-14 19:35:16 +08:00
|
|
|
|
:title="editingNode ? '编辑代理节点' : '添加代理节点'"
|
2026-05-20 01:02:01 +08:00
|
|
|
|
:description="editingNode ? '修改手动代理节点的配置' : '推荐使用一键脚本部署 aether-tunnel,也可手动添加已有 HTTP/SOCKS 代理'"
|
2026-02-07 14:30:46 +08:00
|
|
|
|
:icon="editingNode ? SquarePen : Plus"
|
2026-05-14 19:35:16 +08:00
|
|
|
|
size="lg"
|
2026-02-07 14:30:46 +08:00
|
|
|
|
@update:model-value="handleDialogClose"
|
|
|
|
|
|
>
|
2026-05-14 19:35:16 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!editingNode"
|
|
|
|
|
|
class="mb-4 grid grid-cols-2 gap-2 rounded-lg border border-border/60 bg-muted/30 p-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:variant="addMode === 'script' ? 'default' : 'ghost'"
|
|
|
|
|
|
class="h-9"
|
|
|
|
|
|
@click="addMode = 'script'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Terminal class="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
|
脚本自动添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:variant="addMode === 'manual' ? 'default' : 'ghost'"
|
|
|
|
|
|
class="h-9"
|
|
|
|
|
|
@click="addMode = 'manual'"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
|
|
|
|
|
手动添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!editingNode && addMode === 'script'"
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs text-muted-foreground">
|
2026-05-15 01:21:31 +08:00
|
|
|
|
输入节点名称后生成一次性安装命令,有效期 15 分钟。复制的命令不含敏感授权信息,只能在目标机器使用一次,避免 Token 暴露在页面或聊天记录中。
|
2026-05-14 19:35:16 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>节点名称 *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="installForm.node_name"
|
|
|
|
|
|
placeholder="例如: jp-proxy-01"
|
|
|
|
|
|
@keyup.enter="refreshProxyInstallCommand"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<Label class="text-sm font-semibold">目标系统</Label>
|
|
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:variant="installSystem === 'unix' ? 'default' : 'outline'"
|
|
|
|
|
|
class="justify-start h-auto py-3"
|
|
|
|
|
|
@click="installSystem = 'unix'"
|
|
|
|
|
|
>
|
|
|
|
|
|
macOS / Linux
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
:variant="installSystem === 'windows' ? 'default' : 'outline'"
|
|
|
|
|
|
class="justify-start h-auto py-3"
|
|
|
|
|
|
@click="installSystem = 'windows'"
|
|
|
|
|
|
>
|
|
|
|
|
|
Windows PowerShell
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
|
<div class="flex items-center justify-between gap-2">
|
|
|
|
|
|
<Label class="text-sm font-semibold">复制到代理节点机器执行</Label>
|
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="gap-1.5"
|
|
|
|
|
|
:disabled="installLoading || !proxyInstallCommand"
|
|
|
|
|
|
@click="copyProxyInstallCommand"
|
|
|
|
|
|
>
|
|
|
|
|
|
<CheckCircle
|
|
|
|
|
|
v-if="installCopied"
|
|
|
|
|
|
class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Copy
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="h-3.5 w-3.5"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{{ installCopied ? '已复制' : '一键复制' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
:disabled="installLoading || !installForm.node_name.trim()"
|
|
|
|
|
|
@click="refreshProxyInstallCommand"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ installLoading ? '生成中...' : (proxyInstallSession ? '重新生成' : '生成命令') }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rounded-lg border border-border/60 bg-background overflow-hidden">
|
|
|
|
|
|
<pre class="max-h-32 overflow-x-auto whitespace-pre-wrap break-all p-3 text-xs font-mono">{{ proxyInstallCommand || '输入节点名称后点击“生成命令”' }}</pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
{{ proxyInstallHint }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-07 14:30:46 +08:00
|
|
|
|
<form
|
2026-05-14 19:35:16 +08:00
|
|
|
|
v-else
|
2026-02-07 14:30:46 +08:00
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
@submit.prevent="handleAddManualNode"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>名称 *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="addForm.name"
|
|
|
|
|
|
placeholder="例如: 美西 VPN 代理"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>代理地址 *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="addForm.proxy_url"
|
|
|
|
|
|
placeholder="http://proxy:port 或 socks5://proxy:port"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>用户名</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="addForm.username"
|
|
|
|
|
|
placeholder="可选"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
data-form-type="other"
|
|
|
|
|
|
data-lpignore="true"
|
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>密码</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="addForm.password"
|
2026-03-19 20:04:56 +08:00
|
|
|
|
type="text"
|
|
|
|
|
|
masked
|
2026-02-07 14:30:46 +08:00
|
|
|
|
placeholder="可选"
|
|
|
|
|
|
autocomplete="new-password"
|
|
|
|
|
|
data-form-type="other"
|
|
|
|
|
|
data-lpignore="true"
|
|
|
|
|
|
data-1p-ignore="true"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>区域</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="addForm.region"
|
|
|
|
|
|
placeholder="可选,例如: US-West"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
2026-05-14 19:35:16 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="!editingNode && addMode === 'script'"
|
|
|
|
|
|
class="flex items-center justify-end gap-2 w-full"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="handleDialogClose(false)"
|
|
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="installLoading || !proxyInstallCommand"
|
|
|
|
|
|
@click="copyProxyInstallCommand"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ installCopied ? '已复制' : '复制命令' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="flex items-center justify-between w-full"
|
|
|
|
|
|
>
|
2026-02-11 23:24:59 +08:00
|
|
|
|
<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>
|
2026-02-07 14:30:46 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
|
<!-- 远程配置对话框 (aether-tunnel 节点) -->
|
2026-02-07 19:20:09 +08:00
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="showConfigDialog"
|
|
|
|
|
|
title="远程配置"
|
2026-05-20 01:02:01 +08:00
|
|
|
|
description="修改后将在下次心跳时自动下发到 aether-tunnel 节点"
|
2026-02-07 19:20:09 +08:00
|
|
|
|
:icon="Settings"
|
|
|
|
|
|
size="md"
|
|
|
|
|
|
@update:model-value="handleConfigDialogClose"
|
|
|
|
|
|
>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<form
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
@submit.prevent
|
|
|
|
|
|
>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>允许的端口</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="configForm.allowed_ports"
|
|
|
|
|
|
placeholder="80, 443, 8080, 8443"
|
|
|
|
|
|
/>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
逗号分隔的目标端口白名单
|
|
|
|
|
|
</p>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>日志级别</Label>
|
|
|
|
|
|
<Select v-model="configForm.log_level">
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<SelectItem value="trace">
|
|
|
|
|
|
trace
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="debug">
|
|
|
|
|
|
debug
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="info">
|
|
|
|
|
|
info
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="warn">
|
|
|
|
|
|
warn
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="error">
|
|
|
|
|
|
error
|
|
|
|
|
|
</SelectItem>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>心跳间隔 (秒)</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="configForm.heartbeat_interval"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="5"
|
|
|
|
|
|
max="600"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-12 16:02:38 +08:00
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>接单状态</Label>
|
|
|
|
|
|
<Select v-model="configForm.scheduling_state">
|
|
|
|
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="active">
|
|
|
|
|
|
active
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="draining">
|
|
|
|
|
|
draining
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
<SelectItem value="cordoned">
|
|
|
|
|
|
cordoned
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
draining/cordoned 都会停止新隧道请求;draining 用于排空,cordoned 用于人工隔离。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
</div>
|
2026-03-02 12:58:41 +08:00
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>升级到版本</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="configForm.upgrade_to"
|
|
|
|
|
|
placeholder="例如 0.2.3"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="text-xs text-muted-foreground">
|
|
|
|
|
|
留空可清除已有升级指令
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-02-08 13:33:08 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="configNode"
|
|
|
|
|
|
class="text-xs text-muted-foreground"
|
|
|
|
|
|
>
|
2026-02-07 19:20:09 +08:00
|
|
|
|
配置版本: v{{ configNode.config_version }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="handleConfigDialogClose(false)"
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="savingConfig"
|
|
|
|
|
|
@click="handleSaveConfig"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ savingConfig ? '保存中...' : '保存' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
2026-02-28 13:52:32 +08:00
|
|
|
|
|
2026-03-02 12:58:41 +08:00
|
|
|
|
<!-- 批量升级对话框 -->
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:model-value="showBatchUpgradeDialog"
|
|
|
|
|
|
title="批量升级"
|
2026-04-19 13:38:59 +08:00
|
|
|
|
description="给所有 tunnel 节点写入升级目标,节点会在下次心跳自动领取"
|
2026-03-02 12:58:41 +08:00
|
|
|
|
:icon="Settings"
|
|
|
|
|
|
size="sm"
|
2026-04-12 16:02:38 +08:00
|
|
|
|
@update:model-value="(open: boolean) => { if (!open) { resetBatchUpgradeDialog() } }"
|
2026-03-02 12:58:41 +08:00
|
|
|
|
>
|
|
|
|
|
|
<form
|
|
|
|
|
|
class="space-y-4"
|
|
|
|
|
|
@submit.prevent="handleBatchUpgrade"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-1.5">
|
|
|
|
|
|
<Label>目标版本</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
v-model="batchUpgradeVersion"
|
|
|
|
|
|
placeholder="例如 0.2.3"
|
|
|
|
|
|
/>
|
2026-04-12 16:02:38 +08:00
|
|
|
|
<p class="text-xs text-muted-foreground">
|
2026-04-19 13:38:59 +08:00
|
|
|
|
gateway 只会写入 `upgrade_to` 目标版本,不再维护分波 rollout 或确认状态。
|
2026-04-12 16:02:38 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-03-02 12:58:41 +08:00
|
|
|
|
</form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
2026-04-12 16:02:38 +08:00
|
|
|
|
@click="resetBatchUpgradeDialog()"
|
2026-03-02 12:58:41 +08:00
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
:disabled="batchUpgrading || !batchUpgradeVersion.trim()"
|
|
|
|
|
|
@click="handleBatchUpgrade"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ batchUpgrading ? '下发中...' : '确认下发' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
2026-02-28 13:52:32 +08:00
|
|
|
|
<!-- 连接事件对话框 -->
|
|
|
|
|
|
<Dialog
|
|
|
|
|
|
:open="showEventsDialog"
|
|
|
|
|
|
title="连接事件"
|
|
|
|
|
|
:description="eventsNode ? `${eventsNode.name} 的连接历史` : ''"
|
|
|
|
|
|
size="lg"
|
|
|
|
|
|
@update:open="(v: boolean) => { if (!v) { showEventsDialog = false; eventsNode = null; nodeEvents = [] } }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
|
<!-- 可靠性指标摘要 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="eventsNode"
|
|
|
|
|
|
class="grid grid-cols-3 gap-3 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
|
|
|
|
|
<span class="block text-foreground/60 text-xs">失败请求</span>
|
|
|
|
|
|
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.failed_requests || 0) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
|
|
|
|
|
<span class="block text-foreground/60 text-xs">DNS 失败</span>
|
|
|
|
|
|
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.dns_failures || 0) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="bg-muted/40 rounded-lg px-3 py-2 text-center">
|
|
|
|
|
|
<span class="block text-foreground/60 text-xs">流错误</span>
|
|
|
|
|
|
<span class="tabular-nums font-medium">{{ formatNumber(eventsNode.stream_errors || 0) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 事件列表 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="loadingEvents"
|
|
|
|
|
|
class="py-8 text-center text-muted-foreground text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
加载中...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="nodeEvents.length === 0"
|
|
|
|
|
|
class="py-8 text-center text-muted-foreground text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
暂无连接事件记录
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="max-h-80 overflow-y-auto space-y-1.5"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="event in nodeEvents"
|
|
|
|
|
|
:key="event.id"
|
|
|
|
|
|
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<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">{{ event.detail || '-' }}</span>
|
|
|
|
|
|
<span class="text-xs text-muted-foreground/70 tabular-nums shrink-0">{{ formatTime(event.created_at) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
@click="showEventsDialog = false; eventsNode = null; nodeEvents = []"
|
|
|
|
|
|
>
|
|
|
|
|
|
关闭
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</Dialog>
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-14 19:35:16 +08:00
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
2026-02-07 12:24:42 +08:00
|
|
|
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
|
|
|
|
|
import { useToast } from '@/composables/useToast'
|
|
|
|
|
|
import { useConfirm } from '@/composables/useConfirm'
|
2026-05-14 19:35:16 +08:00
|
|
|
|
import { useClipboard } from '@/composables/useClipboard'
|
2026-04-12 16:02:38 +08:00
|
|
|
|
import {
|
|
|
|
|
|
proxyNodesApi,
|
|
|
|
|
|
type ProxyNode,
|
|
|
|
|
|
type ProxyNodeEvent,
|
2026-05-14 19:35:16 +08:00
|
|
|
|
type ProxyNodeInstallSession,
|
2026-05-08 23:20:50 +08:00
|
|
|
|
type ProxyNodeMetricsResponse,
|
2026-04-12 16:02:38 +08:00
|
|
|
|
type ProxyNodeRemoteConfig,
|
|
|
|
|
|
type ProxyNodeSchedulingState,
|
2026-04-14 22:51:02 +08:00
|
|
|
|
type ProxyNodeTestResult,
|
2026-04-12 16:02:38 +08:00
|
|
|
|
} from '@/api/proxy-nodes'
|
2026-02-07 12:24:42 +08:00
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Input,
|
2026-02-07 14:30:46 +08:00
|
|
|
|
Label,
|
2026-02-07 12:24:42 +08:00
|
|
|
|
Select,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
TableHeader,
|
|
|
|
|
|
TableBody,
|
|
|
|
|
|
TableRow,
|
|
|
|
|
|
TableHead,
|
2026-05-01 02:14:49 +08:00
|
|
|
|
SortableTableHead,
|
|
|
|
|
|
TableFilterMenu,
|
2026-02-07 12:24:42 +08:00
|
|
|
|
TableCell,
|
|
|
|
|
|
Pagination,
|
|
|
|
|
|
RefreshButton,
|
2026-02-07 14:30:46 +08:00
|
|
|
|
Dialog,
|
2026-02-07 12:24:42 +08:00
|
|
|
|
} from '@/components/ui'
|
|
|
|
|
|
|
2026-05-14 19:35:16 +08:00
|
|
|
|
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, ChevronDown, ChevronRight, Terminal, Copy, CheckCircle } from 'lucide-vue-next'
|
2026-02-22 00:43:41 +08:00
|
|
|
|
import { parseApiError } from '@/utils/errorParser'
|
2026-02-11 15:33:01 +08:00
|
|
|
|
import { formatRegion } from '@/utils/region'
|
2026-02-08 13:33:08 +08:00
|
|
|
|
import HardwareTooltip from './components/HardwareTooltip.vue'
|
2026-05-08 23:20:50 +08:00
|
|
|
|
import ProxyNodeDataPanel from './components/ProxyNodeDataPanel.vue'
|
2026-02-07 12:24:42 +08:00
|
|
|
|
|
|
|
|
|
|
const { success, error: toastError } = useToast()
|
2026-04-19 13:46:21 +08:00
|
|
|
|
const { confirmDanger } = useConfirm()
|
2026-05-14 19:35:16 +08:00
|
|
|
|
const { copyToClipboard } = useClipboard()
|
2026-02-07 12:24:42 +08:00
|
|
|
|
const store = useProxyNodesStore()
|
|
|
|
|
|
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const filterStatus = ref('all')
|
2026-05-01 02:14:49 +08:00
|
|
|
|
const proxyNodeStatusFilterOptions = [
|
|
|
|
|
|
{ value: 'all', label: '全部状态' },
|
|
|
|
|
|
{ value: 'online', label: '在线' },
|
|
|
|
|
|
{ value: 'offline', label: '离线' },
|
|
|
|
|
|
]
|
2026-02-07 12:24:42 +08:00
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
|
const pageSize = ref(20)
|
|
|
|
|
|
|
2026-02-07 14:30:46 +08:00
|
|
|
|
// 手动添加/编辑对话框
|
|
|
|
|
|
const showAddDialog = ref(false)
|
|
|
|
|
|
const addingNode = ref(false)
|
|
|
|
|
|
const editingNode = ref<ProxyNode | null>(null)
|
2026-05-14 19:35:16 +08:00
|
|
|
|
const addMode = ref<'script' | 'manual'>('script')
|
2026-02-07 14:30:46 +08:00
|
|
|
|
const addForm = ref({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
proxy_url: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
region: '',
|
|
|
|
|
|
})
|
2026-05-14 19:35:16 +08:00
|
|
|
|
const installForm = ref({
|
|
|
|
|
|
node_name: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
const installSystem = ref<'unix' | 'windows'>('unix')
|
|
|
|
|
|
const installLoading = ref(false)
|
|
|
|
|
|
const installCopied = ref(false)
|
|
|
|
|
|
const proxyInstallSession = ref<ProxyNodeInstallSession | null>(null)
|
|
|
|
|
|
let installCopiedResetTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
const proxyInstallCommand = computed(() => {
|
|
|
|
|
|
if (!proxyInstallSession.value) return ''
|
|
|
|
|
|
return installSystem.value === 'windows'
|
|
|
|
|
|
? proxyInstallSession.value.powershell_command
|
|
|
|
|
|
: proxyInstallSession.value.unix_command
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const proxyInstallHint = computed(() => {
|
|
|
|
|
|
if (!proxyInstallSession.value) {
|
2026-05-15 01:21:31 +08:00
|
|
|
|
return '脚本会自动安装或更新代理程序,并保留已有配置。'
|
2026-05-14 19:35:16 +08:00
|
|
|
|
}
|
2026-05-15 01:21:31 +08:00
|
|
|
|
return `这条命令将在 ${Math.floor(proxyInstallSession.value.expires_in_seconds / 60)} 分钟内有效,成功使用后立即失效。`
|
2026-05-14 19:35:16 +08:00
|
|
|
|
})
|
2026-02-07 14:30:46 +08:00
|
|
|
|
|
2026-05-20 01:02:01 +08:00
|
|
|
|
// 远程配置对话框 (aether-tunnel 节点)
|
2026-02-07 19:20:09 +08:00
|
|
|
|
const showConfigDialog = ref(false)
|
|
|
|
|
|
const savingConfig = ref(false)
|
|
|
|
|
|
const configNode = ref<ProxyNode | null>(null)
|
|
|
|
|
|
const configForm = ref({
|
|
|
|
|
|
allowed_ports: '',
|
|
|
|
|
|
log_level: 'info',
|
|
|
|
|
|
heartbeat_interval: '30',
|
2026-04-12 16:02:38 +08:00
|
|
|
|
scheduling_state: 'active' as ProxyNodeSchedulingState,
|
2026-03-02 12:58:41 +08:00
|
|
|
|
upgrade_to: '',
|
2026-02-07 19:20:09 +08:00
|
|
|
|
})
|
2026-03-02 12:58:41 +08:00
|
|
|
|
const showBatchUpgradeDialog = ref(false)
|
|
|
|
|
|
const batchUpgradeVersion = ref('')
|
|
|
|
|
|
const batchUpgrading = ref(false)
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
2026-02-28 13:52:32 +08:00
|
|
|
|
// 连接事件对话框
|
|
|
|
|
|
const showEventsDialog = ref(false)
|
|
|
|
|
|
const eventsNode = ref<ProxyNode | null>(null)
|
|
|
|
|
|
const nodeEvents = ref<ProxyNodeEvent[]>([])
|
|
|
|
|
|
const loadingEvents = ref(false)
|
|
|
|
|
|
|
2026-05-08 23:20:50 +08:00
|
|
|
|
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>>({})
|
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
|
// 测试连通性
|
|
|
|
|
|
const testingNodes = ref(new Set<string>())
|
2026-02-11 23:24:59 +08:00
|
|
|
|
const testingUrl = ref(false)
|
2026-02-07 19:20:09 +08:00
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
const filteredNodes = computed(() => {
|
|
|
|
|
|
let filtered = [...store.nodes]
|
|
|
|
|
|
|
|
|
|
|
|
if (searchQuery.value) {
|
|
|
|
|
|
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
|
|
|
|
|
filtered = filtered.filter(node => {
|
|
|
|
|
|
const text = `${node.name} ${node.ip} ${node.region || ''}`.toLowerCase()
|
|
|
|
|
|
return keywords.every(kw => text.includes(kw))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (filterStatus.value !== 'all') {
|
|
|
|
|
|
filtered = filtered.filter(node => node.status === filterStatus.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return filtered
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const paginatedNodes = computed(() => {
|
|
|
|
|
|
const start = (currentPage.value - 1) * pageSize.value
|
|
|
|
|
|
return filteredNodes.value.slice(start, start + pageSize.value)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-19 13:38:59 +08:00
|
|
|
|
watch([searchQuery, filterStatus], () => {
|
2026-02-07 12:24:42 +08:00
|
|
|
|
currentPage.value = 1
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-14 19:35:16 +08:00
|
|
|
|
watch(() => installForm.value.node_name, () => {
|
|
|
|
|
|
resetProxyInstallState()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(installSystem, () => {
|
|
|
|
|
|
installCopied.value = false
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await store.fetchNodes()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-14 19:35:16 +08:00
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
async function refresh() {
|
|
|
|
|
|
await store.fetchNodes()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:51:02 +08:00
|
|
|
|
function formatConnectivityTestParts(result: ProxyNodeTestResult): string[] {
|
2026-04-18 21:13:37 +08:00
|
|
|
|
const parts = [
|
|
|
|
|
|
`探测: ${formatConnectivityProbe(result.probe_url)}`,
|
|
|
|
|
|
`超时: ${result.timeout_secs}s`,
|
|
|
|
|
|
`延迟: ${result.latency_ms != null ? `${result.latency_ms}ms` : '暂无样本'}`,
|
|
|
|
|
|
]
|
2026-04-14 22:51:02 +08:00
|
|
|
|
if (result.exit_ip) parts.push(`出口IP: ${result.exit_ip}`)
|
|
|
|
|
|
return parts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:13:37 +08:00
|
|
|
|
function formatConnectivityProbe(probeUrl: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(probeUrl)
|
|
|
|
|
|
return `${url.host}${url.pathname === '/' ? '' : url.pathname}`
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return probeUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-11 23:24:59 +08:00
|
|
|
|
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) {
|
2026-04-14 22:51:02 +08:00
|
|
|
|
success(`连通性测试通过,${formatConnectivityTestParts(result).join(',')}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
} else {
|
2026-04-18 21:13:37 +08:00
|
|
|
|
toastError(`连通性测试失败(${formatConnectivityTestParts(result).join(',')}): ${result.error || '未知错误'}`)
|
2026-02-11 23:24:59 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '测试请求失败'))
|
2026-02-11 23:24:59 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
testingUrl.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 19:35:16 +08:00
|
|
|
|
function clearInstallCopiedResetTimer() {
|
|
|
|
|
|
if (installCopiedResetTimer) {
|
|
|
|
|
|
clearTimeout(installCopiedResetTimer)
|
|
|
|
|
|
installCopiedResetTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetProxyInstallState() {
|
|
|
|
|
|
proxyInstallSession.value = null
|
|
|
|
|
|
installCopied.value = false
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openAddDialog() {
|
|
|
|
|
|
editingNode.value = null
|
|
|
|
|
|
addMode.value = 'script'
|
|
|
|
|
|
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
|
|
|
|
|
installForm.value = { node_name: '' }
|
|
|
|
|
|
resetProxyInstallState()
|
|
|
|
|
|
showAddDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshProxyInstallCommand() {
|
|
|
|
|
|
const nodeName = installForm.value.node_name.trim()
|
|
|
|
|
|
if (!nodeName || installLoading.value) return
|
|
|
|
|
|
installLoading.value = true
|
|
|
|
|
|
resetProxyInstallState()
|
|
|
|
|
|
try {
|
|
|
|
|
|
proxyInstallSession.value = await store.createInstallSession({ node_name: nodeName })
|
|
|
|
|
|
success('代理节点安装命令已生成')
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '生成代理节点安装命令失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
installLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyProxyInstallCommand() {
|
|
|
|
|
|
if (!proxyInstallCommand.value) return
|
|
|
|
|
|
const copied = await copyToClipboard(proxyInstallCommand.value, false)
|
|
|
|
|
|
if (!copied) return
|
|
|
|
|
|
installCopied.value = true
|
|
|
|
|
|
success('安装命令已复制到剪贴板')
|
|
|
|
|
|
clearInstallCopiedResetTimer()
|
|
|
|
|
|
installCopiedResetTimer = setTimeout(() => {
|
|
|
|
|
|
installCopied.value = false
|
|
|
|
|
|
installCopiedResetTimer = null
|
|
|
|
|
|
}, 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 22:51:02 +08:00
|
|
|
|
async function handleEdit(node: ProxyNode) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { node: detail } = await proxyNodesApi.getNode(node.id)
|
|
|
|
|
|
editingNode.value = detail
|
|
|
|
|
|
addForm.value = {
|
|
|
|
|
|
name: detail.name,
|
|
|
|
|
|
proxy_url: detail.proxy_url || '',
|
|
|
|
|
|
username: detail.proxy_username || '',
|
|
|
|
|
|
password: detail.proxy_password || '',
|
|
|
|
|
|
region: detail.region || '',
|
|
|
|
|
|
}
|
2026-05-14 19:35:16 +08:00
|
|
|
|
addMode.value = 'manual'
|
|
|
|
|
|
resetProxyInstallState()
|
2026-04-14 22:51:02 +08:00
|
|
|
|
showAddDialog.value = true
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '读取代理节点详情失败'))
|
2026-02-07 14:30:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleDialogClose(open: boolean) {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
showAddDialog.value = false
|
|
|
|
|
|
editingNode.value = null
|
2026-05-14 19:35:16 +08:00
|
|
|
|
addMode.value = 'script'
|
2026-02-07 14:30:46 +08:00
|
|
|
|
addForm.value = { name: '', proxy_url: '', username: '', password: '', region: '' }
|
2026-05-14 19:35:16 +08:00
|
|
|
|
installForm.value = { node_name: '' }
|
|
|
|
|
|
resetProxyInstallState()
|
2026-02-07 14:30:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleUpdateManualNode() {
|
|
|
|
|
|
if (!editingNode.value || !addForm.value.name || !addForm.value.proxy_url) return
|
|
|
|
|
|
|
|
|
|
|
|
addingNode.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await proxyNodesApi.updateManualNode(editingNode.value.id, {
|
|
|
|
|
|
name: addForm.value.name,
|
|
|
|
|
|
proxy_url: addForm.value.proxy_url,
|
|
|
|
|
|
username: addForm.value.username || undefined,
|
|
|
|
|
|
// 空密码不发送(保留原值)
|
|
|
|
|
|
password: addForm.value.password || undefined,
|
|
|
|
|
|
region: addForm.value.region || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
success('代理节点已更新')
|
|
|
|
|
|
handleDialogClose(false)
|
|
|
|
|
|
await store.fetchNodes()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '更新失败'))
|
2026-02-07 14:30:46 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
addingNode.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAddManualNode() {
|
|
|
|
|
|
if (!addForm.value.name || !addForm.value.proxy_url) return
|
|
|
|
|
|
|
|
|
|
|
|
addingNode.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await store.createManualNode({
|
|
|
|
|
|
name: addForm.value.name,
|
|
|
|
|
|
proxy_url: addForm.value.proxy_url,
|
|
|
|
|
|
username: addForm.value.username || undefined,
|
|
|
|
|
|
password: addForm.value.password || undefined,
|
|
|
|
|
|
region: addForm.value.region || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
success('代理节点已添加')
|
|
|
|
|
|
handleDialogClose(false)
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '添加失败'))
|
2026-02-07 14:30:46 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
addingNode.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
|
function handleConfig(node: ProxyNode) {
|
|
|
|
|
|
configNode.value = node
|
|
|
|
|
|
const rc: ProxyNodeRemoteConfig = node.remote_config ?? {}
|
|
|
|
|
|
configForm.value = {
|
|
|
|
|
|
allowed_ports: rc.allowed_ports?.join(', ') || '',
|
|
|
|
|
|
log_level: rc.log_level || 'info',
|
|
|
|
|
|
heartbeat_interval: String(rc.heartbeat_interval || node.heartbeat_interval || 30),
|
2026-04-12 16:02:38 +08:00
|
|
|
|
scheduling_state: rc.scheduling_state || 'active',
|
2026-03-02 12:58:41 +08:00
|
|
|
|
upgrade_to: rc.upgrade_to || '',
|
2026-02-07 19:20:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
showConfigDialog.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleConfigDialogClose(open: boolean) {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
showConfigDialog.value = false
|
|
|
|
|
|
configNode.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSaveConfig() {
|
|
|
|
|
|
if (!configNode.value) return
|
|
|
|
|
|
savingConfig.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data: Partial<ProxyNodeRemoteConfig> = {}
|
|
|
|
|
|
const portsInput = configForm.value.allowed_ports.trim()
|
|
|
|
|
|
if (portsInput) {
|
|
|
|
|
|
data.allowed_ports = portsInput
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((s: string) => parseInt(s.trim()))
|
|
|
|
|
|
.filter((n: number) => !isNaN(n) && n >= 1 && n <= 65535)
|
|
|
|
|
|
} else if (configNode.value.remote_config?.allowed_ports) {
|
|
|
|
|
|
// 输入清空 → 显式发送空数组以清除已有端口白名单
|
|
|
|
|
|
data.allowed_ports = []
|
|
|
|
|
|
}
|
|
|
|
|
|
if (configForm.value.log_level) {
|
|
|
|
|
|
data.log_level = configForm.value.log_level
|
|
|
|
|
|
}
|
|
|
|
|
|
const hb = parseInt(configForm.value.heartbeat_interval)
|
|
|
|
|
|
if (!isNaN(hb) && hb >= 5) {
|
|
|
|
|
|
data.heartbeat_interval = hb
|
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
|
data.scheduling_state = configForm.value.scheduling_state
|
2026-03-02 12:58:41 +08:00
|
|
|
|
const targetVersion = configForm.value.upgrade_to.trim()
|
|
|
|
|
|
if (targetVersion) {
|
|
|
|
|
|
data.upgrade_to = targetVersion
|
|
|
|
|
|
} else if (configNode.value.remote_config?.upgrade_to) {
|
|
|
|
|
|
data.upgrade_to = null
|
|
|
|
|
|
}
|
2026-02-07 19:20:09 +08:00
|
|
|
|
await proxyNodesApi.updateNodeConfig(configNode.value.id, data)
|
|
|
|
|
|
success('远程配置已保存,将在下次心跳时生效')
|
|
|
|
|
|
handleConfigDialogClose(false)
|
|
|
|
|
|
await store.fetchNodes()
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '保存失败'))
|
2026-02-07 19:20:09 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
savingConfig.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 12:58:41 +08:00
|
|
|
|
async function handleBatchUpgrade() {
|
|
|
|
|
|
const version = batchUpgradeVersion.value.trim()
|
|
|
|
|
|
if (!version || batchUpgrading.value) return
|
|
|
|
|
|
batchUpgrading.value = true
|
|
|
|
|
|
try {
|
2026-04-19 13:38:59 +08:00
|
|
|
|
const result = await proxyNodesApi.batchUpgrade(version)
|
|
|
|
|
|
if (result.updated > 0) {
|
|
|
|
|
|
success(`已向 ${result.updated} 个节点写入升级目标 ${result.version},${result.skipped} 个节点无需变更`)
|
2026-04-12 16:02:38 +08:00
|
|
|
|
} else {
|
2026-04-19 13:38:59 +08:00
|
|
|
|
success(`当前没有需要变更的 tunnel 节点,目标版本仍为 ${result.version}`)
|
2026-04-12 16:02:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
resetBatchUpgradeDialog()
|
2026-03-02 12:58:41 +08:00
|
|
|
|
await store.fetchNodes()
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '批量升级下发失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
batchUpgrading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 16:02:38 +08:00
|
|
|
|
function resetBatchUpgradeDialog() {
|
|
|
|
|
|
showBatchUpgradeDialog.value = false
|
|
|
|
|
|
batchUpgradeVersion.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
async function handleDelete(node: ProxyNode) {
|
|
|
|
|
|
const confirmed = await confirmDanger(
|
2026-02-25 21:59:29 +08:00
|
|
|
|
`确定要删除代理节点 "${node.name}" (${node.tunnel_mode ? node.ip : `${node.ip}:${node.port}`}) 吗?`,
|
2026-02-07 12:24:42 +08:00
|
|
|
|
'删除节点'
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!confirmed) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-07 14:30:46 +08:00
|
|
|
|
const result = await proxyNodesApi.deleteProxyNode(node.id)
|
2026-04-12 16:02:38 +08:00
|
|
|
|
await store.fetchNodes()
|
2026-02-07 14:30:46 +08:00
|
|
|
|
if (result.cleared_system_proxy) {
|
|
|
|
|
|
success('代理节点已删除,系统默认代理已自动清除')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
success('代理节点已删除')
|
|
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '删除失败'))
|
2026-02-07 12:24:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 19:20:09 +08:00
|
|
|
|
async function handleTest(node: ProxyNode) {
|
|
|
|
|
|
if (testingNodes.value.has(node.id)) return
|
|
|
|
|
|
|
|
|
|
|
|
testingNodes.value.add(node.id)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await proxyNodesApi.testNode(node.id)
|
|
|
|
|
|
if (result.success) {
|
2026-04-14 22:51:02 +08:00
|
|
|
|
success(`连通性测试通过,${formatConnectivityTestParts(result).join(',')}`)
|
2026-02-07 19:20:09 +08:00
|
|
|
|
} else {
|
2026-04-18 21:13:37 +08:00
|
|
|
|
toastError(`连通性测试失败(${formatConnectivityTestParts(result).join(',')}): ${result.error || '未知错误'}`)
|
2026-02-07 19:20:09 +08:00
|
|
|
|
}
|
2026-02-22 00:43:41 +08:00
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '测试请求失败'))
|
2026-02-07 19:20:09 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
testingNodes.value.delete(node.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-08 23:20:50 +08:00
|
|
|
|
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, '加载节点数据失败'),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 13:52:32 +08:00
|
|
|
|
async function handleViewEvents(node: ProxyNode) {
|
|
|
|
|
|
eventsNode.value = node
|
|
|
|
|
|
showEventsDialog.value = true
|
|
|
|
|
|
loadingEvents.value = true
|
|
|
|
|
|
try {
|
2026-05-08 23:20:50 +08:00
|
|
|
|
const res = await proxyNodesApi.listNodeEvents(node.id, { limit: 50 })
|
2026-02-28 13:52:32 +08:00
|
|
|
|
nodeEvents.value = res.items
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
toastError(parseApiError(err, '加载事件失败'))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingEvents.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventTypeLabel(type: string) {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'connected': return '连接'
|
|
|
|
|
|
case 'disconnected': return '断开'
|
|
|
|
|
|
case 'error': return '错误'
|
|
|
|
|
|
default: return type
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function eventTypeVariant(type: string) {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'connected': return 'success' as const
|
|
|
|
|
|
case 'disconnected': return 'destructive' as const
|
|
|
|
|
|
case 'error': return 'destructive' as const
|
|
|
|
|
|
default: return 'secondary' as const
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-07 12:24:42 +08:00
|
|
|
|
function statusVariant(status: string) {
|
|
|
|
|
|
switch (status) {
|
|
|
|
|
|
case 'online': return 'success' as const
|
|
|
|
|
|
case 'offline': return 'destructive' as const
|
|
|
|
|
|
default: return 'secondary' as const
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 21:13:37 +08:00
|
|
|
|
function statusLabel(node: ProxyNode) {
|
|
|
|
|
|
if (node.tunnel_mode && !node.is_manual) {
|
|
|
|
|
|
switch (node.status) {
|
|
|
|
|
|
case 'online': return '隧道在线'
|
|
|
|
|
|
case 'offline': return '隧道离线'
|
|
|
|
|
|
default: return node.status
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (node.status) {
|
2026-02-07 12:24:42 +08:00
|
|
|
|
case 'online': return '在线'
|
|
|
|
|
|
case 'offline': return '离线'
|
2026-04-18 21:13:37 +08:00
|
|
|
|
default: return node.status
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function statusTitle(node: ProxyNode) {
|
|
|
|
|
|
if (node.tunnel_mode && !node.is_manual) {
|
|
|
|
|
|
if (node.status === 'online') {
|
|
|
|
|
|
return '表示 gateway 仍能看到 tunnel/heartbeat,不代表默认探测站点一定可达'
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'gateway 当前未检测到可用 tunnel 连接'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch (node.status) {
|
|
|
|
|
|
case 'online': return '节点当前被标记为在线'
|
|
|
|
|
|
case 'offline': return '节点当前被标记为离线'
|
|
|
|
|
|
default: return node.status
|
2026-02-07 12:24:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatNumber(n: number) {
|
|
|
|
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
|
|
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
|
|
|
|
return String(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(iso: string | null) {
|
|
|
|
|
|
if (!iso) return '-'
|
|
|
|
|
|
const d = new Date(iso)
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
const diff = (now.getTime() - d.getTime()) / 1000
|
|
|
|
|
|
if (diff < 60) return '刚刚'
|
|
|
|
|
|
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`
|
|
|
|
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`
|
|
|
|
|
|
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
|
|
|
|
}
|
2026-02-25 21:59:29 +08:00
|
|
|
|
|
2026-02-28 14:44:44 +08:00
|
|
|
|
function failureRate(node: ProxyNode) {
|
|
|
|
|
|
if (!node.total_requests) return 0
|
|
|
|
|
|
const failed = (node.failed_requests || 0) + (node.dns_failures || 0) + (node.stream_errors || 0)
|
|
|
|
|
|
return (failed / node.total_requests) * 100
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatFailureRate(node: ProxyNode) {
|
|
|
|
|
|
if (!node.total_requests) return '-'
|
|
|
|
|
|
const rate = failureRate(node)
|
|
|
|
|
|
if (rate === 0) return '0%'
|
|
|
|
|
|
if (rate < 0.1) return '<0.1%'
|
|
|
|
|
|
return `${rate.toFixed(1)}%`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 21:59:29 +08:00
|
|
|
|
function nodeAddress(node: ProxyNode) {
|
|
|
|
|
|
if (node.is_manual) return node.proxy_url || `${node.ip}:${node.port}`
|
|
|
|
|
|
if (node.tunnel_mode) return node.ip || 'WebSocket Tunnel'
|
|
|
|
|
|
return `${node.ip}:${node.port}`
|
|
|
|
|
|
}
|
2026-03-02 12:58:41 +08:00
|
|
|
|
|
|
|
|
|
|
function nodeProxyVersion(node: ProxyNode) {
|
|
|
|
|
|
const metadata = node.proxy_metadata
|
|
|
|
|
|
if (!metadata || typeof metadata !== 'object') return '-'
|
|
|
|
|
|
const version = (metadata as Record<string, unknown>).version
|
|
|
|
|
|
if (typeof version !== 'string') return '-'
|
|
|
|
|
|
const normalized = version.trim()
|
|
|
|
|
|
return normalized || '-'
|
|
|
|
|
|
}
|
2026-04-12 16:02:38 +08:00
|
|
|
|
|
|
|
|
|
|
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
|
|
|
|
|
|
|
|
|
|
|
|
function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
|
|
|
|
|
|
switch (node.remote_config?.scheduling_state) {
|
|
|
|
|
|
case 'draining':
|
|
|
|
|
|
return { label: '排空中', variant: 'warning' }
|
|
|
|
|
|
case 'cordoned':
|
|
|
|
|
|
return { label: '已封锁', variant: 'dark' }
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-07 12:24:42 +08:00
|
|
|
|
</script>
|