refactor(proxy-nodes): 简化批量升级为直接写入 upgrade_to 目标

- 后端移除分波 rollout/探测逻辑,改为一次性给所有合格 tunnel 节点写入升级目标,并自动取消活动中的 rollout
- 前端移除 rollout 进度、阶段筛选、重试/跳过/冲突清理等相关 UI 和交互
- 同步更新批量升级接口类型与测试用例
This commit is contained in:
fawney19
2026-04-19 13:38:59 +08:00
parent e6fd95453d
commit 7007d7a556
4 changed files with 140 additions and 949 deletions

View File

@@ -126,18 +126,11 @@ export interface ProxyNodeTestResult {
export interface ProxyNodeBatchUpgradeResult {
version: string
batch_size: number
cooldown_secs: number
probe_url?: string | null
probe_timeout_secs?: number | null
eligible_total: number
updated: number
skipped: number
node_ids: string[]
blocked: boolean
pending_node_ids: string[]
rollout_active: boolean
completed: number
remaining: number
rollout_cancelled: boolean
}
export interface ProxyNodeUpgradeRolloutCancelResult {

View File

@@ -63,25 +63,6 @@
</SelectContent>
</Select>
</div>
<div class="min-w-0 flex-1">
<Select
v-model="filterRolloutStage"
:disabled="!store.rollout"
>
<SelectTrigger class="w-full h-8 text-xs border-border/60">
<SelectValue placeholder="阶段" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in rolloutStageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
@@ -117,23 +98,6 @@
</SelectItem>
</SelectContent>
</Select>
<Select
v-model="filterRolloutStage"
:disabled="!store.rollout"
>
<SelectTrigger class="w-36 h-8 text-xs border-border/60">
<SelectValue placeholder="全部阶段" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in rolloutStageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="h-4 w-px bg-border" />
<Button
variant="outline"
@@ -160,305 +124,6 @@
</div>
</div>
<div
v-if="store.rollout"
class="px-4 sm:px-6 py-4 border-b border-border/60 bg-muted/15"
>
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Badge
:variant="store.rollout.blocked ? 'warning' : 'success'"
class="text-[11px] px-2 py-0.5"
>
{{ store.rollout.blocked ? 'Rollout 等待确认' : 'Rollout 推进中' }}
</Badge>
<span class="text-sm font-semibold">目标版本 {{ store.rollout.version }}</span>
<span class="text-xs text-muted-foreground">
每波 {{ store.rollout.batch_size }} 个节点 · 冷却 {{ formatDurationShort(store.rollout.cooldown_secs) }}
</span>
<Badge
v-if="filterRolloutStage !== 'all'"
variant="outline"
class="text-[11px] px-2 py-0.5"
>
筛选: {{ activeRolloutStageLabel }}
</Badge>
<Button
v-if="filterRolloutStage !== 'all'"
variant="ghost"
size="sm"
class="h-7 px-2 text-[11px]"
@click="filterRolloutStage = 'all'"
>
清除阶段筛选
</Button>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div class="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
已完成
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ store.rollout.completed_node_ids.length }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
等待确认
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ store.rollout.pending_node_ids.length }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
冲突节点
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ store.rollout.conflict_node_ids.length }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
已跳过
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ store.rollout.skipped_node_ids.length }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<div class="text-[11px] text-muted-foreground">
在线可升级
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ store.rollout.online_eligible_total }}
</div>
</div>
</div>
<div
v-if="rolloutPendingPreview.length > 0"
class="space-y-2"
>
<div class="text-xs font-medium text-foreground/80">
当前卡点
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="entry in rolloutPendingPreview"
:key="entry.node_id"
class="flex items-center gap-2 rounded-full border border-border/60 bg-background/90 px-3 py-1 text-xs"
>
<span class="font-medium">{{ entry.name }}</span>
<Badge
:variant="entry.variant"
class="px-1.5 py-0 text-[10px]"
>
{{ entry.label }}
</Badge>
<Button
v-if="canRetryUpgradeNode(entry.node)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px]"
:disabled="isUpgradeNodeActioning(entry.node_id)"
@click="handleRetryUpgradeNode(entry.node)"
>
<Loader2
v-if="isUpgradeNodeActioning(entry.node_id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<RotateCcw
v-else
class="h-3 w-3 mr-1"
/>
重试
</Button>
<Button
v-if="canSkipUpgradeNode(entry.node)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px]"
:disabled="isUpgradeNodeActioning(entry.node_id)"
@click="handleSkipUpgradeNode(entry.node)"
>
<Loader2
v-if="isUpgradeNodeActioning(entry.node_id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<SkipForward
v-else
class="h-3 w-3 mr-1"
/>
跳过
</Button>
</div>
</div>
<p
v-if="hiddenPendingNodeCount > 0"
class="text-xs text-muted-foreground"
>
还有 {{ hiddenPendingNodeCount }} 个节点处于等待状态
</p>
</div>
<div
v-if="rolloutConflictPreview.length > 0"
class="space-y-2"
>
<div class="text-xs font-medium text-foreground/80">
冲突节点
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="entry in rolloutConflictPreview"
:key="entry.node_id"
class="flex items-center gap-2 rounded-full border border-border/60 bg-background/90 px-3 py-1 text-xs"
>
<span class="font-medium">{{ entry.name }}</span>
<Badge
variant="destructive"
class="px-1.5 py-0 text-[10px]"
>
版本冲突
</Badge>
<Button
v-if="canRetryUpgradeNode(entry.node)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px]"
:disabled="isUpgradeNodeActioning(entry.node_id)"
@click="handleRetryUpgradeNode(entry.node)"
>
<Loader2
v-if="isUpgradeNodeActioning(entry.node_id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<RotateCcw
v-else
class="h-3 w-3 mr-1"
/>
对齐版本
</Button>
<Button
v-if="canSkipUpgradeNode(entry.node)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px]"
:disabled="isUpgradeNodeActioning(entry.node_id)"
@click="handleSkipUpgradeNode(entry.node)"
>
<Loader2
v-if="isUpgradeNodeActioning(entry.node_id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<SkipForward
v-else
class="h-3 w-3 mr-1"
/>
跳过
</Button>
</div>
</div>
<p
v-if="hiddenConflictNodeCount > 0"
class="text-xs text-muted-foreground"
>
还有 {{ hiddenConflictNodeCount }} 个节点存在版本冲突
</p>
</div>
<div
v-if="rolloutSkippedPreview.length > 0"
class="space-y-2"
>
<div class="text-xs font-medium text-foreground/80">
已跳过节点
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="entry in rolloutSkippedPreview"
:key="entry.node_id"
class="flex items-center gap-2 rounded-full border border-border/60 bg-background/90 px-3 py-1 text-xs"
>
<span class="font-medium">{{ entry.name }}</span>
<Badge
variant="outline"
class="px-1.5 py-0 text-[10px]"
>
已跳过
</Badge>
<Button
v-if="canRetryUpgradeNode(entry.node)"
variant="ghost"
size="sm"
class="h-6 px-2 text-[11px]"
:disabled="isUpgradeNodeActioning(entry.node_id)"
@click="handleRetryUpgradeNode(entry.node)"
>
<Loader2
v-if="isUpgradeNodeActioning(entry.node_id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<RotateCcw
v-else
class="h-3 w-3 mr-1"
/>
恢复
</Button>
</div>
</div>
<p
v-if="hiddenSkippedNodeCount > 0"
class="text-xs text-muted-foreground"
>
还有 {{ hiddenSkippedNodeCount }} 个节点已被人工跳过
</p>
</div>
</div>
<div class="space-y-1 text-xs text-muted-foreground xl:text-right">
<div class="flex items-center justify-start gap-2 xl:justify-end">
<Button
v-if="store.rollout.skipped_node_ids.length > 0"
variant="outline"
size="sm"
class="h-8 text-xs"
:disabled="restoringSkippedUpgradeNodes || clearingUpgradeConflicts || cancellingUpgradeRollout"
@click="handleRestoreSkippedUpgradeNodes"
>
{{ restoringSkippedUpgradeNodes ? '恢复中...' : '恢复已跳过' }}
</Button>
<Button
v-if="store.rollout.conflict_node_ids.length > 0"
variant="outline"
size="sm"
class="h-8 text-xs"
:disabled="clearingUpgradeConflicts || cancellingUpgradeRollout || restoringSkippedUpgradeNodes"
@click="handleClearUpgradeConflicts"
>
{{ clearingUpgradeConflicts ? '清理中...' : '清空冲突' }}
</Button>
<Button
variant="destructive"
size="sm"
class="h-8 text-xs"
:disabled="cancellingUpgradeRollout || clearingUpgradeConflicts || restoringSkippedUpgradeNodes"
@click="handleCancelUpgradeRollout"
>
{{ cancellingUpgradeRollout ? '取消中...' : '取消 Rollout' }}
</Button>
</div>
<div>开始时间: {{ formatTime(store.rollout.started_at) }}</div>
<div>最近放量: {{ formatTime(store.rollout.last_dispatched_at) }}</div>
<div>最近更新: {{ formatTime(store.rollout.updated_at) }}</div>
<div v-if="store.rollout.probe">
主动探测: GET {{ store.rollout.probe.url }} · {{ store.rollout.probe.timeout_secs }}s
</div>
<div v-else>
主动探测: 未配置等待真实流量确认
</div>
</div>
</div>
</div>
<!-- 桌面端表格 -->
<div class="hidden xl:block overflow-x-auto">
<Table>
@@ -618,42 +283,6 @@
>
<History class="h-4 w-4" />
</Button>
<Button
v-if="canRetryUpgradeNode(node)"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="isUpgradeNodeActioning(node.id) ? '处理中...' : '重试当前节点升级'"
:disabled="isUpgradeNodeActioning(node.id)"
@click="handleRetryUpgradeNode(node)"
>
<Loader2
v-if="isUpgradeNodeActioning(node.id)"
class="h-4 w-4 animate-spin"
/>
<RotateCcw
v-else
class="h-4 w-4"
/>
</Button>
<Button
v-if="canSkipUpgradeNode(node)"
variant="ghost"
size="icon"
class="h-8 w-8"
:title="isUpgradeNodeActioning(node.id) ? '处理中...' : '跳过当前节点'"
:disabled="isUpgradeNodeActioning(node.id)"
@click="handleSkipUpgradeNode(node)"
>
<Loader2
v-if="isUpgradeNodeActioning(node.id)"
class="h-4 w-4 animate-spin"
/>
<SkipForward
v-else
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
@@ -800,42 +429,6 @@
<Settings class="h-3 w-3 mr-1" />
配置
</Button>
<Button
v-if="canRetryUpgradeNode(node)"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="isUpgradeNodeActioning(node.id)"
@click="handleRetryUpgradeNode(node)"
>
<Loader2
v-if="isUpgradeNodeActioning(node.id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<RotateCcw
v-else
class="h-3 w-3 mr-1"
/>
重试
</Button>
<Button
v-if="canSkipUpgradeNode(node)"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="isUpgradeNodeActioning(node.id)"
@click="handleSkipUpgradeNode(node)"
>
<Loader2
v-if="isUpgradeNodeActioning(node.id)"
class="h-3 w-3 mr-1 animate-spin"
/>
<SkipForward
v-else
class="h-3 w-3 mr-1"
/>
跳过
</Button>
<Button
variant="ghost"
size="sm"
@@ -1069,7 +662,7 @@
<Dialog
:model-value="showBatchUpgradeDialog"
title="批量升级"
description="所有在线 tunnel 节点下发升级版本指令"
description="所有 tunnel 节点写入升级目标,节点会在下次心跳自动领取"
:icon="Settings"
size="sm"
@update:model-value="(open: boolean) => { if (!open) { resetBatchUpgradeDialog() } }"
@@ -1084,29 +677,8 @@
v-model="batchUpgradeVersion"
placeholder="例如 0.2.3"
/>
</div>
<div class="space-y-1.5">
<Label>升级健康探测 URL</Label>
<Input
v-model="batchUpgradeProbeUrl"
placeholder="可选例如 https://example.com/health"
/>
<p class="text-xs text-muted-foreground">
留空则只等待真实流量或手动确认填写后 gateway 会通过 tunnel 主动发起 GET 探测
</p>
</div>
<div class="space-y-1.5">
<Label>探测超时</Label>
<Input
v-model="batchUpgradeProbeTimeout"
type="number"
min="5"
max="60"
inputmode="numeric"
placeholder="默认 10"
/>
<p class="text-xs text-muted-foreground">
仅在填写探测 URL 时生效允许范围 5 60
gateway 只会写入 `upgrade_to` 目标版本,不再维护分波 rollout 或确认状态。
</p>
</div>
</form>
@@ -1211,7 +783,6 @@ import {
type ProxyNodeRemoteConfig,
type ProxyNodeSchedulingState,
type ProxyNodeTestResult,
type ProxyNodeUpgradeRolloutTrackedNode,
} from '@/api/proxy-nodes'
import {
@@ -1236,7 +807,7 @@ import {
Dialog,
} from '@/components/ui'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History, RotateCcw, SkipForward } from 'lucide-vue-next'
import { Search, Trash2, Plus, SquarePen, Activity, Loader2, Settings, History } from 'lucide-vue-next'
import { parseApiError } from '@/utils/errorParser'
import { formatRegion } from '@/utils/region'
import HardwareTooltip from './components/HardwareTooltip.vue'
@@ -1247,7 +818,6 @@ const store = useProxyNodesStore()
const searchQuery = ref('')
const filterStatus = ref('all')
const filterRolloutStage = ref<RolloutStageFilterValue>('all')
const currentPage = ref(1)
const pageSize = ref(20)
@@ -1276,13 +846,7 @@ const configForm = ref({
})
const showBatchUpgradeDialog = ref(false)
const batchUpgradeVersion = ref('')
const batchUpgradeProbeUrl = ref('')
const batchUpgradeProbeTimeout = ref('10')
const batchUpgrading = ref(false)
const clearingUpgradeConflicts = ref(false)
const cancellingUpgradeRollout = ref(false)
const restoringSkippedUpgradeNodes = ref(false)
const actioningUpgradeNodes = ref(new Set<string>())
// 连接事件对话框
const showEventsDialog = ref(false)
@@ -1294,95 +858,6 @@ const loadingEvents = ref(false)
const testingNodes = ref(new Set<string>())
const testingUrl = ref(false)
const rolloutTrackedNodeById = computed(() => new Map(
(store.rollout?.tracked_nodes ?? []).map(node => [node.node_id, node] as const)
))
const rolloutConflictNodeIds = computed(() => new Set(store.rollout?.conflict_node_ids ?? []))
const rolloutCompletedNodeIds = computed(() => new Set(store.rollout?.completed_node_ids ?? []))
const rolloutSkippedNodeIds = computed(() => new Set(store.rollout?.skipped_node_ids ?? []))
const rolloutPendingPreview = computed(() => {
if (!store.rollout) return []
return store.rollout.pending_node_ids
.map(nodeId => {
const node = store.nodes.find(candidate => candidate.id === nodeId)
if (!node) return null
const badge = nodeRolloutBadge(node)
return {
node_id: node.id,
name: node.name,
node,
label: badge?.label ?? '等待确认',
variant: badge?.variant ?? 'secondary',
}
})
.filter((entry): entry is {
node_id: string
name: string
node: ProxyNode
label: string
variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
} => entry !== null)
.slice(0, 6)
})
const hiddenPendingNodeCount = computed(() =>
Math.max(0, (store.rollout?.pending_node_ids.length ?? 0) - rolloutPendingPreview.value.length)
)
const rolloutConflictPreview = computed(() => {
if (!store.rollout) return []
return store.rollout.conflict_node_ids
.map(nodeId => {
const node = store.nodes.find(candidate => candidate.id === nodeId)
if (!node) return null
return {
node_id: node.id,
name: node.name,
node,
}
})
.filter((entry): entry is {
node_id: string
name: string
node: ProxyNode
} => entry !== null)
.slice(0, 6)
})
const hiddenConflictNodeCount = computed(() =>
Math.max(0, (store.rollout?.conflict_node_ids.length ?? 0) - rolloutConflictPreview.value.length)
)
const rolloutSkippedPreview = computed(() => {
if (!store.rollout) return []
return store.rollout.skipped_node_ids
.map(nodeId => {
const node = store.nodes.find(candidate => candidate.id === nodeId)
if (!node) return null
return {
node_id: node.id,
name: node.name,
node,
}
})
.filter((entry): entry is {
node_id: string
name: string
node: ProxyNode
} => entry !== null)
.slice(0, 6)
})
const hiddenSkippedNodeCount = computed(() =>
Math.max(0, (store.rollout?.skipped_node_ids.length ?? 0) - rolloutSkippedPreview.value.length)
)
const activeRolloutStageLabel = computed(() =>
rolloutStageOptions.find(option => option.value === filterRolloutStage.value)?.label ?? '全部阶段'
)
const filteredNodes = computed(() => {
let filtered = [...store.nodes]
@@ -1398,10 +873,6 @@ const filteredNodes = computed(() => {
filtered = filtered.filter(node => node.status === filterStatus.value)
}
if (filterRolloutStage.value !== 'all') {
filtered = filtered.filter(node => nodeMatchesRolloutStage(node, filterRolloutStage.value))
}
return filtered
})
@@ -1410,19 +881,10 @@ const paginatedNodes = computed(() => {
return filteredNodes.value.slice(start, start + pageSize.value)
})
watch([searchQuery, filterStatus, filterRolloutStage], () => {
watch([searchQuery, filterStatus], () => {
currentPage.value = 1
})
watch(
() => store.rollout,
rollout => {
if (!rollout) {
filterRolloutStage.value = 'all'
}
},
)
onMounted(async () => {
await store.fetchNodes()
})
@@ -1605,21 +1067,11 @@ async function handleBatchUpgrade() {
if (!version || batchUpgrading.value) return
batchUpgrading.value = true
try {
const probeUrl = batchUpgradeProbeUrl.value.trim()
const rawProbeTimeout = batchUpgradeProbeTimeout.value.trim()
const parsedProbeTimeout = rawProbeTimeout ? parseInt(rawProbeTimeout, 10) : NaN
const probeTimeoutSecs = probeUrl && !isNaN(parsedProbeTimeout)
? Math.min(60, Math.max(5, parsedProbeTimeout))
: undefined
const result = await proxyNodesApi.batchUpgrade(version, {
probe_url: probeUrl || undefined,
probe_timeout_secs: probeUrl ? probeTimeoutSecs : undefined,
})
if (result.blocked) {
success(`升级 rollout 正在等待 ${result.pending_node_ids.length} 个节点确认到 ${result.version}`)
const result = await proxyNodesApi.batchUpgrade(version)
if (result.updated > 0) {
success(`已向 ${result.updated} 个节点写入升级目标 ${result.version}${result.skipped} 个节点无需变更`)
} else {
success(`升级指令已下发:本波 ${result.updated} 个节点,跳过 ${result.skipped} 个,已完成 ${result.completed} 个,剩余 ${result.remaining}`)
success(`当前没有需要变更的 tunnel 节点,目标版本仍为 ${result.version}`)
}
resetBatchUpgradeDialog()
await store.fetchNodes()
@@ -1630,129 +1082,9 @@ async function handleBatchUpgrade() {
}
}
async function handleCancelUpgradeRollout() {
if (!store.rollout || cancellingUpgradeRollout.value) return
const confirmed = await confirmDanger(
`确定要取消当前升级 rollout 吗?这会停止后续自动放量,但不会回滚已经下发到节点的升级指令。`,
'取消升级 Rollout',
'取消 Rollout'
)
if (!confirmed) return
cancellingUpgradeRollout.value = true
try {
const result = await proxyNodesApi.cancelUpgradeRollout()
await store.fetchNodes()
if (result.cancelled) {
success(
`已取消 ${result.version || '当前'} rollout待确认节点 ${(result.pending_node_ids?.length ?? 0)} 个,冲突节点 ${(result.conflict_node_ids?.length ?? 0)}`
)
} else {
success('当前没有活动中的 rollout')
}
} catch (err: unknown) {
toastError(parseApiError(err, '取消 rollout 失败'))
} finally {
cancellingUpgradeRollout.value = false
}
}
async function handleClearUpgradeConflicts() {
if (!store.rollout || clearingUpgradeConflicts.value || store.rollout.conflict_node_ids.length === 0) return
const confirmed = await confirmWarning(
`确定要清空 ${store.rollout.conflict_node_ids.length} 个冲突节点上的升级目标吗?这不会回滚已经下发成功的节点,但会解除当前 rollout 的冲突阻塞。`,
'清空冲突升级目标'
)
if (!confirmed) return
clearingUpgradeConflicts.value = true
try {
const result = await proxyNodesApi.clearUpgradeConflicts()
await store.fetchNodes()
if (result.cleared > 0) {
success(
`已清空 ${result.cleared} 个冲突节点;本次新增下发 ${result.updated} 个节点,剩余 ${result.remaining}`
)
} else {
success('当前没有需要清理的冲突节点')
}
} catch (err: unknown) {
toastError(parseApiError(err, '清空冲突失败'))
} finally {
clearingUpgradeConflicts.value = false
}
}
async function handleRestoreSkippedUpgradeNodes() {
if (!store.rollout || restoringSkippedUpgradeNodes.value || store.rollout.skipped_node_ids.length === 0) return
const confirmed = await confirmWarning(
`确定要恢复 ${store.rollout.skipped_node_ids.length} 个已跳过节点吗?它们会重新进入当前 rollout 队列如果当前没有待确认节点gateway 会按批次重新下发升级。`,
'恢复已跳过节点'
)
if (!confirmed) return
restoringSkippedUpgradeNodes.value = true
try {
const result = await proxyNodesApi.restoreSkippedUpgradeNodes()
await store.fetchNodes()
if (result.restored > 0) {
success(
`已恢复 ${result.restored} 个已跳过节点;本次新增下发 ${result.updated} 个节点,剩余 ${result.remaining}`
)
} else {
success('当前没有需要恢复的已跳过节点')
}
} catch (err: unknown) {
toastError(parseApiError(err, '恢复已跳过节点失败'))
} finally {
restoringSkippedUpgradeNodes.value = false
}
}
async function handleSkipUpgradeNode(node: ProxyNode) {
if (!canSkipUpgradeNode(node) || isUpgradeNodeActioning(node.id)) return
const confirmed = await confirmWarning(
`确定要在当前 rollout 中跳过节点 "${node.name}" 吗?这会把它排除出本轮升级,并清除当前 rollout 下发给它的升级目标。`,
'跳过当前节点'
)
if (!confirmed) return
setUpgradeNodeActioning(node.id, true)
try {
const result = await proxyNodesApi.skipUpgradeNode(node.id)
await store.fetchNodes()
success(
`已跳过节点 ${node.name};本次新增下发 ${result.updated} 个节点,剩余 ${result.remaining}`
)
} catch (err: unknown) {
toastError(parseApiError(err, '跳过节点失败'))
} finally {
setUpgradeNodeActioning(node.id, false)
}
}
async function handleRetryUpgradeNode(node: ProxyNode) {
if (!canRetryUpgradeNode(node) || isUpgradeNodeActioning(node.id)) return
setUpgradeNodeActioning(node.id, true)
try {
const result = await proxyNodesApi.retryUpgradeNode(node.id)
await store.fetchNodes()
success(
`已重试节点 ${node.name};当前待确认 ${result.pending_node_ids.length} 个,剩余 ${result.remaining}`
)
} catch (err: unknown) {
toastError(parseApiError(err, '重试节点失败'))
} finally {
setUpgradeNodeActioning(node.id, false)
}
}
function resetBatchUpgradeDialog() {
showBatchUpgradeDialog.value = false
batchUpgradeVersion.value = ''
batchUpgradeProbeUrl.value = ''
batchUpgradeProbeTimeout.value = '10'
}
async function handleDelete(node: ProxyNode) {
@@ -1911,93 +1243,23 @@ function nodeProxyVersion(node: ProxyNode) {
}
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
type RolloutStageFilterValue =
| 'all'
| 'pending'
| 'conflict'
| 'skipped'
| 'completed'
| 'awaiting_version'
| 'awaiting_traffic'
| 'cooling_down'
| 'unhealthy'
| 'ready_to_finalize'
const rolloutStageOptions: Array<{ value: RolloutStageFilterValue; label: string }> = [
{ value: 'all', label: '全部阶段' },
{ value: 'pending', label: '全部卡点' },
{ value: 'awaiting_version', label: '等待版本' },
{ value: 'awaiting_traffic', label: '等待流量' },
{ value: 'cooling_down', label: '冷却中' },
{ value: 'unhealthy', label: '健康失败' },
{ value: 'ready_to_finalize', label: '等待收尾' },
{ value: 'conflict', label: '版本冲突' },
{ value: 'skipped', label: '已跳过' },
{ value: 'completed', label: '已完成' },
]
function normalizeVersion(value: string | null | undefined) {
const trimmed = (value || '').trim().toLowerCase()
return trimmed.startsWith('proxy-v') ? trimmed.slice('proxy-v'.length) : trimmed
}
function formatDurationShort(seconds: number | null | undefined) {
if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return '0s'
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
return `${Math.floor(seconds / 3600)}h`
}
function resolveTrackedNodeBadge(
tracked: ProxyNodeUpgradeRolloutTrackedNode,
): { label: string; variant: BadgeVariant } {
switch (tracked.state) {
case 'awaiting_version':
return { label: '等待版本上报', variant: 'secondary' }
case 'awaiting_traffic':
return { label: '等待流量确认', variant: 'warning' }
case 'cooling_down':
return {
label: tracked.cooldown_remaining_secs && tracked.cooldown_remaining_secs > 0
? `冷却中 ${formatDurationShort(tracked.cooldown_remaining_secs)}`
: '冷却中',
variant: 'warning',
}
case 'unhealthy':
return { label: '健康检查未过', variant: 'destructive' }
case 'ready_to_finalize':
return { label: '等待收尾', variant: 'success' }
default:
return { label: '升级中', variant: 'secondary' }
}
}
function nodeRolloutBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
const rollout = store.rollout
if (!rollout || node.is_manual || !node.tunnel_mode) return null
if (rolloutSkippedNodeIds.value.has(node.id)) {
return { label: '已跳过', variant: 'outline' }
if (node.is_manual || !node.tunnel_mode) return null
const rawTarget = node.remote_config?.upgrade_to?.trim()
if (!rawTarget) return null
const targetVersion = normalizeVersion(rawTarget)
const currentVersion = normalizeVersion(nodeProxyVersion(node))
if (targetVersion && currentVersion && targetVersion === currentVersion) {
return { label: `目标版本 ${rawTarget}`, variant: 'success' }
}
if (rolloutConflictNodeIds.value.has(node.id)) {
return { label: '版本冲突', variant: 'destructive' }
}
const tracked = rolloutTrackedNodeById.value.get(node.id)
if (tracked) {
return resolveTrackedNodeBadge(tracked)
}
if (rolloutCompletedNodeIds.value.has(node.id)) {
const nodeVersion = normalizeVersion(nodeProxyVersion(node))
if (nodeVersion && nodeVersion === normalizeVersion(rollout.version)) {
return { label: '已确认完成', variant: 'success' }
}
return { label: '目标版本', variant: 'outline' }
}
const targetVersion = normalizeVersion(rollout.version)
const remoteUpgradeTarget = normalizeVersion(node.remote_config?.upgrade_to ?? null)
if (remoteUpgradeTarget && remoteUpgradeTarget === targetVersion) {
return { label: '已下发升级', variant: 'secondary' }
}
return null
return { label: `待升级 ${rawTarget}`, variant: 'warning' }
}
function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
@@ -2010,80 +1272,4 @@ function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVa
return null
}
}
function rolloutStageOfNode(node: ProxyNode): Exclude<RolloutStageFilterValue, 'all'> | null {
const rollout = store.rollout
if (!rollout || node.is_manual || !node.tunnel_mode) return null
if (rolloutSkippedNodeIds.value.has(node.id)) {
return 'skipped'
}
if (rolloutConflictNodeIds.value.has(node.id)) {
return 'conflict'
}
const tracked = rolloutTrackedNodeById.value.get(node.id)
if (tracked) {
return tracked.state
}
if (rolloutCompletedNodeIds.value.has(node.id)) {
return 'completed'
}
if (rollout.pending_node_ids.includes(node.id)) {
return 'pending'
}
const targetVersion = normalizeVersion(rollout.version)
const remoteUpgradeTarget = normalizeVersion(node.remote_config?.upgrade_to ?? null)
if (remoteUpgradeTarget && remoteUpgradeTarget === targetVersion) {
return 'pending'
}
return null
}
function nodeMatchesRolloutStage(node: ProxyNode, stage: RolloutStageFilterValue) {
if (stage === 'all') return true
const resolvedStage = rolloutStageOfNode(node)
if (!resolvedStage) return false
if (stage === 'pending') {
return resolvedStage === 'pending'
|| resolvedStage === 'awaiting_version'
|| resolvedStage === 'awaiting_traffic'
|| resolvedStage === 'cooling_down'
|| resolvedStage === 'unhealthy'
|| resolvedStage === 'ready_to_finalize'
}
return resolvedStage === stage
}
function isUpgradeNodeActioning(nodeId: string) {
return actioningUpgradeNodes.value.has(nodeId)
}
function setUpgradeNodeActioning(nodeId: string, active: boolean) {
const next = new Set(actioningUpgradeNodes.value)
if (active) {
next.add(nodeId)
} else {
next.delete(nodeId)
}
actioningUpgradeNodes.value = next
}
function canSkipUpgradeNode(node: ProxyNode) {
const rollout = store.rollout
if (!rollout || node.is_manual || !node.tunnel_mode) return false
if (rolloutSkippedNodeIds.value.has(node.id) || rolloutCompletedNodeIds.value.has(node.id)) {
return false
}
return rolloutConflictNodeIds.value.has(node.id)
|| rolloutTrackedNodeById.value.has(node.id)
|| rollout.pending_node_ids.includes(node.id)
}
function canRetryUpgradeNode(node: ProxyNode) {
const rollout = store.rollout
if (!rollout || node.is_manual || !node.tunnel_mode) return false
if (rolloutCompletedNodeIds.value.has(node.id)) return false
return rolloutSkippedNodeIds.value.has(node.id)
|| rolloutConflictNodeIds.value.has(node.id)
|| rolloutTrackedNodeById.value.has(node.id)
}
</script>