feat: stabilize multi-region runtime sync and worker orchestration

This commit is contained in:
root
2026-04-27 15:48:12 +08:00
parent 7cbde2aa78
commit 215a364891
137 changed files with 31931 additions and 1943 deletions

View File

@@ -103,6 +103,9 @@ export const opsApi = {
dispatchJob: (jobId: number) => http.post(`/ops/jobs/${jobId}/dispatch`),
issueAgentToken: (payload: Record<string, unknown>) => http.post("/ops/agent/tokens", payload),
buildAgentBootstrapPlan: (payload: Record<string, unknown>) => http.post("/ops/agent/bootstrap-plan", payload),
migrationSourceProfile: () => http.get("/ops/migration/source-profile"),
previewMigration: (payload: Record<string, unknown>) => http.post("/ops/migration/preview", payload),
executeMigration: (payload: Record<string, unknown>) => http.post("/ops/migration/execute", payload),
releases: (params?: Record<string, unknown>) => http.get("/ops/releases", { params }),
latestRelease: (channel = "stable") => http.get("/ops/releases/latest", { params: { channel } }),
latestReleasePackageMetadata: () => http.get("/ops/releases/package-metadata/latest"),

View File

@@ -68,7 +68,8 @@ const menuItems = [
{ path: "/runtime", label: "运行中心" },
{ path: "/runtime-debug", label: "运行调试" },
{ path: "/ops-center", label: "运维中枢" },
{ path: "/managed-nodes", label: "托管节点" },
{ path: "/managed-nodes", label: "服务器管理" },
{ path: "/migration", label: "迁移向导" },
{ path: "/settings", label: "系统设置" },
{ path: "/imports", label: "域名导入" },
{ path: "/sensitive-words", label: "敏感词配置" },

View File

@@ -17,7 +17,8 @@ const routes: RouteRecordRaw[] = [
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心", description: "看检测任务有没有真正跑起来,重点盯 running、ppm、步骤队列和节点负载。" } },
{ path: "runtime-debug", name: "runtime-debug", component: () => import("@/views/runtime/RuntimeDebugView.vue"), meta: { title: "运行调试", description: "排查步骤卡点、事件流、调试日志和运行时异常。" } },
{ path: "ops-center", name: "ops-center", component: () => import("@/views/ops/OpsCenterView.vue"), meta: { title: "运维中枢", description: "这里是总驾驶舱,负责发布、运维任务、巡检、接管进度和整体运维视角。" } },
{ path: "managed-nodes", name: "managed-nodes", component: () => import("@/views/ops/ManagedNodesView.vue"), meta: { title: "托管节点", description: "这里专门管理器本身:新增节点、填 SSH、启用停用、维护接入信息。" } },
{ path: "managed-nodes", name: "managed-nodes", component: () => import("@/views/ops/ManagedNodesView.vue"), meta: { title: "服务器管理", description: "这里专门管理服务器本身:新增机器、填 SSH、启用停用、维护接入信息。" } },
{ path: "migration", name: "migration", component: () => import("@/views/ops/MigrationView.vue"), meta: { title: "迁移向导", description: "面向已 clone 代码的新机器,先预检,再预览计划,最后按选项同步配置、数据库和服务。" } },
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置", description: "维护系统基础配置、运行参数和默认行为。" } },
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入", description: "导入待检测域名数据,建立任务源。" } },
{ path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置", description: "维护过滤词、黑名单词和筛选规则。" } },

View File

@@ -43,7 +43,7 @@
<div class="summary-card">
<div class="summary-title">当前速度</div>
<div class="summary-value">{{ processedPerMinuteText }}</div>
<div class="summary-note">近窗完成 {{ opsSummary.completed_recent || 0 }} / 失败 {{ opsSummary.failed_recent || 0 }} / 黑名单 {{ opsSummary.blacklisted_recent || 0 }}</div>
<div class="summary-note">近窗完成 {{ opsSummary.completed_recent || 0 }} / 失败 {{ opsSummary.failed_recent || 0 }} / 近窗黑名单 {{ opsSummary.blacklisted_recent || 0 }}</div>
</div>
<div class="summary-card">
<div class="summary-title">预计剩余</div>
@@ -61,18 +61,25 @@
<div class="summary-note">注册待处理 {{ backlogRegisterPendingText }} / 后续步骤 {{ backlogDownstreamPendingText }}</div>
</div>
<div class="summary-card">
<div class="summary-title">有效执行节点</div>
<div class="summary-value">{{ opsSummary.active_execution_nodes || 0 }}</div>
<div class="summary-note">在线 Worker {{ opsSummary.online_worker_nodes || 0 }} / 独立 Worker {{ opsSummary.dedicated_online_worker_nodes || 0 }} / 当前线程负载 {{ queueDisplayRunningText }}</div>
<div class="summary-title">有效执行服务器</div>
<div class="summary-value">{{ activeExecutionServerCount }}</div>
<div class="summary-note">在线 Worker 服务器 {{ opsSummary.online_worker_nodes || 0 }} / 当前参与进程 {{ participatingProcessCountText }} / 当前参与线程 {{ queueDisplayRunningText }}</div>
</div>
<div class="summary-card">
<div class="summary-title">集群代理</div>
<div class="summary-value">{{ clusterProxyAvailableText }}</div>
<div class="summary-note">{{ clusterProxySummaryText }}</div>
</div>
</div>
<el-row v-if="activeJob.job_code" :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务待处理" :value="activeJob.items_pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前线程负载" :value="activeJobDisplayRunning" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前参与线程" :value="activeJobDisplayRunning" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务执行中" :value="activeJob.items_running || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务通过" :value="activeJob.items_completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务黑名单" :value="activeJob.items_blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="近窗黑名单" :value="opsSummary.blacklisted_recent || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="累计黑名单" :value="inventoryBlacklistedTotal" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前任务失败" :value="activeJob.items_failed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="6"><el-statistic title="当前进度" :value="activeJob.progress_percent || 0" suffix="%" /></el-col>
</el-row>
@@ -85,29 +92,36 @@
/>
</PageCard>
<PageCard title="步骤队列" description="每一步堆积、吞吐和失败,快速判断到底卡在注册、百度、360、爱站还是站长之家。">
<PageCard title="步骤队列" description="按后台勾选顺序展示每一步堆积、吞吐和失败,方便直接判断当前流程推进到哪一步。">
<el-table :data="stepQueue" border empty-text="当前没有活跃步骤队列">
<el-table-column label="顺序" width="70">
<template #default="{ $index }">
{{ $index + 1 }}
</template>
</el-table-column>
<el-table-column prop="step_name" label="步骤" min-width="150" />
<el-table-column prop="items_pending" label="待处理" min-width="90" />
<el-table-column prop="items_claimed" label="已领" min-width="90" />
<el-table-column prop="items_running" label="执行中" min-width="90" />
<el-table-column prop="items_completed" label="完成" min-width="90" />
<el-table-column prop="items_blacklisted" label="黑名单" min-width="90" />
<el-table-column prop="items_completed" label="当前完成" min-width="90" />
<el-table-column prop="items_blacklisted" label="当前黑名单" min-width="90" />
<el-table-column prop="items_failed" label="失败" min-width="90" />
<el-table-column prop="started_recent" label="近窗启动" min-width="100" />
<el-table-column prop="processed_recent" label="近窗处理" min-width="100" />
<el-table-column prop="completed_recent" label="近窗完成" min-width="100" />
<el-table-column prop="blacklisted_recent" label="近窗黑名单" min-width="100" />
<el-table-column prop="failed_recent" label="近窗失败" min-width="100" />
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
</el-table>
</PageCard>
<PageCard title="节点吞吐" description="看 controller 和 worker 是不是都真在跑,谁在空转,谁在出结果。">
<el-table :data="nodeThroughput" border empty-text="当前没有节点吞吐数据">
<el-table-column prop="node_code" label="节点" min-width="180" />
<PageCard title="服务器吞吐" description="这里按服务器聚合展示,不再把每个进程实例混成节点,方便运营直接看哪台机器真在跑。">
<el-table :data="serverThroughput" border empty-text="当前没有服务器吞吐数据">
<el-table-column prop="server_code" label="服务器" min-width="180" />
<el-table-column prop="process_count" label="参与进程" min-width="100" />
<el-table-column prop="items_running" label="任务执行中" min-width="110" />
<el-table-column prop="display_running" label="线程负载" min-width="100" />
<el-table-column label="活跃线程" min-width="120">
<el-table-column prop="display_running" label="参与线程" min-width="100" />
<el-table-column label="线程状态" min-width="120">
<template #default="{ row }">
{{ formatThreadState(row) }}
</template>
@@ -117,7 +131,7 @@
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
<el-table-column prop="completed_recent" label="完成" min-width="90" />
<el-table-column prop="failed_recent" label="失败" min-width="90" />
<el-table-column prop="blacklisted_recent" label="黑名单" min-width="90" />
<el-table-column prop="blacklisted_recent" label="近窗黑名单" min-width="100" />
</el-table>
</PageCard>
</div>
@@ -160,6 +174,21 @@ const opsSummary = ref<Record<string, any>>({});
const bottleneckStep = ref<Record<string, any>>({});
const stepQueue = ref<Record<string, any>[]>([]);
const nodeThroughput = ref<Record<string, any>[]>([]);
const inventoryBlacklistedTotal = ref(0);
const resolveServerCode = (rawNodeCode?: string) => {
const normalized = String(rawNodeCode || "").trim();
if (!normalized) return "";
const parts = normalized.split("-");
if (parts.length >= 2) {
const suffix = parts[parts.length - 1];
const parent = parts.slice(0, -1).join("-");
if (/^[a-z]{1,3}$/i.test(suffix) && /\d$/.test(parent)) {
return parent;
}
}
return normalized;
};
const clearRefreshTimer = () => {
if (timer) {
@@ -190,6 +219,58 @@ const processedPerMinuteText = computed(() => {
const activeJobDisplayRunning = computed(() => Number(activeJob.value.items_display_running || activeJob.value.items_running || 0));
const queueDisplayRunningText = computed(() => String(Number(opsSummary.value.queue_display_running_total || 0)));
const participatingProcessCountText = computed(() => String(Number(opsSummary.value.aggregate_process_count || 0)));
const clusterProxyAvailableText = computed(() => String(Number(opsSummary.value.cluster_proxy_available_count || 0)));
const clusterProxySummaryText = computed(() => {
const label = String(opsSummary.value.cluster_proxy_runtime_label || "未知");
const detail = String(opsSummary.value.cluster_proxy_runtime_detail || opsSummary.value.cluster_proxy_last_refresh_status || "").trim();
return detail ? `${label} / ${detail}` : label;
});
const serverThroughput = computed(() => {
const grouped = new Map<string, Record<string, any>>();
for (const rawRow of nodeThroughput.value || []) {
const serverCode = resolveServerCode(String(rawRow?.node_code || ""));
if (!serverCode) continue;
const bucket = grouped.get(serverCode) || {
server_code: serverCode,
process_count: 0,
items_running: 0,
display_running: 0,
items_claimed: 0,
current_load: 0,
active_threads: 0,
max_threads: 0,
processed_recent: 0,
processed_per_minute: 0,
completed_recent: 0,
failed_recent: 0,
blacklisted_recent: 0
};
bucket.process_count += 1;
bucket.items_running += Number(rawRow?.items_running || 0);
bucket.display_running += Number(rawRow?.display_running || 0);
bucket.items_claimed += Number(rawRow?.items_claimed || 0);
bucket.current_load += Number(rawRow?.current_load || 0);
bucket.active_threads += Number(rawRow?.active_threads || 0);
bucket.max_threads += Number(rawRow?.max_threads || 0);
bucket.processed_recent += Number(rawRow?.processed_recent || 0);
bucket.processed_per_minute = Number((bucket.processed_per_minute + Number(rawRow?.processed_per_minute || 0)).toFixed(2));
bucket.completed_recent += Number(rawRow?.completed_recent || 0);
bucket.failed_recent += Number(rawRow?.failed_recent || 0);
bucket.blacklisted_recent += Number(rawRow?.blacklisted_recent || 0);
grouped.set(serverCode, bucket);
}
return [...grouped.values()].sort((a, b) => (
Number(b.processed_recent || 0) - Number(a.processed_recent || 0)
|| Number(b.display_running || 0) - Number(a.display_running || 0)
|| String(a.server_code || "").localeCompare(String(b.server_code || ""))
));
});
const activeExecutionServerCount = computed(() => serverThroughput.value.filter((item) =>
Number(item.items_running || 0) > 0
|| Number(item.items_claimed || 0) > 0
|| Number(item.processed_recent || 0) > 0
).length);
const backlogPendingText = computed(() => String(Number(opsSummary.value.backlog_pending_total || 0)));
@@ -240,11 +321,18 @@ const loadOverview = async (showError = true) => {
backlog_running_total: data.backlog_running_total || 0,
backlog_register_pending_total: data.backlog_register_pending_total || 0,
backlog_downstream_pending_total: data.backlog_downstream_pending_total || 0,
queue_display_running_total: data.queue_display_running_total || 0
queue_display_running_total: data.queue_display_running_total || 0,
aggregate_process_count: data.aggregate_process_count || 0,
aggregate_active_thread_count: data.aggregate_active_thread_count || 0,
cluster_proxy_available_count: data.cluster_proxy_available_count || 0,
cluster_proxy_runtime_label: data.cluster_proxy_runtime_label || "",
cluster_proxy_runtime_detail: data.cluster_proxy_runtime_detail || "",
cluster_proxy_last_refresh_status: data.cluster_proxy_last_refresh_status || ""
};
bottleneckStep.value = data.bottleneck_step || {};
stepQueue.value = Array.isArray(data.step_queue) ? data.step_queue : [];
nodeThroughput.value = Array.isArray(data.node_throughput) ? data.node_throughput : [];
inventoryBlacklistedTotal.value = Number(data.blacklist_total || 0);
stats.value = [
{ label: "总域名", value: String(data.domains_total), note: "数据库域名总量" },
{ label: "库存待检测", value: String(data.pending_total), note: "全库尚未跑完的域名" },
@@ -266,9 +354,14 @@ const loadOverview = async (showError = true) => {
{ label: "API", value: data.api_status, note: "当前 API 运行状态" },
{ label: "本机 Worker", value: localWorkerValue, note: localWorkerNote },
{
label: "有效执行节点",
label: "有效执行服务器",
value: `${data.cluster_worker_status || "offline"} / ${data.cluster_online_worker_nodes || 0}`,
note: `可执行检测的在线节点,独立 Worker ${data.cluster_dedicated_online_worker_nodes || 0}控制面 ${data.cluster_online_control_nodes || 0}`
note: `可执行检测的在线服务器,当前参与进程 ${data.aggregate_process_count || 0}当前参与线程 ${data.queue_display_running_total || 0}`
},
{
label: "集群代理",
value: String(data.cluster_proxy_available_count || 0),
note: String(data.cluster_proxy_runtime_label || "未知")
},
{ label: "模式", value: data.worker_mode, note: "当前 Worker 运行模式" }
];

View File

@@ -31,8 +31,10 @@
<el-descriptions-item label="检测进程数">{{ status.worker_process_count }}</el-descriptions-item>
<el-descriptions-item label="本机线程配置">{{ status.thread_count }}</el-descriptions-item>
<el-descriptions-item label="线程来源">{{ threadCountSummary }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前汇总线程">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与节点">{{ participatingNodeCount }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与线程">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与服务器">{{ participatingServerCount }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="当前参与进程">{{ aggregateProcessCount }}</el-descriptions-item>
<el-descriptions-item v-if="isAggregateDetectView" label="参与线程上限">{{ aggregateMaxThreadCount }}</el-descriptions-item>
<el-descriptions-item label="代理启用">{{ status.proxy_enable ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="允许直连">{{ status.allow_direct ? "是" : "否" }}</el-descriptions-item>
<el-descriptions-item label="代理池数量">{{ status.proxy_pool_count }}</el-descriptions-item>
@@ -114,7 +116,8 @@
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="待检测" :value="status.progress.pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测中" :value="displayRunningStat" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测通过" :value="status.progress.completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="黑名单" :value="status.progress.blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="当前任务黑名单" :value="status.progress.blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="近窗黑名单" :value="queueSummary.throughput?.blacklisted_recent || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="检测失败" :value="status.progress.failed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="可注册" :value="registerableStat" /></el-col>
</el-row>
@@ -136,9 +139,9 @@
<div class="summary-note">{{ currentPhaseDetail }}</div>
</div>
<div class="summary-card">
<div class="summary-title">活跃线程</div>
<div class="summary-title">参与线程</div>
<div class="summary-value">{{ threadSnapshotActive }} / {{ threadSnapshotMax }}</div>
<div class="summary-note">当前参与节点实际活跃线程 / 配置线程上限</div>
<div class="summary-note">当前参与服务器汇总线程 / 配置线程上限</div>
</div>
<div class="summary-card">
<div class="summary-title">当前速度</div>
@@ -161,9 +164,10 @@
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务待领" :value="activeJob.items_pending || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务已领" :value="activeJobDisplayClaimed" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务执行中" :value="activeJobDisplayRunning" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="活跃线程" :value="threadSnapshotActive" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="参与线程" :value="threadSnapshotActive" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务完成" :value="activeJob.items_completed || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务黑名单" :value="activeJob.items_blacklisted || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="近窗黑名单" :value="queueSummary.throughput?.blacklisted_recent || 0" /></el-col>
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="任务失败" :value="activeJob.items_failed || 0" /></el-col>
</el-row>
@@ -216,25 +220,39 @@
:title="queueAlertText"
/>
<el-table :data="queueSummary.nodes || []" border style="margin-bottom: 16px" empty-text="当前没有可展示的节点吞吐摘要">
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table
:data="queueSummaryServerRows"
border
:max-height="sixRowTableMaxHeight"
style="margin-bottom: 16px"
empty-text="当前没有可展示的服务器吞吐摘要"
>
<el-table-column prop="server_code" label="服务器" min-width="180" />
<el-table-column prop="process_count" label="参与进程" min-width="100" />
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
<el-table-column prop="items_claimed" label="已领未跑" min-width="100" />
<el-table-column prop="processed_recent" label="近窗处理" min-width="100" />
<el-table-column prop="processed_per_minute" label="项/分钟" min-width="100" />
<el-table-column prop="completed_recent" label="完成" min-width="90" />
<el-table-column prop="blacklisted_recent" label="黑名单" min-width="90" />
<el-table-column prop="blacklisted_recent" label="近窗黑名单" min-width="100" />
<el-table-column prop="failed_recent" label="失败" min-width="90" />
</el-table>
</div>
<el-table v-if="activeJobDisplayNodeStats.length" :data="activeJobDisplayNodeStats" border style="margin-bottom: 16px">
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table
v-if="activeJobDisplayServerStats.length"
:data="activeJobDisplayServerStats"
border
:max-height="sixRowTableMaxHeight"
style="margin-bottom: 16px"
>
<el-table-column prop="server_code" label="服务器" min-width="180" />
<el-table-column prop="process_count" label="参与进程" min-width="100" />
<el-table-column prop="items_total" label="任务总数" min-width="100" />
<el-table-column prop="items_pending" label="待领" min-width="90" />
<el-table-column prop="items_claimed" label="已领" min-width="90" />
<el-table-column prop="items_running" label="任务执行中" min-width="100" />
<el-table-column label="活跃线程" min-width="130">
<el-table-column label="参与线程" min-width="130">
<template #default="{ row }">
<span>{{ row.active_threads || 0 }} / {{ row.max_threads || 0 }}</span>
</template>
@@ -287,10 +305,12 @@
<div class="task-detail-meta">
<el-switch v-model="logAutoFollow" inline-prompt active-text="跟随日志" inactive-text="暂停跟随" size="small" />
<el-button text size="small" @click="scrollLogToBottom(true)">回到底部</el-button>
<span>{{ isAggregateDetectView ? "参与节点" : "本机 Worker" }}{{ isAggregateDetectView ? participatingNodeCount : (status.worker_online ? "在线" : "离线") }}</span>
<span>{{ isAggregateDetectView ? "参与服务器" : "本机 Worker" }}{{ isAggregateDetectView ? participatingServerCount : (status.worker_online ? "在线" : "离线") }}</span>
<span v-if="isAggregateDetectView">参与进程{{ aggregateProcessCount }}</span>
<span v-if="isAggregateDetectView">参与线程{{ threadSnapshotActive }}</span>
<span v-if="selectedRun?.phase_label">阶段{{ selectedRun.phase_label }}</span>
<span>运行中{{ displayRunningStat }}</span>
<span>线程{{ runtimeThreadSummary }}</span>
<span>{{ isAggregateDetectView ? "线程上限" : "线程配置" }}{{ runtimeThreadSummary }}</span>
</div>
</div>
<div v-if="selectedRun?.phase_detail" class="phase-detail">{{ selectedRun.phase_detail }}</div>
@@ -322,7 +342,13 @@
</el-table>
</div>
<el-table v-if="phaseHistory.length" :data="phaseHistory" border style="margin-top: 16px">
<el-table
v-if="phaseHistory.length"
:data="phaseHistory"
border
:max-height="sixRowTableMaxHeight"
style="margin-top: 16px"
>
<el-table-column prop="at" label="切换时间" min-width="180" />
<el-table-column prop="label" label="阶段" min-width="120" />
<el-table-column prop="detail" label="阶段说明" min-width="360" show-overflow-tooltip />
@@ -338,6 +364,7 @@ import PageCard from "@/components/PageCard.vue";
import { detectApi, settingsApi } from "@/api/modules";
const DETECT_LAST_ACTION_KEY = "domaincheck:detect:last-action";
const sixRowTableMaxHeight = 336;
const router = useRouter();
const status = ref({
worker_online: false,
@@ -354,6 +381,10 @@ const status = ref({
thread_count_node_code: "",
active_thread_count: 0,
max_thread_count: 0,
aggregate_process_count: 0,
aggregate_participating_node_count: 0,
aggregate_participating_node_codes: [] as string[],
aggregate_max_thread_count: 0,
proxy_enable: false,
allow_direct: false,
proxy_pool_count: 0,
@@ -392,12 +423,77 @@ const status = ref({
}
});
const resolveServerCode = (rawNodeCode?: string) => {
const normalized = String(rawNodeCode || "").trim();
if (!normalized) return "";
const parts = normalized.split("-");
if (parts.length >= 2) {
const suffix = parts[parts.length - 1];
const parent = parts.slice(0, -1).join("-");
if (/^[a-z]{1,3}$/i.test(suffix) && /\d$/.test(parent)) {
return parent;
}
}
return normalized;
};
const aggregateServerRows = (rows: any[]) => {
const grouped = new Map<string, Record<string, any>>();
for (const rawItem of rows || []) {
const serverCode = resolveServerCode(String(rawItem?.node_code || ""));
if (!serverCode || serverCode === "unassigned") continue;
const bucket = grouped.get(serverCode) || {
server_code: serverCode,
process_count: 0,
items_total: 0,
items_pending: 0,
items_claimed: 0,
items_running: 0,
items_completed: 0,
items_blacklisted: 0,
items_failed: 0,
processed_recent: 0,
processed_per_minute: 0,
completed_recent: 0,
blacklisted_recent: 0,
failed_recent: 0,
display_running: 0,
current_load: 0,
active_threads: 0,
max_threads: 0
};
bucket.process_count += 1;
bucket.items_total += Number(rawItem?.items_total || 0);
bucket.items_pending += Number(rawItem?.items_pending || 0);
bucket.items_claimed += Number(rawItem?.items_claimed || 0);
bucket.items_running += Number(rawItem?.items_running || 0);
bucket.items_completed += Number(rawItem?.items_completed || 0);
bucket.items_blacklisted += Number(rawItem?.items_blacklisted || 0);
bucket.items_failed += Number(rawItem?.items_failed || 0);
bucket.processed_recent += Number(rawItem?.processed_recent || 0);
bucket.processed_per_minute = Number((bucket.processed_per_minute + Number(rawItem?.processed_per_minute || 0)).toFixed(2));
bucket.completed_recent += Number(rawItem?.completed_recent || 0);
bucket.blacklisted_recent += Number(rawItem?.blacklisted_recent || 0);
bucket.failed_recent += Number(rawItem?.failed_recent || 0);
bucket.display_running += Number(rawItem?.display_running || 0);
bucket.current_load += Number(rawItem?.current_load || 0);
bucket.active_threads += Number(rawItem?.active_threads || 0);
bucket.max_threads += Number(rawItem?.max_threads || 0);
grouped.set(serverCode, bucket);
}
return [...grouped.values()].sort((a, b) => (
Number(b.processed_recent || 0) - Number(a.processed_recent || 0)
|| Number(b.display_running || 0) - Number(a.display_running || 0)
|| String(a.server_code || "").localeCompare(String(b.server_code || ""))
));
};
const threadCountSummary = computed(() => {
const effective = Number(status.value.thread_count || 0);
const defaultValue = Number(status.value.thread_count_default || 0);
const nodeCode = String(status.value.thread_count_node_code || "").trim();
if (status.value.thread_count_source === "node_override" && status.value.thread_count_override) {
return `${effective}节点 ${nodeCode || "-"} 单独覆盖,默认 ${defaultValue}`;
return `${effective}服务器 ${nodeCode || "-"} 单独覆盖,默认 ${defaultValue}`;
}
return `${effective}(默认)`;
});
@@ -415,7 +511,8 @@ const queueSummary = ref({
},
throughput: {
processed_recent: 0,
processed_per_minute: 0
processed_per_minute: 0,
blacklisted_recent: 0
},
nodes: [] as any[]
});
@@ -466,7 +563,20 @@ const activeJobDisplayClaimed = computed(() => Number(activeJob.value?.display_i
const activeJobDisplayRunning = computed(() => Math.max(
Number(activeJob.value?.items_running ?? 0),
Number(activeJob.value?.display_active_threads ?? 0),
Number(activeJob.value?.display_items_running ?? 0),
));
const aggregateProcessCount = computed(() => {
if (isAggregateDetectView.value) {
return currentParticipatingProcessRows.value.length || Number(status.value.aggregate_process_count || status.value.worker_process_count || 0);
}
return Number(status.value.worker_process_count || 0);
});
const aggregateMaxThreadCount = computed(() => {
if (isAggregateDetectView.value) {
return Number(status.value.aggregate_max_thread_count || 0);
}
return Number(status.value.max_thread_count || status.value.thread_count || 0);
});
const threadSnapshotActive = computed(() => {
const jobValue = Number(activeJob.value?.display_active_threads ?? 0);
if (jobValue > 0) {
@@ -476,6 +586,10 @@ const threadSnapshotActive = computed(() => {
});
const threadSnapshotMax = computed(() => {
const jobValue = Number(activeJob.value?.display_max_threads ?? 0);
const aggregateValue = Number(status.value.aggregate_max_thread_count || 0);
if (aggregateValue > 0) {
return Math.max(jobValue, aggregateValue);
}
if (jobValue > 0) {
return jobValue;
}
@@ -490,9 +604,23 @@ const activeJobDisplayNodeStats = computed(() => {
}
return Array.isArray(activeJob.value?.node_stats) ? activeJob.value.node_stats : [];
});
const participatingNodeCount = computed(() => {
const rows = activeJobDisplayNodeStats.value.filter((item) => String(item?.node_code || "").trim() && String(item?.node_code || "").trim() !== "unassigned");
return rows.length || 0;
const currentParticipatingProcessRows = computed(() =>
activeJobDisplayNodeStats.value.filter((item) => {
const nodeCode = String(item?.node_code || "").trim();
if (!nodeCode || nodeCode === "unassigned") return false;
return [
Number(item?.items_claimed || 0),
Number(item?.items_running || 0),
Number(item?.display_running || 0),
Number(item?.current_load || 0),
Number(item?.active_threads || 0)
].some((value) => value > 0);
})
);
const activeJobDisplayServerStats = computed(() => aggregateServerRows(activeJobDisplayNodeStats.value));
const queueSummaryServerRows = computed(() => aggregateServerRows(Array.isArray(queueSummary.value.nodes) ? queueSummary.value.nodes : []));
const participatingServerCount = computed(() => {
return activeJobDisplayServerStats.value.length || Number(status.value.aggregate_participating_node_count || 0) || 0;
});
const queueProcessedPerMinute = computed(() => Number(queueSummary.value.throughput?.processed_per_minute || 0));
const queueProcessedPerHour = computed(() => Number((queueProcessedPerMinute.value * 60).toFixed(2)));
@@ -521,7 +649,7 @@ const estimatedRemainingText = computed(() => {
});
const runtimeThreadSummary = computed(() => {
if (isAggregateDetectView.value) {
return `${threadSnapshotActive.value} / ${threadSnapshotMax.value}(参与节点汇总)`;
return `${threadSnapshotActive.value} / ${threadSnapshotMax.value}(参与服务器汇总)`;
}
return threadCountSummary.value;
});
@@ -532,16 +660,32 @@ const activeJobSummaryText = computed(() => {
const job = activeJob.value;
const displayClaimed = Number(job.display_items_claimed ?? job.items_claimed ?? 0);
const displayRunning = activeJobDisplayRunning.value;
const displayActiveThreads = Number(job.display_active_threads ?? 0);
const displayMaxThreads = Number(job.display_max_threads ?? 0);
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 已领 ${displayClaimed} / 实时执行 ${displayRunning} / 活跃线程 ${displayActiveThreads}/${displayMaxThreads} / 速度 ${queueProcessedPerMinute.value} 项/分钟 / 预计剩余 ${estimatedRemainingText.value}`;
const displayActiveThreads = threadSnapshotActive.value;
const displayMaxThreads = threadSnapshotMax.value;
return `状态:${jobStatusText(job.status)} / 总数 ${job.items_total || 0} / 待领 ${job.items_pending || 0} / 已领 ${displayClaimed} / 实时执行 ${displayRunning} / 参与线程 ${displayActiveThreads}/${displayMaxThreads} / 速度 ${queueProcessedPerMinute.value} 项/分钟 / 预计剩余 ${estimatedRemainingText.value}`;
});
const selectedRun = computed(() => {
if (isAggregateDetectView.value) {
return null;
}
return runs.value.find((item) => item.run_id === selectedRunId.value) || runs.value[0] || null;
});
const currentPhaseLabel = computed(() => {
if (selectedRun.value?.phase_label) {
return selectedRun.value.phase_label;
}
if (isAggregateDetectView.value && activeJob.value?.job_code) {
return "集群执行中";
}
return status.value.worker_online ? "运行中" : "未启动";
});
const selectedRun = computed(() => runs.value.find((item) => item.run_id === selectedRunId.value) || runs.value[0] || null);
const currentPhaseLabel = computed(() => selectedRun.value?.phase_label || (status.value.worker_online ? "运行中" : "未启动"));
const currentPhaseDetail = computed(() => {
if (selectedRun.value?.phase_detail) {
return selectedRun.value.phase_detail;
}
if (isAggregateDetectView.value && activeJob.value?.job_code) {
return status.value.recent_event || `当前汇总执行由 ${participatingServerCount.value} 台服务器、${aggregateProcessCount.value} 个进程承担。`;
}
if (status.value.recent_event) {
return status.value.recent_event;
}
@@ -629,7 +773,7 @@ const remoteLogMirrorText = computed(() => {
});
const detectScopeSummaryText = computed(() => {
if (isAggregateDetectView.value) {
return `当前检测状态:${currentPhaseLabel};当前页展示的是多节点汇总运行态,参与节点 ${participatingNodeCount.value} 台,实时活跃线程 ${threadSnapshotActive.value}/${threadSnapshotMax.value}`;
return `当前检测状态:${currentPhaseLabel};当前页展示的是多服务器汇总运行态,参与服务器 ${participatingServerCount.value} 台,参与进程 ${aggregateProcessCount.value} 个,参与线程 ${threadSnapshotActive.value}/${threadSnapshotMax.value}`;
}
return `当前检测状态:${currentPhaseLabel};本机 Worker ${status.value.worker_online ? "在线" : "离线"}`;
});

View File

@@ -1,5 +1,5 @@
<template>
<PageCard title="托管节点" description="专门用于纳管器、维护 SSH 信息、启停节点。运维中枢继续负责总驾驶舱、任务、发布和巡检。">
<PageCard title="服务器管理" description="专门用于纳管服务器、维护 SSH 信息、启停机器。运维中枢继续负责总驾驶舱、任务、发布和巡检。">
<template #header-extra>
<div class="header-actions">
<el-button plain :loading="loading.syncing" @click="syncNodesFromCluster">同步节点</el-button>
@@ -14,12 +14,12 @@
show-icon
type="info"
style="margin-bottom: 16px"
title="这里是独立的节点管理入口。后面你新增机器、填 SSH、启用/停用节点,都从这里做,不用再去运维中枢长页面里找。"
title="这里是独立的服务器管理入口。后面你新增机器、填 SSH、启用/停用服务器,都从这里做,不用再去运维中枢长页面里找。"
/>
<div class="summary-grid">
<div class="summary-card">
<span class="summary-label">节点</span>
<span class="summary-label">服务器</span>
<strong>{{ summary.total }}</strong>
<span class="summary-note">已纳管 {{ summary.managedTotal }} / 已启用 {{ summary.enabled }}</span>
</div>
@@ -29,14 +29,14 @@
<span class="summary-note">stale {{ summary.stale }} / 待接入 {{ summary.pendingBootstrap }}</span>
</div>
<div class="summary-card">
<span class="summary-label">执行节点</span>
<span class="summary-label">执行服务器</span>
<strong>{{ summary.participating }}</strong>
<span class="summary-note">在线待命 {{ summary.standby }} / 死信 {{ summary.queueDeadLetterNodes }}</span>
</div>
</div>
<el-table :data="nodes" stripe size="small" height="100%">
<el-table-column label="节点" min-width="220">
<el-table-column label="服务器" min-width="220">
<template #default="{ row }">
<div class="node-cell">
<strong>{{ row.node_code || "-" }}</strong>
@@ -89,7 +89,7 @@
</el-table-column>
</el-table>
<el-dialog v-model="managedNodeVisible" :title="managedNodeEditing ? '编辑托管节点' : '新增托管节点'" width="760px">
<el-dialog v-model="managedNodeVisible" :title="managedNodeEditing ? '编辑服务器' : '新增服务器'" width="760px">
<el-form label-position="top" :model="managedNodeForm" class="dialog-form">
<div class="dialog-grid">
<el-form-item label="节点编码">

View File

@@ -0,0 +1,818 @@
<template>
<PageCard title="迁移向导" description="目标机已先手动 clone 代码后,在这里复用托管节点 SSH 信息做预检查、计划预览和迁移执行。">
<template #header-extra>
<div class="header-actions">
<el-button plain :loading="loading.nodes" @click="loadNodes">刷新节点</el-button>
<el-button plain :loading="loading.source" @click="loadSourceProfile">刷新源信息</el-button>
</div>
</template>
<el-alert
:closable="false"
type="info"
show-icon
title="当前 MVP 只覆盖“海外中控机迁到一台已 clone 代码的新机器”场景。数据库覆盖默认关闭,建议先预检、看计划,再执行。"
style="margin-bottom: 16px"
/>
<el-alert
v-if="!migrationApiSupported"
:closable="false"
type="warning"
show-icon
:title="backendCompatibility.message || defaultUnavailableMessage"
style="margin-bottom: 16px"
/>
<div class="section-grid">
<div class="summary-card">
<span class="summary-label">源节点</span>
<strong>{{ sourceProfile.source_node?.node_code || "-" }}</strong>
<span class="summary-note">
{{ sourceProfile.source_node?.region || "-" }} / {{ sourceProfile.source_node?.role || "-" }} / {{ sourceProfile.source_node?.worker_mode || "-" }}
</span>
</div>
<div class="summary-card">
<span class="summary-label">源数据库</span>
<strong>{{ sourceProfile.database?.database || "-" }}</strong>
<span class="summary-note">
{{ sourceProfile.database?.user || "-" }} @ {{ sourceProfile.database?.host || "-" }}:{{ sourceProfile.database?.port || "-" }}
</span>
</div>
<div class="summary-card">
<span class="summary-label">目标节点</span>
<strong>{{ currentTargetSummary }}</strong>
<span class="summary-note">{{ currentTargetSshSummary }}</span>
</div>
</div>
<el-form label-position="top" :model="form" class="migration-form">
<div class="form-grid">
<el-form-item label="目标托管节点">
<el-select v-model="form.target_node_code" filterable placeholder="请选择已配置 SSH 的托管节点">
<el-option
v-for="item in targetNodes"
:key="item.node_code"
:label="`${item.node_code} (${item.ssh_user || '-'}@${item.ssh_host || '-'})`"
:value="item.node_code"
/>
</el-select>
</el-form-item>
<el-form-item label="目标仓库路径">
<el-input v-model="form.target_repo_path" placeholder="/www/wwwroot/getDomain" />
</el-form-item>
<el-form-item label="目标 domainRoot">
<el-input v-model="form.target_domain_root" placeholder="/opt/domaincheck/domainCheck" />
</el-form-item>
<el-form-item label="目标 apiRoot">
<el-input v-model="form.target_api_root" placeholder="/opt/domaincheck/domain-api" />
</el-form-item>
<el-form-item label="目标 webRoot">
<el-input v-model="form.target_web_root" placeholder="/opt/domaincheck/domain-web" />
</el-form-item>
</div>
<div class="toggle-grid">
<el-switch v-model="form.sync_env_files" active-text="同步 env 配置" inactive-text="跳过 env 配置" />
<el-switch v-model="form.sync_systemd_units" active-text="同步 systemd" inactive-text="跳过 systemd" />
<el-switch v-model="form.build_frontend" active-text="远端重建前端" inactive-text="跳过前端构建" />
<el-switch v-model="form.restart_services" active-text="执行后重启服务" inactive-text="执行后不重启服务" />
<el-switch v-model="form.overwrite_database" active-text="覆盖目标数据库" inactive-text="不覆盖目标数据库" />
<el-switch
v-model="form.backup_target_database"
:disabled="!form.overwrite_database"
active-text="覆盖前先备份目标库"
inactive-text="跳过目标库备份"
/>
</div>
<div v-if="form.overwrite_database" class="form-grid" style="margin-top: 8px">
<el-form-item label="目标 DB Host">
<el-input v-model="form.target_db_host" placeholder="留空则尝试从目标机 .env 自动识别" />
</el-form-item>
<el-form-item label="目标 DB Port">
<el-input-number v-model="form.target_db_port" :min="0" :max="65535" />
</el-form-item>
<el-form-item label="目标 DB Name">
<el-input v-model="form.target_db_name" placeholder="留空则尝试从目标机 .env 自动识别" />
</el-form-item>
<el-form-item label="目标 DB User">
<el-input v-model="form.target_db_user" placeholder="留空则尝试从目标机 .env 自动识别" />
</el-form-item>
<el-form-item label="目标 DB Password" class="full-width">
<el-input v-model="form.target_db_password" type="password" show-password placeholder="留空则尝试从目标机 .env 自动识别" />
</el-form-item>
</div>
</el-form>
<el-alert
v-if="previewStale"
:closable="false"
type="warning"
show-icon
title="表单参数已变化,当前预检令牌已失效,请重新执行“预检查并生成计划”。"
style="margin-bottom: 16px"
/>
<div class="action-row">
<el-button type="primary" plain :disabled="!migrationApiSupported" :loading="loading.preview" @click="previewMigration">预检查并生成计划</el-button>
<el-button type="danger" plain :disabled="!migrationApiSupported || !canExecute" :loading="loading.execute" @click="executeMigration">执行迁移</el-button>
</div>
<div v-if="previewResult" class="result-section">
<div class="result-header">
<strong>预检查结果</strong>
<el-tag :type="previewResult.code === 0 ? 'success' : 'danger'">{{ previewResult.message || "预检查完成" }}</el-tag>
</div>
<div v-if="previewBlockingReasons.length" class="preview-list">
<div v-for="item in previewBlockingReasons" :key="`blocking-${item}`" class="preview-list__item preview-list__item--danger">{{ item }}</div>
</div>
<div v-if="previewWarnings.length" class="preview-list">
<div v-for="item in previewWarnings" :key="`warning-${item}`" class="preview-list__item preview-list__item--warning">{{ item }}</div>
</div>
<div class="inspection-summary-grid" style="margin-top: 12px">
<div class="mini-card">
<span class="summary-label">目标 Git Commit</span>
<strong>{{ previewData.remote_checks?.git_commit || "-" }}</strong>
<span class="summary-note">预检读取目标仓库 HEAD</span>
</div>
<div class="mini-card">
<span class="summary-label">目标数据库</span>
<strong>{{ previewData.target_db_config?.database || "-" }}</strong>
<span class="summary-note">
{{ previewData.target_db_config?.user || "-" }} @ {{ previewData.target_db_config?.host || "-" }}:{{ previewData.target_db_config?.port || "-" }}
</span>
</div>
<div class="mini-card">
<span class="summary-label">目标库现状</span>
<strong>{{ targetDbInspection.available ? (targetDbInspection.has_business_data ? "非空库" : "近似空库") : "未识别" }}</strong>
<span class="summary-note">
{{ targetDbInspection.public_table_count || 0 }} / 业务表 {{ targetDbInspection.business_table_count || 0 }} / 估算行 {{ targetDbInspection.approx_total_rows || 0 }}
</span>
</div>
<div class="mini-card">
<span class="summary-label">工具面</span>
<strong>{{ readyToolCount }} / {{ totalToolCount }}</strong>
<span class="summary-note">python3 / node / npm / systemctl / psql / pg_dump / curl</span>
</div>
</div>
<div class="result-grid">
<div class="preview-box">
<div class="preview-box__title">执行计划</div>
<div class="preview-list">
<div v-for="step in previewPlanSteps" :key="step.key" class="preview-list__item">{{ step.title }}</div>
</div>
</div>
<div class="preview-box">
<div class="preview-box__title">目标机检查</div>
<div class="preview-list">
<div class="preview-list__item">repo_exists: {{ boolLabel(previewData.remote_checks?.paths?.repo_exists) }}</div>
<div class="preview-list__item">repo_git: {{ boolLabel(previewData.remote_checks?.paths?.repo_git) }}</div>
<div class="preview-list__item">domain_root_exists: {{ boolLabel(previewData.remote_checks?.paths?.domain_root_exists) }}</div>
<div class="preview-list__item">api_root_exists: {{ boolLabel(previewData.remote_checks?.paths?.api_root_exists) }}</div>
<div class="preview-list__item">web_root_exists: {{ boolLabel(previewData.remote_checks?.paths?.web_root_exists) }}</div>
</div>
</div>
</div>
<div v-if="requiredConfirmationText" class="preview-box" style="margin-top: 12px">
<div class="preview-box__title">高风险确认</div>
<div class="preview-list" style="margin-bottom: 12px">
<div class="preview-list__item preview-list__item--warning">
目标库探测到已有业务数据执行前需要输入确认文案<strong>{{ requiredConfirmationText }}</strong>
</div>
</div>
<el-input
v-model="form.execute_confirmation_text"
:placeholder="`请输入 ${requiredConfirmationText}`"
clearable
/>
</div>
</div>
<div v-if="executeResult" class="result-section">
<div class="result-header">
<strong>提交结果</strong>
<el-tag :type="executeResult.code === 0 ? 'success' : 'danger'">{{ executeResult.message || "执行完成" }}</el-tag>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="任务号">{{ activeMigrationJob.job_code || executeResult.data?.job?.job_code || "-" }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="jobStatusType(activeMigrationJob.status || executeResult.data?.job?.status || '')" size="small">
{{ jobStatusLabel(activeMigrationJob.status || executeResult.data?.job?.status || "") }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="目标节点">{{ activeMigrationJob.target_node_code || executeResult.data?.job?.target_node_code || "-" }}</el-descriptions-item>
<el-descriptions-item label="最近更新">{{ activeMigrationJob.updated_at || activeMigrationJob.finished_at || "-" }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="activeMigrationJob.id" class="result-section">
<div class="result-header">
<strong>后台迁移任务</strong>
<div class="header-actions">
<el-button plain size="small" :loading="loading.jobDetail || loading.jobEvents" @click="refreshMigrationJobProgress">刷新日志</el-button>
<el-button type="primary" plain size="small" @click="migrationLogVisible = true">查看日志窗口</el-button>
</div>
</div>
<div class="inspection-summary-grid" style="margin-bottom: 12px">
<div class="mini-card">
<span class="summary-label">任务号</span>
<strong>{{ activeMigrationJob.job_code || "-" }}</strong>
<span class="summary-note">ID {{ activeMigrationJob.id || "-" }}</span>
</div>
<div class="mini-card">
<span class="summary-label">状态</span>
<strong>{{ jobStatusLabel(activeMigrationJob.status || "") }}</strong>
<span class="summary-note">{{ activeMigrationJob.updated_at || activeMigrationJob.finished_at || activeMigrationJob.created_at || "-" }}</span>
</div>
<div class="mini-card">
<span class="summary-label">事件流</span>
<strong>{{ migrationJobEventStream.length }}</strong>
<span class="summary-note">最近 {{ migrationJobEventsSummary.latest_at || "-" }}</span>
</div>
<div class="mini-card">
<span class="summary-label">自动刷新</span>
<strong>{{ migrationJobIsTerminal ? "已停止" : "运行中" }}</strong>
<span class="summary-note">{{ migrationJobIsTerminal ? "任务已收口" : "每 2 秒刷新一次" }}</span>
</div>
</div>
<el-alert
v-if="activeMigrationJob.error_message"
:closable="false"
type="error"
show-icon
style="margin-bottom: 12px"
>
{{ activeMigrationJob.error_message }}
</el-alert>
<el-table v-if="executionSteps.length" :data="executionSteps" stripe size="small">
<el-table-column prop="title" label="步骤" min-width="180" />
<el-table-column label="结果" width="100">
<template #default="{ row }">
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{ row.ok ? "成功" : "失败" }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="240" />
</el-table>
</div>
<el-drawer v-model="migrationLogVisible" title="迁移执行日志" size="880px">
<template #default>
<div class="drawer-meta">
<span>任务{{ activeMigrationJob.job_code || "-" }}</span>
<span>状态{{ jobStatusLabel(activeMigrationJob.status || "") }}</span>
<span>节点{{ activeMigrationJob.target_node_code || "-" }}</span>
<span>事件{{ migrationJobEventStream.length }}</span>
</div>
<div class="log-console">
<div v-for="item in migrationJobEventStream" :key="item.id || `${item.event_type}-${item.created_at}`" class="log-entry">
<div class="log-entry__meta">
<span>{{ item.created_at || "-" }}</span>
<el-tag :type="eventLevelType(item.level || 'info')" size="small">{{ item.level_label || item.level || "-" }}</el-tag>
<span>{{ item.event_type || "-" }}</span>
</div>
<div class="log-entry__message">{{ item.summary || item.message || "-" }}</div>
<pre v-if="migrationEventDetailText(item)" class="log-entry__detail">{{ migrationEventDetailText(item) }}</pre>
</div>
<div v-if="!migrationJobEventStream.length" class="preview-box">
<div class="preview-list__item">当前还没有新的迁移事件日志</div>
</div>
</div>
</template>
</el-drawer>
</PageCard>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import PageCard from "@/components/PageCard.vue";
import { opsApi } from "@/api/modules";
type GenericRecord = Record<string, any>;
const loading = ref({
nodes: false,
source: false,
preview: false,
execute: false,
jobDetail: false,
jobEvents: false
});
const sourceProfile = ref<GenericRecord>({});
const targetNodes = ref<GenericRecord[]>([]);
const previewResult = ref<GenericRecord | null>(null);
const executeResult = ref<GenericRecord | null>(null);
const activeMigrationJob = ref<GenericRecord>({});
const migrationJobEvents = ref<GenericRecord[]>([]);
const migrationJobEventsSummary = ref<GenericRecord>({});
const migrationLogVisible = ref(false);
const lastPreviewPayloadSignature = ref("");
const defaultUnavailableMessage = "当前后端还没部署迁移向导接口,请先发布或重启 domaincheck-api 到最新版本。";
const backendCompatibility = ref({
migrationApiSupported: true,
message: ""
});
let migrationJobPollTimer: ReturnType<typeof setInterval> | null = null;
const form = ref({
target_node_code: "",
target_repo_path: "/www/wwwroot/getDomain",
target_domain_root: "/opt/domaincheck/domainCheck",
target_api_root: "/opt/domaincheck/domain-api",
target_web_root: "/opt/domaincheck/domain-web",
sync_env_files: true,
sync_systemd_units: true,
build_frontend: true,
restart_services: true,
overwrite_database: false,
backup_target_database: true,
target_db_host: "",
target_db_port: 0,
target_db_name: "",
target_db_user: "",
target_db_password: "",
execute_confirmation_text: ""
});
const currentTarget = computed(
() => targetNodes.value.find((item) => String(item.node_code || "") === String(form.value.target_node_code || "")) || {}
);
const migrationApiSupported = computed(() => backendCompatibility.value.migrationApiSupported);
const currentTargetSummary = computed(() => String(currentTarget.value.node_code || "-"));
const currentTargetSshSummary = computed(() => {
const user = String(currentTarget.value.ssh_user || "-").trim() || "-";
const host = String(currentTarget.value.ssh_host || "-").trim() || "-";
const port = Number(currentTarget.value.ssh_port || 22) || 22;
return `${user}@${host}:${port}`;
});
const previewData = computed(() => previewResult.value?.data || {});
const previewPlanSteps = computed(() => Array.isArray(previewData.value?.plan_steps) ? previewData.value.plan_steps : []);
const previewBlockingReasons = computed(() => Array.isArray(previewData.value?.blocking_reasons) ? previewData.value.blocking_reasons : []);
const previewWarnings = computed(() => Array.isArray(previewData.value?.warnings) ? previewData.value.warnings : []);
const readyToolCount = computed(() => Object.values(previewData.value?.remote_checks?.tools || {}).filter(Boolean).length);
const totalToolCount = computed(() => Object.keys(previewData.value?.remote_checks?.tools || {}).length);
const requiredConfirmationText = computed(() => String(previewData.value?.execution_guard?.required_confirmation_text || "").trim());
const previewToken = computed(() => String(previewData.value?.execution_guard?.token || "").trim());
const targetDbInspection = computed(() => previewData.value?.target_db_inspection || {});
const basePayloadSignature = computed(() => JSON.stringify(buildBasePayload()));
const previewStale = computed(
() => Boolean(previewResult.value) && lastPreviewPayloadSignature.value !== "" && lastPreviewPayloadSignature.value !== basePayloadSignature.value
);
const canExecute = computed(
() =>
previewResult.value?.code === 0 &&
migrationApiSupported.value &&
!loading.value.execute &&
Boolean(previewToken.value) &&
!previewStale.value &&
(!requiredConfirmationText.value || form.value.execute_confirmation_text.trim() === requiredConfirmationText.value)
);
const executionSteps = computed(() =>
Array.isArray(activeMigrationJob.value?.result?.execution_steps)
? activeMigrationJob.value.result.execution_steps
: (Array.isArray(executeResult.value?.data?.execution_steps) ? executeResult.value?.data.execution_steps : [])
);
const migrationJobEventStream = computed(() => [...migrationJobEvents.value].reverse());
const migrationJobIsTerminal = computed(() => {
const status = String(activeMigrationJob.value?.status || "").trim();
return ["success", "failed", "blocked", "cancelled", "completed_with_issues", "partially_succeeded"].includes(status);
});
const boolLabel = (value: unknown) => (value ? "是" : "否");
const formatJsonBlock = (value: unknown) => {
if (value === null || typeof value === "undefined" || value === "") {
return "";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const jobStatusType = (status: string) => {
if (status === "success") return "success";
if (status === "failed" || status === "blocked" || status === "cancelled") return "danger";
if (status === "running" || status === "dispatching" || status === "awaiting_approval") return "warning";
return "info";
};
const jobStatusLabel = (status: string) => {
if (status === "success") return "成功";
if (status === "failed") return "失败";
if (status === "blocked") return "阻断";
if (status === "cancelled") return "已取消";
if (status === "running") return "执行中";
if (status === "dispatching") return "派发中";
if (status === "awaiting_approval") return "待审批";
if (status === "queued") return "排队中";
if (status === "completed_with_issues") return "带问题完成";
if (status === "partially_succeeded") return "部分成功";
return status || "未知";
};
const eventLevelType = (level: string) => {
if (level === "success") return "success";
if (level === "warning") return "warning";
if (level === "error" || level === "critical") return "danger";
return "info";
};
const migrationEventDetailText = (row: GenericRecord) => {
const payload = row?.payload || {};
const chunks: string[] = [];
if (payload?.step_key || payload?.title) {
chunks.push(`step: ${payload.step_key || "-"} / ${payload.title || "-"}`);
}
if (payload?.stdout) {
chunks.push(`stdout:\n${String(payload.stdout).trim()}`);
}
if (payload?.stderr) {
chunks.push(`stderr:\n${String(payload.stderr).trim()}`);
}
if (!payload?.stdout && !payload?.stderr) {
const compact = { ...payload };
delete compact.occurred_at;
delete compact.summary_text;
if (Object.keys(compact).length) {
chunks.push(formatJsonBlock(compact));
}
}
return chunks.filter(Boolean).join("\n\n").trim();
};
const clearMigrationApiCompatibilityWarning = () => {
backendCompatibility.value = {
migrationApiSupported: true,
message: ""
};
};
const markMigrationApiUnavailable = (message = defaultUnavailableMessage) => {
backendCompatibility.value = {
migrationApiSupported: false,
message
};
};
const extractMigrationErrorMessage = (error: any, fallback: string) => {
const responseStatus = Number(error?.response?.status || 0);
if (responseStatus === 404) {
return defaultUnavailableMessage;
}
const apiMessage = String(error?.response?.data?.message || error?.message || "").trim();
if (apiMessage) {
return apiMessage;
}
return fallback;
};
const buildBasePayload = () => ({
target_node_code: form.value.target_node_code,
target_repo_path: form.value.target_repo_path,
target_domain_root: form.value.target_domain_root,
target_api_root: form.value.target_api_root,
target_web_root: form.value.target_web_root,
sync_env_files: form.value.sync_env_files,
sync_systemd_units: form.value.sync_systemd_units,
build_frontend: form.value.build_frontend,
restart_services: form.value.restart_services,
overwrite_database: form.value.overwrite_database,
backup_target_database: form.value.backup_target_database,
target_db_host: form.value.target_db_host,
target_db_port: form.value.target_db_port,
target_db_name: form.value.target_db_name,
target_db_user: form.value.target_db_user,
target_db_password: form.value.target_db_password
});
const buildPayload = () => ({
...buildBasePayload(),
execute_confirmation_token: previewToken.value,
execute_confirmation_text: form.value.execute_confirmation_text.trim()
});
const stopMigrationJobPolling = () => {
if (migrationJobPollTimer) {
clearInterval(migrationJobPollTimer);
migrationJobPollTimer = null;
}
};
const refreshMigrationJobProgress = async (options: { silent?: boolean } = {}) => {
const normalizedJobId = Number(activeMigrationJob.value?.id || executeResult.value?.data?.job?.id || 0);
if (!normalizedJobId) {
return;
}
const { silent = false } = options;
loading.value.jobDetail = true;
loading.value.jobEvents = true;
try {
const [detailResponse, eventsResponse] = await Promise.all([
opsApi.jobDetail(normalizedJobId),
opsApi.jobEvents(normalizedJobId, { limit: 200 })
]);
activeMigrationJob.value = detailResponse.data || {};
migrationJobEvents.value = Array.isArray(eventsResponse.data?.events) ? eventsResponse.data.events : [];
migrationJobEventsSummary.value = eventsResponse.data?.summary || {};
if (migrationJobIsTerminal.value) {
stopMigrationJobPolling();
}
} catch (error: any) {
if (!silent) {
ElMessage.error(error?.message || "读取迁移任务日志失败");
}
} finally {
loading.value.jobDetail = false;
loading.value.jobEvents = false;
}
};
const startMigrationJobPolling = () => {
stopMigrationJobPolling();
void refreshMigrationJobProgress({ silent: true });
migrationJobPollTimer = setInterval(() => {
void refreshMigrationJobProgress({ silent: true });
}, 2000);
};
const loadNodes = async () => {
loading.value.nodes = true;
try {
const response = await opsApi.nodes();
const nodes = Array.isArray(response.data?.nodes) ? response.data.nodes : [];
targetNodes.value = nodes.filter((item: GenericRecord) => Boolean(item.ssh_host) && Boolean(item.ssh_user));
if (!form.value.target_node_code && targetNodes.value.length) {
form.value.target_node_code = String(targetNodes.value[0].node_code || "");
}
} finally {
loading.value.nodes = false;
}
};
const loadSourceProfile = async () => {
loading.value.source = true;
try {
const response = await opsApi.migrationSourceProfile();
sourceProfile.value = response.data || {};
clearMigrationApiCompatibilityWarning();
} catch (error: any) {
const message = extractMigrationErrorMessage(error, "读取迁移源信息失败");
sourceProfile.value = {};
if (Number(error?.response?.status || 0) === 404) {
markMigrationApiUnavailable(message);
ElMessage.warning(message);
return;
}
ElMessage.error(message);
} finally {
loading.value.source = false;
}
};
const previewMigration = async () => {
loading.value.preview = true;
executeResult.value = null;
activeMigrationJob.value = {};
migrationJobEvents.value = [];
migrationJobEventsSummary.value = {};
stopMigrationJobPolling();
form.value.execute_confirmation_text = "";
try {
previewResult.value = await opsApi.previewMigration(buildBasePayload());
lastPreviewPayloadSignature.value = basePayloadSignature.value;
clearMigrationApiCompatibilityWarning();
if (previewResult.value.code === 0) {
ElMessage.success("迁移计划已生成");
}
} catch (error: any) {
const message = extractMigrationErrorMessage(error, "预检查未通过");
previewResult.value = error?.code
? error
: { code: 1, message, data: { blocking_reasons: [message] } };
lastPreviewPayloadSignature.value = basePayloadSignature.value;
if (Number(error?.response?.status || 0) === 404) {
markMigrationApiUnavailable(message);
}
ElMessage.warning(message);
} finally {
loading.value.preview = false;
}
};
const executeMigration = async () => {
loading.value.execute = true;
try {
executeResult.value = await opsApi.executeMigration(buildPayload());
clearMigrationApiCompatibilityWarning();
if (executeResult.value.code === 0) {
activeMigrationJob.value = executeResult.value.data?.job || {};
migrationLogVisible.value = true;
startMigrationJobPolling();
ElMessage.success(executeResult.value.message || "迁移任务已提交");
}
} catch (error: any) {
const message = extractMigrationErrorMessage(error, "迁移执行失败");
executeResult.value = error?.code
? error
: { code: 1, message, data: { blocking_reasons: [message] } };
if (Number(error?.response?.status || 0) === 404) {
markMigrationApiUnavailable(message);
ElMessage.warning(message);
return;
}
ElMessage.error(message);
} finally {
loading.value.execute = false;
}
};
onMounted(async () => {
await Promise.all([loadNodes(), loadSourceProfile()]);
});
onBeforeUnmount(() => {
stopMigrationJobPolling();
});
</script>
<style scoped lang="scss">
.header-actions,
.action-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.section-grid,
.result-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.inspection-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.summary-card,
.mini-card {
padding: 16px;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 6px;
}
.summary-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.summary-note {
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.migration-form {
margin-bottom: 18px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.toggle-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
margin-top: 6px;
}
.full-width {
grid-column: 1 / -1;
}
.result-section {
margin-top: 18px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.preview-box {
padding: 16px;
border-radius: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.preview-box__title {
font-weight: 600;
margin-bottom: 10px;
}
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-list__item {
padding: 10px 12px;
border-radius: 12px;
background: #ffffff;
border: 1px solid #e2e8f0;
color: #334155;
}
.preview-list__item--danger {
border-color: #fecaca;
background: #fff1f2;
color: #991b1b;
}
.preview-list__item--warning {
border-color: #fde68a;
background: #fffbeb;
color: #92400e;
}
.drawer-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: #64748b;
font-size: 13px;
margin-bottom: 12px;
}
.log-console {
display: flex;
flex-direction: column;
gap: 12px;
max-height: calc(100vh - 180px);
overflow: auto;
padding-right: 4px;
}
.log-entry {
padding: 14px;
border-radius: 14px;
border: 1px solid #dbe3ef;
background: #0f172a;
color: #e2e8f0;
}
.log-entry__meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
color: #94a3b8;
font-size: 12px;
}
.log-entry__message {
white-space: pre-wrap;
line-height: 1.6;
}
.log-entry__detail {
margin: 10px 0 0;
padding: 12px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(148, 163, 184, 0.24);
color: #cbd5e1;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.55;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -9,29 +9,49 @@
<PageCard title="运行配置">
<el-form label-position="top">
<el-alert
:title="currentNodeCode ? `支持默认并发和节点单独覆盖;当前后台节点编码:${currentNodeCode}` : '支持默认并发和节点单独覆盖:未单独配置的器自动走默认线程数。'"
:title="currentNodeCode ? `支持默认进程/线程和服务器级覆盖;当前后台节点编码:${currentNodeCode}` : '支持默认进程/线程和服务器级覆盖:未单独配置的服务器自动走默认。'"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<el-form-item label="默认 Worker 进程数">
<el-input-number v-model="processCount" :min="1" />
</el-form-item>
<el-form-item label="服务器独立进程覆盖">
<div class="node-thread-overrides">
<div v-if="!nodeProcessOverrides.length" class="node-thread-empty">
当前没有服务器级单独覆盖配置所有服务器都会走默认进程数
</div>
<div v-for="(item, index) in nodeProcessOverrides" :key="`${item.node_code}-process-${index}`" class="node-thread-row">
<el-input v-model="item.node_code" placeholder="服务器编码,例如 mainland-controller-01" />
<el-input-number v-model="item.process_count" :min="1" />
<el-button type="danger" plain @click="removeNodeProcessOverride(index)">删除</el-button>
</div>
<div class="node-thread-actions">
<el-button plain @click="addNodeProcessOverride">新增服务器覆盖</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="默认检测线程数">
<el-input-number v-model="threadCount" :min="1" />
</el-form-item>
<el-form-item label="节点独立线程覆盖">
<el-form-item label="服务器独立线程覆盖">
<div class="node-thread-overrides">
<div v-if="!nodeThreadOverrides.length" class="node-thread-empty">
当前没有单独覆盖配置所有节点都会走默认线程数
当前没有服务器级单独覆盖配置所有服务器都会走默认线程数
</div>
<div v-for="(item, index) in nodeThreadOverrides" :key="`${item.node_code}-${index}`" class="node-thread-row">
<el-input v-model="item.node_code" placeholder="节点编码,例如 mainland-worker-01" />
<el-input v-model="item.node_code" placeholder="服务器编码,例如 mainland-worker-01" />
<el-input-number v-model="item.thread_count" :min="1" />
<el-button type="danger" plain @click="removeNodeThreadOverride(index)">删除</el-button>
</div>
<div class="node-thread-actions">
<el-button plain @click="addNodeThreadOverride">新增节点覆盖</el-button>
<el-button plain @click="addNodeThreadOverride">新增服务器覆盖</el-button>
</div>
</div>
</el-form-item>
@@ -71,7 +91,7 @@
type="info"
show-icon
style="margin-top: 12px"
title="关闭时不追加远端日志镜像;关键模式回传阶段/代理/异常等关键过程;全量模式会追加更多执行过程,便于临时分析。"
title="默认会按服务器级别统一下发80 进程、每进程 1000 线程;这里只保留服务器级覆盖,不建议人工管理进程实例级并发。关闭时不追加远端日志镜像;关键模式回传阶段/代理/异常等关键过程;全量模式会追加更多执行过程,便于临时分析。"
/>
</el-form>
</PageCard>
@@ -223,6 +243,25 @@ type NodeThreadOverrideRow = {
thread_count: number;
};
type NodeProcessOverrideRow = {
node_code: string;
process_count: number;
};
const resolveServerCode = (rawNodeCode: string) => {
const normalized = String(rawNodeCode || "").trim();
if (!normalized) return "";
const parts = normalized.split("-");
if (parts.length >= 2) {
const suffix = parts[parts.length - 1];
const parent = parts.slice(0, -1).join("-");
if (/^[a-z]{1,3}$/i.test(suffix) && /\d$/.test(parent)) {
return parent;
}
}
return normalized;
};
const DETECT_LABELS: Record<string, string> = {
detect_register: "检查注册",
detect_baidu_site: "百度 site 查询",
@@ -235,9 +274,11 @@ const DETECT_LABELS: Record<string, string> = {
};
const loading = ref(true);
const threadCount = ref(2);
const processCount = ref(80);
const threadCount = ref(1000);
const currentNodeCode = ref("");
const nodeThreadOverrides = ref<NodeThreadOverrideRow[]>([]);
const nodeProcessOverrides = ref<NodeProcessOverrideRow[]>([]);
const detectItems = ref<DetectItem[]>([]);
const backups = ref<BackupRecord[]>([]);
const proxyConfig = ref<Record<string, any>>({
@@ -328,12 +369,32 @@ const moveItem = (index: number, offset: -1 | 1) => {
};
const normalizeNodeThreadOverrides = (payload: Record<string, number | string>) => {
nodeThreadOverrides.value = Object.entries(payload || {})
.map(([node_code, thread_count]) => ({
node_code: String(node_code || "").trim(),
thread_count: Number(thread_count || 0)
}))
.filter((item) => item.node_code && item.thread_count > 0)
const collapsed = Object.entries(payload || {}).reduce<Record<string, number>>((result, [rawNodeCode, threadCountValue]) => {
const node_code = resolveServerCode(String(rawNodeCode || "").trim());
const thread_count = Number(threadCountValue || 0);
if (!node_code || thread_count <= 0) {
return result;
}
result[node_code] = Math.max(Number(result[node_code] || 0), thread_count);
return result;
}, {});
nodeThreadOverrides.value = Object.entries(collapsed)
.map(([node_code, thread_count]) => ({ node_code, thread_count }))
.sort((a, b) => a.node_code.localeCompare(b.node_code));
};
const normalizeNodeProcessOverrides = (payload: Record<string, number | string>) => {
const collapsed = Object.entries(payload || {}).reduce<Record<string, number>>((result, [rawNodeCode, processCountValue]) => {
const node_code = resolveServerCode(String(rawNodeCode || "").trim());
const process_count = Number(processCountValue || 0);
if (!node_code || process_count <= 0) {
return result;
}
result[node_code] = Math.max(Number(result[node_code] || 0), process_count);
return result;
}, {});
nodeProcessOverrides.value = Object.entries(collapsed)
.map(([node_code, process_count]) => ({ node_code, process_count }))
.sort((a, b) => a.node_code.localeCompare(b.node_code));
};
@@ -348,10 +409,21 @@ const addNodeThreadOverride = () => {
});
};
const addNodeProcessOverride = () => {
nodeProcessOverrides.value.push({
node_code: "",
process_count: processCount.value || 80
});
};
const removeNodeThreadOverride = (index: number) => {
nodeThreadOverrides.value.splice(index, 1);
};
const removeNodeProcessOverride = (index: number) => {
nodeProcessOverrides.value.splice(index, 1);
};
const loadBackups = async () => {
try {
const response = await settingsApi.getSettingsBackups();
@@ -379,8 +451,10 @@ const loadSettings = async () => {
loading.value = true;
try {
const response = await settingsApi.getSettings();
processCount.value = response.data.process_count || 80;
threadCount.value = response.data.thread_count;
currentNodeCode.value = response.data.current_node_code || "";
normalizeNodeProcessOverrides(response.data.node_process_counts || {});
normalizeNodeThreadOverrides(response.data.node_thread_counts || {});
normalizeDetectItems(response.data.detect_options || {});
@@ -430,7 +504,19 @@ const buildSettingsPayload = () => {
return result;
}, {});
const nodeProcessCounts = nodeProcessOverrides.value.reduce<Record<string, number>>((result, item) => {
const nodeCode = String(item.node_code || "").trim();
const processCountValue = Number(item.process_count || 0);
if (!nodeCode || processCountValue <= 0) {
return result;
}
result[nodeCode] = processCountValue;
return result;
}, {});
return {
process_count: processCount.value,
node_process_counts: nodeProcessCounts,
thread_count: threadCount.value,
node_thread_counts: nodeThreadCounts,
detect_options: detectOptions,