feat: stabilize multi-region runtime sync and worker orchestration
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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: "敏感词配置" },
|
||||
|
||||
@@ -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: "维护过滤词、黑名单词和筛选规则。" } },
|
||||
|
||||
@@ -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 运行模式" }
|
||||
];
|
||||
|
||||
@@ -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 ? "在线" : "离线"}。`;
|
||||
});
|
||||
|
||||
@@ -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="节点编码">
|
||||
|
||||
818
domain-web/src/views/ops/MigrationView.vue
Normal file
818
domain-web/src/views/ops/MigrationView.vue
Normal 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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user