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

@@ -6,7 +6,7 @@ use crate::handlers::admin::shared::query_param_value;
use crate::maintenance::{ use crate::maintenance::{
cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts, cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts,
restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node, restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
skip_proxy_upgrade_rollout_node, start_proxy_upgrade_rollout, ProxyUpgradeRolloutProbeConfig, skip_proxy_upgrade_rollout_node, ProxyUpgradeRolloutProbeConfig,
}; };
use crate::GatewayError; use crate::GatewayError;
use aether_admin::system::{ use aether_admin::system::{
@@ -130,6 +130,16 @@ struct ProxyNodeBatchUpgradeRequest {
probe_timeout_secs: Option<u64>, probe_timeout_secs: Option<u64>,
} }
#[derive(Debug, Default)]
struct ProxyNodeBatchUpgradeDispatchSummary {
version: String,
eligible_total: usize,
updated: usize,
skipped: usize,
node_ids: Vec<String>,
rollout_cancelled: bool,
}
const JSON_OBJECT_REQUIRED_DETAIL: &str = "请求体必须是合法的 JSON 对象"; const JSON_OBJECT_REQUIRED_DETAIL: &str = "请求体必须是合法的 JSON 对象";
const DEFAULT_PROXY_UPGRADE_BATCH_SIZE: usize = 1; const DEFAULT_PROXY_UPGRADE_BATCH_SIZE: usize = 1;
const DEFAULT_PROXY_UPGRADE_COOLDOWN_SECS: u64 = 60; const DEFAULT_PROXY_UPGRADE_COOLDOWN_SECS: u64 = 60;
@@ -506,10 +516,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
if decision.route_kind.as_deref() == Some("batch_upgrade_nodes") if decision.route_kind.as_deref() == Some("batch_upgrade_nodes")
&& request_context.method() == http::Method::POST && request_context.method() == http::Method::POST
{ {
if !state.has_proxy_node_reader() if !state.has_proxy_node_reader() || !state.has_proxy_node_writer() {
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response())); return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
} }
let input = match parse_json_body::<ProxyNodeBatchUpgradeRequest>(request_body) { let input = match parse_json_body::<ProxyNodeBatchUpgradeRequest>(request_body) {
@@ -520,42 +527,16 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
Ok(version) => version, Ok(version) => version,
Err(response) => return Ok(Some(response)), Err(response) => return Ok(Some(response)),
}; };
let batch_size = match validate_batch_size(input.batch_size) { let summary = dispatch_proxy_node_upgrade_targets(state, &version).await?;
Ok(batch_size) => batch_size,
Err(response) => return Ok(Some(response)),
};
let cooldown_secs = match validate_cooldown_secs(input.cooldown_secs) {
Ok(cooldown_secs) => cooldown_secs,
Err(response) => return Ok(Some(response)),
};
let probe =
match validate_probe_config(input.probe_url.as_deref(), input.probe_timeout_secs) {
Ok(probe) => probe,
Err(response) => return Ok(Some(response)),
};
let rollout = start_proxy_upgrade_rollout(
&state.app().data,
version.clone(),
batch_size,
cooldown_secs,
probe,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some( return Ok(Some(
Json(json!({ Json(json!({
"version": version, "version": summary.version,
"batch_size": rollout.batch_size, "eligible_total": summary.eligible_total,
"cooldown_secs": rollout.cooldown_secs, "updated": summary.updated,
"updated": rollout.updated, "skipped": summary.skipped,
"skipped": rollout.skipped, "node_ids": summary.node_ids,
"node_ids": rollout.node_ids, "rollout_cancelled": summary.rollout_cancelled,
"blocked": rollout.blocked,
"pending_node_ids": rollout.pending_node_ids,
"rollout_active": rollout.rollout_active,
"completed": rollout.completed,
"remaining": rollout.remaining,
})) }))
.into_response(), .into_response(),
)); ));
@@ -1480,6 +1461,79 @@ fn admin_proxy_node_test_node_id_from_path(path: &str) -> Option<String> {
} }
} }
fn normalize_proxy_upgrade_version(value: &str) -> String {
value
.trim()
.strip_prefix("proxy-v")
.unwrap_or(value.trim())
.to_ascii_lowercase()
}
async fn dispatch_proxy_node_upgrade_targets(
state: &AdminAppState<'_>,
version: &str,
) -> Result<ProxyNodeBatchUpgradeDispatchSummary, GatewayError> {
let mut nodes = state.list_proxy_nodes().await?;
nodes.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
let rollout_cancelled = if state.app().data.has_system_config_store() {
cancel_proxy_upgrade_rollout(&state.app().data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.is_some()
} else {
false
};
let normalized_target = normalize_proxy_upgrade_version(version);
let mut summary = ProxyNodeBatchUpgradeDispatchSummary {
version: version.to_string(),
rollout_cancelled,
..Default::default()
};
for node in nodes {
if node.is_manual || !node.tunnel_mode {
continue;
}
summary.eligible_total = summary.eligible_total.saturating_add(1);
let current_version =
aether_data::repository::proxy_nodes::proxy_reported_version(node.proxy_metadata.as_ref());
let pending_target = aether_data::repository::proxy_nodes::remote_config_upgrade_target(
node.remote_config.as_ref(),
);
if pending_target.as_deref() == Some(normalized_target.as_str())
|| current_version.as_deref() == Some(normalized_target.as_str())
{
continue;
}
let Some(updated) = state
.update_proxy_node_remote_config(
&aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation {
node_id: node.id.clone(),
node_name: None,
allowed_ports: None,
log_level: None,
heartbeat_interval: None,
scheduling_state: None,
upgrade_to: Some(Some(version.to_string())),
},
)
.await?
else {
continue;
};
summary.node_ids.push(updated.id);
}
summary.updated = summary.node_ids.len();
summary.skipped = summary.eligible_total.saturating_sub(summary.updated);
Ok(summary)
}
fn validate_batch_size(batch_size: Option<usize>) -> Result<usize, Response<Body>> { fn validate_batch_size(batch_size: Option<usize>) -> Result<usize, Response<Body>> {
let batch_size = batch_size.unwrap_or(DEFAULT_PROXY_UPGRADE_BATCH_SIZE); let batch_size = batch_size.unwrap_or(DEFAULT_PROXY_UPGRADE_BATCH_SIZE);
if (1..=100).contains(&batch_size) { if (1..=100).contains(&batch_size) {

View File

@@ -1252,7 +1252,7 @@ async fn gateway_handles_admin_proxy_node_events_locally_with_trusted_admin_prin
} }
#[tokio::test] #[tokio::test]
async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() { async fn gateway_updates_proxy_node_config_and_dispatches_upgrade_targets_locally() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits); let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new() let upstream = Router::new()
@@ -1346,15 +1346,14 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
.await .await
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(upgrade_payload["version"], "2.0.0"); assert_eq!(upgrade_payload["version"], "2.0.0");
assert_eq!(upgrade_payload["batch_size"], 1); assert_eq!(upgrade_payload["eligible_total"], 3);
assert_eq!(upgrade_payload["updated"], 1); assert_eq!(upgrade_payload["updated"], 3);
assert_eq!(upgrade_payload["skipped"], 1); assert_eq!(upgrade_payload["skipped"], 0);
assert_eq!(upgrade_payload["blocked"], false); assert_eq!(
assert_eq!(upgrade_payload["pending_node_ids"], json!(["node-online"])); upgrade_payload["node_ids"],
assert_eq!(upgrade_payload["node_ids"], json!(["node-online"])); json!(["node-online", "node-offline", "node-zeta"])
assert_eq!(upgrade_payload["completed"], 0); );
assert_eq!(upgrade_payload["remaining"], 2); assert_eq!(upgrade_payload["rollout_cancelled"], false);
assert_eq!(upgrade_payload["rollout_active"], true);
let blocked_upgrade_response = client let blocked_upgrade_response = client
.post(format!("{gateway_url}/api/admin/proxy-nodes/upgrade")) .post(format!("{gateway_url}/api/admin/proxy-nodes/upgrade"))
@@ -1372,11 +1371,7 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
.await .await
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(blocked_upgrade_payload["updated"], 0); assert_eq!(blocked_upgrade_payload["updated"], 0);
assert_eq!(blocked_upgrade_payload["blocked"], true); assert_eq!(blocked_upgrade_payload["skipped"], 3);
assert_eq!(
blocked_upgrade_payload["pending_node_ids"],
json!(["node-online"])
);
let heartbeat_response = client let heartbeat_response = client
.post(format!("{gateway_url}/api/internal/tunnel/heartbeat")) .post(format!("{gateway_url}/api/internal/tunnel/heartbeat"))
@@ -1404,7 +1399,7 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
assert_eq!(heartbeat_payload["remote_config"]["allowed_ports"][1], 8443); assert_eq!(heartbeat_payload["remote_config"]["allowed_ports"][1], 8443);
assert_eq!(heartbeat_payload["remote_config"]["log_level"], "info"); assert_eq!(heartbeat_payload["remote_config"]["log_level"], "info");
let second_upgrade_response = client let post_heartbeat_upgrade_response = client
.post(format!("{gateway_url}/api/admin/proxy-nodes/upgrade")) .post(format!("{gateway_url}/api/admin/proxy-nodes/upgrade"))
.header(GATEWAY_HEADER, "rust-phase3b") .header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123") .header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
@@ -1414,57 +1409,14 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
.send() .send()
.await .await
.expect("request should succeed"); .expect("request should succeed");
assert_eq!(second_upgrade_response.status(), StatusCode::OK); assert_eq!(post_heartbeat_upgrade_response.status(), StatusCode::OK);
let second_upgrade_payload: serde_json::Value = second_upgrade_response let post_heartbeat_upgrade_payload: serde_json::Value = post_heartbeat_upgrade_response
.json() .json()
.await .await
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(second_upgrade_payload["batch_size"], 1); assert_eq!(post_heartbeat_upgrade_payload["node_ids"], json!([]));
assert_eq!(second_upgrade_payload["updated"], 0); assert_eq!(post_heartbeat_upgrade_payload["updated"], 0);
assert_eq!(second_upgrade_payload["skipped"], 2); assert_eq!(post_heartbeat_upgrade_payload["skipped"], 3);
assert_eq!(second_upgrade_payload["blocked"], true);
assert_eq!(
second_upgrade_payload["pending_node_ids"],
json!(["node-online"])
);
assert_eq!(second_upgrade_payload["node_ids"], json!([]));
assert_eq!(second_upgrade_payload["completed"], 0);
assert_eq!(second_upgrade_payload["remaining"], 3);
assert_eq!(second_upgrade_payload["rollout_active"], true);
assert!(
record_proxy_upgrade_traffic_success(&data_state, "node-online")
.await
.expect("traffic confirmation should be recorded")
);
let third_upgrade_response = client
.post(format!("{gateway_url}/api/admin/proxy-nodes/upgrade"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({ "version": "2.0.0", "cooldown_secs": 0 }))
.send()
.await
.expect("request should succeed");
assert_eq!(third_upgrade_response.status(), StatusCode::OK);
let third_upgrade_payload: serde_json::Value = third_upgrade_response
.json()
.await
.expect("json body should parse");
assert_eq!(third_upgrade_payload["batch_size"], 1);
assert_eq!(third_upgrade_payload["updated"], 1);
assert_eq!(third_upgrade_payload["skipped"], 1);
assert_eq!(third_upgrade_payload["blocked"], false);
assert_eq!(
third_upgrade_payload["pending_node_ids"],
json!(["node-zeta"])
);
assert_eq!(third_upgrade_payload["node_ids"], json!(["node-zeta"]));
assert_eq!(third_upgrade_payload["completed"], 1);
assert_eq!(third_upgrade_payload["remaining"], 1);
assert_eq!(third_upgrade_payload["rollout_active"], true);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
@@ -1473,7 +1425,7 @@ async fn gateway_updates_proxy_node_config_and_batches_upgrade_locally() {
} }
#[tokio::test] #[tokio::test]
async fn gateway_marks_draining_proxy_nodes_unschedulable_and_rollout_skips_them() { async fn gateway_still_dispatches_upgrade_targets_to_draining_proxy_nodes() {
let mut alpha = sample_proxy_node("node-alpha"); let mut alpha = sample_proxy_node("node-alpha");
alpha.name = "alpha".to_string(); alpha.name = "alpha".to_string();
alpha.status = "online".to_string(); alpha.status = "online".to_string();
@@ -1562,11 +1514,11 @@ async fn gateway_marks_draining_proxy_nodes_unschedulable_and_rollout_skips_them
.json() .json()
.await .await
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(upgrade_payload["updated"], 1); assert_eq!(upgrade_payload["eligible_total"], 2);
assert_eq!(upgrade_payload["node_ids"], json!(["node-zeta"])); assert_eq!(upgrade_payload["updated"], 2);
assert_eq!(upgrade_payload["pending_node_ids"], json!(["node-zeta"])); assert_eq!(upgrade_payload["node_ids"], json!(["node-alpha", "node-zeta"]));
let rollout_response = client let list_response_after_upgrade = client
.get(format!( .get(format!(
"{gateway_url}/api/admin/proxy-nodes?skip=0&limit=10" "{gateway_url}/api/admin/proxy-nodes?skip=0&limit=10"
)) ))
@@ -1577,11 +1529,17 @@ async fn gateway_marks_draining_proxy_nodes_unschedulable_and_rollout_skips_them
.send() .send()
.await .await
.expect("request should succeed"); .expect("request should succeed");
let rollout_payload: serde_json::Value = rollout_response let list_payload_after_upgrade: serde_json::Value = list_response_after_upgrade
.json() .json()
.await .await
.expect("json body should parse"); .expect("json body should parse");
assert_eq!(rollout_payload["rollout"]["online_eligible_total"], 1); let alpha_after_upgrade = list_payload_after_upgrade["items"]
.as_array()
.expect("items should be array")
.iter()
.find(|item| item["id"] == "node-alpha")
.expect("alpha should exist after upgrade");
assert_eq!(alpha_after_upgrade["remote_config"]["upgrade_to"], "2.0.0");
gateway_handle.abort(); gateway_handle.abort();
} }

View File

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

View File

@@ -63,25 +63,6 @@
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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>
</div> </div>
@@ -117,23 +98,6 @@
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </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" /> <div class="h-4 w-px bg-border" />
<Button <Button
variant="outline" variant="outline"
@@ -160,305 +124,6 @@
</div> </div>
</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"> <div class="hidden xl:block overflow-x-auto">
<Table> <Table>
@@ -618,42 +283,6 @@
> >
<History class="h-4 w-4" /> <History class="h-4 w-4" />
</Button> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -800,42 +429,6 @@
<Settings class="h-3 w-3 mr-1" /> <Settings class="h-3 w-3 mr-1" />
配置 配置
</Button> </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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1069,7 +662,7 @@
<Dialog <Dialog
:model-value="showBatchUpgradeDialog" :model-value="showBatchUpgradeDialog"
title="批量升级" title="批量升级"
description="所有在线 tunnel 节点下发升级版本指令" description="所有 tunnel 节点写入升级目标,节点会在下次心跳自动领取"
:icon="Settings" :icon="Settings"
size="sm" size="sm"
@update:model-value="(open: boolean) => { if (!open) { resetBatchUpgradeDialog() } }" @update:model-value="(open: boolean) => { if (!open) { resetBatchUpgradeDialog() } }"
@@ -1084,29 +677,8 @@
v-model="batchUpgradeVersion" v-model="batchUpgradeVersion"
placeholder="例如 0.2.3" 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"> <p class="text-xs text-muted-foreground">
留空则只等待真实流量或手动确认填写后 gateway 会通过 tunnel 主动发起 GET 探测 gateway 只会写入 `upgrade_to` 目标版本,不再维护分波 rollout 或确认状态。
</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
</p> </p>
</div> </div>
</form> </form>
@@ -1211,7 +783,6 @@ import {
type ProxyNodeRemoteConfig, type ProxyNodeRemoteConfig,
type ProxyNodeSchedulingState, type ProxyNodeSchedulingState,
type ProxyNodeTestResult, type ProxyNodeTestResult,
type ProxyNodeUpgradeRolloutTrackedNode,
} from '@/api/proxy-nodes' } from '@/api/proxy-nodes'
import { import {
@@ -1236,7 +807,7 @@ import {
Dialog, Dialog,
} from '@/components/ui' } 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 { 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'
@@ -1247,7 +818,6 @@ const store = useProxyNodesStore()
const searchQuery = ref('') const searchQuery = ref('')
const filterStatus = ref('all') const filterStatus = ref('all')
const filterRolloutStage = ref<RolloutStageFilterValue>('all')
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
@@ -1276,13 +846,7 @@ const configForm = ref({
}) })
const showBatchUpgradeDialog = ref(false) const showBatchUpgradeDialog = ref(false)
const batchUpgradeVersion = ref('') const batchUpgradeVersion = ref('')
const batchUpgradeProbeUrl = ref('')
const batchUpgradeProbeTimeout = ref('10')
const batchUpgrading = ref(false) 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) const showEventsDialog = ref(false)
@@ -1294,95 +858,6 @@ const loadingEvents = ref(false)
const testingNodes = ref(new Set<string>()) const testingNodes = ref(new Set<string>())
const testingUrl = ref(false) 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(() => { const filteredNodes = computed(() => {
let filtered = [...store.nodes] let filtered = [...store.nodes]
@@ -1398,10 +873,6 @@ const filteredNodes = computed(() => {
filtered = filtered.filter(node => node.status === filterStatus.value) filtered = filtered.filter(node => node.status === filterStatus.value)
} }
if (filterRolloutStage.value !== 'all') {
filtered = filtered.filter(node => nodeMatchesRolloutStage(node, filterRolloutStage.value))
}
return filtered return filtered
}) })
@@ -1410,19 +881,10 @@ const paginatedNodes = computed(() => {
return filteredNodes.value.slice(start, start + pageSize.value) return filteredNodes.value.slice(start, start + pageSize.value)
}) })
watch([searchQuery, filterStatus, filterRolloutStage], () => { watch([searchQuery, filterStatus], () => {
currentPage.value = 1 currentPage.value = 1
}) })
watch(
() => store.rollout,
rollout => {
if (!rollout) {
filterRolloutStage.value = 'all'
}
},
)
onMounted(async () => { onMounted(async () => {
await store.fetchNodes() await store.fetchNodes()
}) })
@@ -1605,21 +1067,11 @@ async function handleBatchUpgrade() {
if (!version || batchUpgrading.value) return if (!version || batchUpgrading.value) return
batchUpgrading.value = true batchUpgrading.value = true
try { try {
const probeUrl = batchUpgradeProbeUrl.value.trim() const result = await proxyNodesApi.batchUpgrade(version)
const rawProbeTimeout = batchUpgradeProbeTimeout.value.trim() if (result.updated > 0) {
const parsedProbeTimeout = rawProbeTimeout ? parseInt(rawProbeTimeout, 10) : NaN success(`已向 ${result.updated} 个节点写入升级目标 ${result.version}${result.skipped} 个节点无需变更`)
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}`)
} else { } else {
success(`升级指令已下发:本波 ${result.updated} 个节点,跳过 ${result.skipped} 个,已完成 ${result.completed} 个,剩余 ${result.remaining}`) success(`当前没有需要变更的 tunnel 节点,目标版本仍为 ${result.version}`)
} }
resetBatchUpgradeDialog() resetBatchUpgradeDialog()
await store.fetchNodes() 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() { function resetBatchUpgradeDialog() {
showBatchUpgradeDialog.value = false showBatchUpgradeDialog.value = false
batchUpgradeVersion.value = '' batchUpgradeVersion.value = ''
batchUpgradeProbeUrl.value = ''
batchUpgradeProbeTimeout.value = '10'
} }
async function handleDelete(node: ProxyNode) { async function handleDelete(node: ProxyNode) {
@@ -1911,93 +1243,23 @@ function nodeProxyVersion(node: ProxyNode) {
} }
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark' 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) { function normalizeVersion(value: string | null | undefined) {
const trimmed = (value || '').trim().toLowerCase() const trimmed = (value || '').trim().toLowerCase()
return trimmed.startsWith('proxy-v') ? trimmed.slice('proxy-v'.length) : trimmed 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 { function nodeRolloutBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
const rollout = store.rollout if (node.is_manual || !node.tunnel_mode) return null
if (!rollout || node.is_manual || !node.tunnel_mode) return null const rawTarget = node.remote_config?.upgrade_to?.trim()
if (rolloutSkippedNodeIds.value.has(node.id)) { if (!rawTarget) return null
return { label: '已跳过', variant: 'outline' }
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: `待升级 ${rawTarget}`, variant: 'warning' }
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
} }
function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null { function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVariant } | null {
@@ -2010,80 +1272,4 @@ function nodeSchedulingBadge(node: ProxyNode): { label: string; variant: BadgeVa
return null 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> </script>