feat: add ops center and node onboarding flow

This commit is contained in:
Your Name
2026-04-18 23:52:51 +08:00
parent 246838ae4c
commit b9c29481b5
142 changed files with 89727 additions and 186 deletions

View File

@@ -100,6 +100,13 @@
:title="`当前检测状态:${currentPhaseLabel};本机 Worker ${status.worker_online ? '在线' : '离线'}。`"
style="margin-top: 12px"
/>
<el-alert
:closable="false"
show-icon
:type="status.worker_log_sync_enabled ? 'info' : 'warning'"
:title="remoteLogMirrorText"
style="margin-top: 12px"
/>
<el-row :gutter="12" style="margin-top: 16px; margin-bottom: 16px">
<el-col :xs="12" :sm="8" :md="4"><el-statistic title="待检测" :value="status.progress.pending || 0" /></el-col>
@@ -328,6 +335,11 @@ const status = ref({
dependency_alerts: [] as any[],
log_lines: [] as string[],
remote_log_lines: [] as string[],
remote_log_line_count: 0,
remote_log_last_at: "",
remote_log_last_line: "",
remote_log_nodes: [] as string[],
remote_log_node_count: 0,
runs: [] as any[],
worker_log_sync_enabled: false,
worker_log_sync_mode: "key",
@@ -487,6 +499,29 @@ const proxyDiagnosisText = computed(() => {
return status.value.proxy_runtime_label || "当前可用代理 / 配置代理池链接数";
});
const remoteLogMirrorText = computed(() => {
if (!status.value.worker_log_sync_enabled) {
return "当前已关闭远端日志回传;日志控制台只展示本机 Worker 输出,不会拼接其他节点的执行过程。";
}
const modeText = status.value.worker_log_sync_mode === "full" ? "全量" : "关键";
const nodeCount = Number(status.value.remote_log_node_count || 0);
const lineCount = Number(status.value.remote_log_line_count || 0);
const nodes = Array.isArray(status.value.remote_log_nodes) ? status.value.remote_log_nodes : [];
const lastAt = String(status.value.remote_log_last_at || "").trim();
const parts = [
`当前已开启${modeText}回传,日志控制台会自动拼接远端节点回传日志。`,
`来源节点 ${nodeCount}`,
`样本 ${lineCount}`,
];
if (nodes.length) {
parts.push(`节点:${nodes.join("、")}`);
}
if (lastAt) {
parts.push(`最近回传:${lastAt}`);
}
return parts.join("");
});
const queueAlertType = computed<"success" | "warning" | "info" | "error">(() => {
if (!queueSummary.value.has_active_job) return "info";
if ((queueSummary.value.queue?.overdue_leases || 0) > 0) return "error";

File diff suppressed because it is too large Load Diff

View File

@@ -73,10 +73,10 @@
<div class="summary-title">有效执行节点</div>
<div class="summary-value">{{ runtime.cluster?.summary?.online_worker_nodes || 0 }}</div>
<div class="summary-note">
可承担检测任务的在线节点
真正执行/领任务 {{ runtime.detect?.participation_summary?.dispatch_active_nodes || 0 }}
/ 在线未参与 {{ runtime.detect?.participation_summary?.non_participating_nodes || 0 }}
/ 独立 Worker {{ runtime.cluster?.summary?.dedicated_online_worker_nodes || 0 }}
/ 控制面 {{ runtime.cluster?.summary?.online_control_nodes || 0 }}
/ 忙碌 {{ runtime.cluster?.summary?.status_counts?.busy || 0 }}
/ 控制面兼跑 {{ runtime.detect?.participation_summary?.controller_worker_nodes || 0 }}
</div>
</div>
<div class="summary-card">
@@ -126,6 +126,12 @@
<span class="status-chip" :class="(runtime.cluster?.summary?.online_worker_nodes || 0) > 0 ? 'ok' : 'danger'">
有效执行节点 {{ runtime.cluster?.summary?.online_worker_nodes || 0 }}
</span>
<span class="status-chip" :class="(runtime.detect?.participation_summary?.dispatch_active_nodes || 0) > 0 ? 'warn' : 'neutral'">
真正参与 {{ runtime.detect?.participation_summary?.dispatch_active_nodes || 0 }}
</span>
<span class="status-chip" :class="(runtime.detect?.participation_summary?.non_participating_nodes || 0) > 0 ? 'info' : 'neutral'">
在线未参与 {{ runtime.detect?.participation_summary?.non_participating_nodes || 0 }}
</span>
<span class="status-chip" :class="preflight.ok ? 'ok' : 'danger'">
预检 {{ preflight.ok ? "通过" : "异常" }}
</span>
@@ -203,6 +209,69 @@
<el-button plain :loading="actionLoading === 'push_sync'" @click="invokeAction('push_sync')">立即同步</el-button>
<el-button plain @click="goDetect">前往检测控制</el-button>
</div>
<div class="action-subpanel">
<div class="action-subpanel-header">
<h4>远端日志回传</h4>
<el-tag :type="workerLogSyncEnabled ? 'success' : 'info'">
{{ workerLogSyncEnabled ? (workerLogSyncMode === "full" ? "全量回传" : "关键回传") : "已关闭" }}
</el-tag>
</div>
<div class="action-buttons">
<el-button
plain
:type="!workerLogSyncEnabled ? 'primary' : ''"
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'off'"
@click="updateWorkerLogSync(false, 'key')"
>
关闭回传
</el-button>
<el-button
plain
:type="workerLogSyncEnabled && workerLogSyncMode === 'key' ? 'primary' : ''"
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'key'"
@click="updateWorkerLogSync(true, 'key')"
>
关键回传
</el-button>
<el-button
plain
:type="workerLogSyncEnabled && workerLogSyncMode === 'full' ? 'primary' : ''"
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'full'"
@click="updateWorkerLogSync(true, 'full')"
>
全量回传
</el-button>
</div>
<el-alert
:closable="false"
show-icon
type="info"
style="margin-top: 12px"
:title="workerLogSyncDescription"
/>
<div class="remote-log-observation">
<div class="remote-log-observation__item">
<span class="label">样本</span>
<strong>{{ runtime.detect?.log_sync?.line_count || 0 }} </strong>
</div>
<div class="remote-log-observation__item">
<span class="label">来源节点</span>
<strong>{{ runtime.detect?.log_sync?.source_node_count || 0 }} </strong>
</div>
<div class="remote-log-observation__item remote-log-observation__item--wide">
<span class="label">节点列表</span>
<span>{{ workerLogSyncNodesText }}</span>
</div>
<div class="remote-log-observation__item remote-log-observation__item--wide">
<span class="label">最近回传</span>
<span>{{ workerLogSyncLastAtText }}</span>
</div>
<div class="remote-log-observation__item remote-log-observation__item--full">
<span class="label">最近一条</span>
<span>{{ workerLogSyncLastLineText }}</span>
</div>
</div>
</div>
<el-alert
v-if="lastAction.message"
:title="`${lastAction.label}${lastAction.message}${lastAction.at ? `${lastAction.at}` : ''}`"
@@ -338,7 +407,7 @@
<div class="history-panel">
<div class="history-header">
<h3>当前参与检测节点</h3>
<span class="history-note">直接展示哪些节点正在领任务执行任务或产生近窗吞吐controller 兼跑检测也会计入</span>
<span class="history-note">这里只展示真正有任务动作或近窗吞吐的节点controller 兼跑检测也会明确计入</span>
</div>
<el-alert
:closable="false"
@@ -370,6 +439,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="参与状态" min-width="130">
<template #default="{ row }">
<el-tag :type="participationStateTagType(row)">
{{ participationStateText(row) }}
</el-tag>
</template>
</el-table-column>
<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" />
@@ -379,6 +455,58 @@
</el-table>
</div>
<div class="history-panel">
<div class="history-header">
<h3>在线但未参与节点</h3>
<span class="history-note">这些节点当前在线但没有执行/领任务若只是上报了负载会单独标成负载待确认</span>
</div>
<el-alert
:closable="false"
show-icon
type="info"
style="margin-bottom: 12px"
:title="nonParticipatingNodesAlertText"
/>
<el-table
:data="nonParticipatingNodes"
border
size="small"
empty-text="当前没有在线但未参与的节点"
>
<el-table-column prop="node_code" label="节点" min-width="180" />
<el-table-column prop="region" label="区域" min-width="100" />
<el-table-column prop="role" label="角色" min-width="100" />
<el-table-column label="有效 Worker" min-width="100">
<template #default="{ row }">
<el-tag :type="row.is_effective_worker ? 'success' : 'info'">
{{ row.is_effective_worker ? "是" : "否" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'online' ? 'success' : row.status === 'busy' ? 'warning' : 'info'">
{{ row.status || "-" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="未参与状态" min-width="130">
<template #default="{ row }">
<el-tag :type="nonParticipationStateTagType(row)">
{{ nonParticipationStateText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="current_load" label="负载" min-width="80" />
<el-table-column label="待命说明" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
{{ standbyReasonText(row) }}
</template>
</el-table-column>
<el-table-column prop="last_heartbeat_at" label="最近心跳" min-width="160" />
</el-table>
</div>
<div class="history-panel">
<div class="history-header">
<h3>动作留痕</h3>
@@ -450,12 +578,12 @@
</el-table-column>
<el-table-column label="当前阶段" min-width="140">
<template #default="{ row }">
{{ row.metadata?.phase || "-" }}
{{ row.metadata?.phase || row.metadata?.phase_label || "-" }}
</template>
</el-table-column>
<el-table-column label="阶段说明" min-width="260" show-overflow-tooltip>
<template #default="{ row }">
{{ row.metadata?.detail || "-" }}
{{ row.metadata?.detail || row.metadata?.phase_detail || "-" }}
</template>
</el-table-column>
<el-table-column prop="last_heartbeat_at" label="最近心跳" min-width="160" />
@@ -679,6 +807,8 @@ const loading = reactive({
});
const actionLoading = ref("");
const workerLogSyncLoading = ref(false);
const pendingWorkerLogMode = ref<"off" | "key" | "full">("off");
const autoRefresh = ref(true);
const lastUpdatedAt = ref("");
const lastAction = ref({
@@ -869,12 +999,16 @@ const clusterIssueCount = computed(() => {
const clusterAlertText = computed(() => {
const summary = runtime.cluster?.summary || {};
const participationSummary = runtime.detect?.participation_summary || {};
const busy = Array.isArray(summary.busy_nodes) ? summary.busy_nodes.join("、") : "";
const staleNodes = Array.isArray(summary.stale_nodes) ? summary.stale_nodes : [];
const offlineNodes = Array.isArray(summary.offline_nodes) ? summary.offline_nodes : [];
const parts = [
`当前有效执行节点 ${summary.online_worker_nodes || 0},其中独立 Worker ${summary.dedicated_online_worker_nodes || 0}`
`当前有效执行节点 ${summary.online_worker_nodes || 0},其中独立 Worker ${summary.dedicated_online_worker_nodes || 0},控制面兼跑 ${participationSummary.controller_worker_nodes || 0}`
];
parts.push(
`真正执行/领任务 ${participationSummary.dispatch_active_nodes || 0} 台,近窗刚有吞吐 ${participationSummary.recent_only_nodes || 0} 台,在线未参与 ${participationSummary.non_participating_nodes || 0}`
);
if (busy) {
parts.push(`忙碌节点:${busy}`);
}
@@ -893,8 +1027,9 @@ const participatingNodesAlertText = computed(() => {
return "当前没有节点正在领任务、执行任务或产生近窗吞吐。";
}
const names = rows.map((item: Record<string, any>) => String(item.node_code || "-")).join("、");
const effectiveCount = rows.filter((item: Record<string, any>) => Boolean(item.is_effective_worker)).length;
return `当前参与检测节点 ${rows.length} 台,其中有效执行节点 ${effectiveCount} 台:${names}`;
const dispatchActiveCount = rows.filter((item: Record<string, any>) => ["running", "claimed"].includes(String(item.participation_state || "").trim())).length;
const recentOnlyCount = rows.filter((item: Record<string, any>) => String(item.participation_state || "").trim() === "recent_throughput").length;
return `当前参与检测节点 ${rows.length} 台:正在执行/领任务 ${dispatchActiveCount} 台,近窗刚有吞吐 ${recentOnlyCount} 台。节点:${names}`;
});
const syncAlertText = computed(() => {
@@ -920,6 +1055,127 @@ const syncAlertText = computed(() => {
return parts.join("");
});
const workerLogSyncEnabled = computed(() => {
if (runtime.detect?.log_sync) {
return Boolean(runtime.detect.log_sync.enabled);
}
return Boolean(settingsState.runtime_settings?.worker_log_sync_enabled);
});
const workerLogSyncMode = computed(() => {
const sourceValue = runtime.detect?.log_sync?.mode ?? settingsState.runtime_settings?.worker_log_sync_mode;
return String(sourceValue || "").trim().toLowerCase() === "full" ? "full" : "key";
});
const workerLogSyncDescription = computed(() => {
if (!workerLogSyncEnabled.value) {
return "当前已关闭远端日志回传;跨机联调时不会把 Worker 过程日志镜像到当前后台。";
}
const lineCount = Number(runtime.detect?.log_sync?.line_count || 0);
const sourceNodes = Array.isArray(runtime.detect?.log_sync?.source_nodes) ? runtime.detect.log_sync.source_nodes : [];
const lastAt = String(runtime.detect?.log_sync?.last_at || "").trim();
const observationSuffix = lineCount > 0
? ` 当前已收到 ${lineCount} 条回传日志,来源节点 ${sourceNodes.length || 0}${lastAt ? `,最近一条 ${lastAt}` : ""}`
: " 当前还没有收到远端日志样本。";
if (workerLogSyncMode.value === "full") {
return `当前为全量回传;会尽量保留更多 Worker 执行过程,适合短时深度排障,但日志量会更大。${observationSuffix}`;
}
return `当前为关键回传;只同步阶段、异常、代理与关键执行事件,适合日常联调观察。${observationSuffix}`;
});
const workerLogSyncNodesText = computed(() => {
const nodes = Array.isArray(runtime.detect?.log_sync?.source_nodes) ? runtime.detect.log_sync.source_nodes : [];
return nodes.length ? nodes.join("、") : "当前还没有远端节点回传样本";
});
const workerLogSyncLastAtText = computed(() => {
return String(runtime.detect?.log_sync?.last_at || "").trim() || "暂无";
});
const workerLogSyncLastLineText = computed(() => {
return String(runtime.detect?.log_sync?.last_line || "").trim() || "当前还没有远端日志回传";
});
const participatingNodeCodeSet = computed(() => {
const rows = Array.isArray(runtime.detect?.participating_nodes) ? runtime.detect.participating_nodes : [];
return new Set(rows.map((item: Record<string, any>) => String(item.node_code || "").trim()).filter(Boolean));
});
const nonParticipatingNodes = computed(() => {
if (Array.isArray(runtime.detect?.non_participating_nodes)) {
return runtime.detect.non_participating_nodes;
}
if (Array.isArray(runtime.detect?.standby_nodes)) {
return runtime.detect.standby_nodes;
}
const rows = Array.isArray(runtime.cluster?.nodes) ? runtime.cluster.nodes : [];
return rows.filter((item: Record<string, any>) => {
const nodeCode = String(item.node_code || "").trim();
const status = String(item.status || "").trim();
const currentLoad = Number(item.current_load || 0);
const isEffectiveWorker = Boolean(item.is_effective_worker);
return Boolean(nodeCode)
&& isEffectiveWorker
&& ["online", "busy"].includes(status)
&& !participatingNodeCodeSet.value.has(nodeCode)
&& currentLoad <= 0;
});
});
const nonParticipatingNodesAlertText = computed(() => {
if (!nonParticipatingNodes.value.length) {
return "当前所有有效执行节点要么正在参与检测,要么当前没有额外在线未参与节点。";
}
const names = nonParticipatingNodes.value.map((item: Record<string, any>) => String(item.node_code || "-")).join("、");
const loadSyncingCount = nonParticipatingNodes.value.filter(
(item: Record<string, any>) => String(item.participation_state || "").trim() === "load_syncing"
).length;
const standbyCount = nonParticipatingNodes.value.length - loadSyncingCount;
return `当前在线但未参与节点 ${nonParticipatingNodes.value.length} 台:在线待命 ${standbyCount} 台,负载待确认 ${loadSyncingCount} 台。节点:${names}`;
});
const nonParticipationStateText = (row: Record<string, any>) => {
if (row?.participation_label) return String(row.participation_label);
if (String(row?.participation_state || "").trim() === "load_syncing") return "负载待确认";
return "在线待命";
};
const nonParticipationStateTagType = (row: Record<string, any>) => {
const state = String(row?.participation_state || "").trim();
if (state === "load_syncing") return "warning";
return "info";
};
const participationStateText = (row: Record<string, any>) => {
if (row?.participation_label) return String(row.participation_label);
const running = Number(row.items_running || 0);
const claimed = Number(row.items_claimed || 0);
const processedRecent = Number(row.processed_recent || 0);
if (running > 0) return "执行中";
if (claimed > 0) return "已领待跑";
if (processedRecent > 0) return "近窗有吞吐";
return "参与中";
};
const participationStateTagType = (row: Record<string, any>) => {
const state = String(row?.participation_state || participationStateText(row));
if (["running", "执行中", "busy", "忙碌中"].includes(state)) return "warning";
if (["claimed", "已领待跑"].includes(state)) return "success";
if (["recent_throughput", "近窗有吞吐"].includes(state)) return "info";
return "info";
};
const standbyReasonText = (row: Record<string, any>) => {
if (row?.standby_reason) return String(row.standby_reason);
if (row?.participation_reason) return String(row.participation_reason);
const metadata = row?.metadata || {};
if (metadata?.detail) return String(metadata.detail);
if (metadata?.phase_detail) return String(metadata.phase_detail);
if (metadata?.phase) return `当前阶段:${metadata.phase}`;
if (metadata?.phase_label) return `当前阶段:${metadata.phase_label}`;
return "节点在线,当前未领任务、未执行任务,也没有近窗吞吐。";
};
const hasNodeOverride = (nodeCode?: string) => {
const normalized = String(nodeCode || "").trim();
return Boolean(normalized && settingsState.node_thread_counts?.[normalized] != null);
@@ -1046,6 +1302,33 @@ const updateNodeThreadSettings = async (updater: (nodeThreadCounts: Record<strin
});
};
const updateWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
workerLogSyncLoading.value = true;
pendingWorkerLogMode.value = enabled ? mode : "off";
try {
const latest = await settingsApi.getSettings();
const payload = latest.data || {};
await settingsApi.updateSettings({
thread_count: payload.thread_count,
node_thread_counts: payload.node_thread_counts || {},
detect_options: payload.detect_options || {},
proxy_config: payload.proxy_config || {},
runtime_settings: {
...(payload.runtime_settings || {}),
worker_log_sync_enabled: enabled,
worker_log_sync_mode: mode
}
});
ElMessage.success(`远端日志回传已${enabled ? `切到${mode === "full" ? "全量" : "关键"}模式` : "关闭"}`);
await loadAll(false);
} catch (error: any) {
ElMessage.error(error?.message || "更新远端日志回传开关失败");
} finally {
workerLogSyncLoading.value = false;
pendingWorkerLogMode.value = "off";
}
};
const saveNodeThreadOverride = async () => {
if (!nodeThreadDialog.nodeCode) {
ElMessage.warning("节点编码不能为空");
@@ -1269,6 +1552,11 @@ onBeforeUnmount(clearRefreshTimer);
color: #c2410c;
}
.status-chip.info {
background: #eff6ff;
color: #1d4ed8;
}
.status-chip.danger {
background: #fef2f2;
color: #b91c1c;
@@ -1300,6 +1588,42 @@ onBeforeUnmount(clearRefreshTimer);
font-size: 12px;
}
.remote-log-observation {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-top: 12px;
}
.remote-log-observation__item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 10px;
background: #f8fbff;
border: 1px solid #dbe4ee;
color: #0f172a;
font-size: 13px;
}
.remote-log-observation__item .label {
color: #64748b;
font-size: 12px;
}
.remote-log-observation__item--wide,
.remote-log-observation__item--full {
grid-column: span 2;
}
@media (max-width: 900px) {
.remote-log-observation__item--wide,
.remote-log-observation__item--full {
grid-column: span 1;
}
}
.cluster-summary {
display: flex;
flex-wrap: wrap;