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

@@ -21,6 +21,107 @@ export const runtimeApi = {
action: (action: string) => http.post(`/runtime/actions/${action}`)
};
export const opsApi = {
overview: () => http.get("/ops/overview"),
goLiveSummary: (params?: Record<string, unknown>) => http.get("/ops/go-live-summary", { params }),
doctorDecision: (params?: Record<string, unknown>) => http.get("/ops/doctor-decision", { params }),
goLiveSignoff: (params?: Record<string, unknown>) => http.get("/ops/go-live-signoff", { params }),
goLiveBundle: (params?: Record<string, unknown>) => http.get("/ops/go-live-bundle", { params }),
goLiveReview: (params?: Record<string, unknown>) => http.get("/ops/go-live-review", { params }),
stackDiagnosis: (params?: Record<string, unknown>) => http.get("/ops/stack-diagnosis", { params }),
linkSnapshot: () => http.get("/ops/link-snapshot"),
inspectionOverview: (params?: Record<string, unknown>) => http.get("/ops/inspection-overview", { params }),
activityStream: (params?: Record<string, unknown>) => http.get("/ops/activity-stream", { params }),
driverFeed: () => http.get("/ops/driver-feed"),
codexBrief: () => http.get("/ops/codex-brief"),
capabilities: () => http.get("/ops/capabilities"),
contracts: () => http.get("/ops/contracts"),
contractDetail: (contractKey: string) => http.get(`/ops/contracts/${encodeURIComponent(contractKey)}`),
actionTemplates: () => http.get("/ops/action-templates"),
playbooks: () => http.get("/ops/playbooks"),
playbookRuns: (params?: Record<string, unknown>) => http.get("/ops/playbook-runs", { params }),
playbookRunDetail: (runCode: string) => http.get(`/ops/playbook-runs/${encodeURIComponent(runCode)}`),
playbookRunEvents: (runCode: string, params?: Record<string, unknown>) =>
http.get(`/ops/playbook-runs/${encodeURIComponent(runCode)}/events`, { params }),
rerunPlaybookRun: (runCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/playbook-runs/${encodeURIComponent(runCode)}/rerun`, payload || {}),
cancelPlaybookRun: (runCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/playbook-runs/${encodeURIComponent(runCode)}/cancel`, payload || {}),
previewPlaybook: (payload: Record<string, unknown>) => http.post("/ops/playbooks/preview", payload),
executePlaybook: (payload: Record<string, unknown>) => http.post("/ops/playbooks/execute", payload),
blueprint: () => http.get("/ops/blueprint"),
runbook: () => http.get("/ops/runbook"),
resolveRunbookSequence: (sequenceKey: string, payload?: Record<string, unknown>) =>
http.post(`/ops/runbook/sequences/${encodeURIComponent(sequenceKey)}/resolve`, payload || {}),
executeRunbookSequence: (sequenceKey: string, payload?: Record<string, unknown>) =>
http.post(`/ops/runbook/sequences/${encodeURIComponent(sequenceKey)}/execute`, payload || {}),
nodes: () => http.get("/ops/nodes"),
nodeHandover: (nodeCode: string, params?: Record<string, unknown>) =>
http.get(`/ops/nodes/${encodeURIComponent(nodeCode)}/handover`, { params }),
nodeOnboarding: (nodeCode: string, params?: Record<string, unknown>) =>
http.get(`/ops/nodes/${encodeURIComponent(nodeCode)}/onboarding`, { params }),
previewNodeOnboardingRecovery: (nodeCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/nodes/${encodeURIComponent(nodeCode)}/onboarding/recovery/preview`, payload || {}),
executeNodeOnboardingRecovery: (nodeCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/nodes/${encodeURIComponent(nodeCode)}/onboarding/recovery/execute`, payload || {}),
nodeSceneLog: (nodeCode: string, params?: Record<string, unknown>) =>
http.get(`/ops/nodes/${encodeURIComponent(nodeCode)}/scene-log`, { params }),
buildNodeHandoverBootstrapPlan: (nodeCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/nodes/${encodeURIComponent(nodeCode)}/handover/bootstrap-plan`, payload || {}),
deliveryQueue: (nodeCode: string) => http.get(`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue`),
deliveryQueueRecords: (nodeCode: string, params?: Record<string, unknown>) =>
http.get(`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue/records`, { params }),
flushDeliveryQueue: (nodeCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue/flush`, payload || {}),
replayDeliveryQueue: (nodeCode: string, payload?: Record<string, unknown>) =>
http.post(`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue/replay`, payload || {}),
replayDeliveryQueueRecord: (nodeCode: string, recordId: string, payload?: Record<string, unknown>) =>
http.post(
`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue/records/${encodeURIComponent(recordId)}/replay`,
payload || {}
),
discardDeliveryQueueRecord: (nodeCode: string, recordId: string, payload?: Record<string, unknown>) =>
http.post(
`/ops/nodes/${encodeURIComponent(nodeCode)}/delivery-queue/records/${encodeURIComponent(recordId)}/discard`,
payload || {}
),
upsertNode: (payload: Record<string, unknown>) => http.post("/ops/nodes", payload),
syncNodesFromCluster: (dryRun = false) => http.post(`/ops/nodes/sync-from-cluster?dry_run=${dryRun ? "true" : "false"}`),
previewPolicy: (payload: Record<string, unknown>) => http.post("/ops/policy/preview", payload),
jobs: (params?: Record<string, unknown>) => http.get("/ops/jobs", { params }),
jobDetail: (jobId: number) => http.get(`/ops/jobs/${jobId}`),
jobEvents: (jobId: number, params?: Record<string, unknown>) => http.get(`/ops/jobs/${jobId}/events`, { params }),
previewDriverAction: (payload: Record<string, unknown>) => http.post("/ops/driver-actions/preview", payload),
resolveDriverAction: (payload: Record<string, unknown>) => http.post("/ops/driver-actions/resolve", payload),
executeDriverAction: (payload: Record<string, unknown>) => http.post("/ops/driver-actions/execute", payload),
executeResolvedDriverAction: (payload: Record<string, unknown>) => http.post("/ops/driver-actions/execute-resolved", payload),
resolveCodexAction: (payload: Record<string, unknown>) => http.post("/ops/codex-actions/resolve", payload),
executeCodexAction: (payload: Record<string, unknown>) => http.post("/ops/codex-actions/execute", payload),
createJobBatch: (payload: Record<string, unknown>) => http.post("/ops/jobs/batch", payload),
approveJob: (jobId: number, payload?: Record<string, unknown>) => http.post(`/ops/jobs/${jobId}/approve`, payload || {}),
cancelJob: (jobId: number, payload?: Record<string, unknown>) => http.post(`/ops/jobs/${jobId}/cancel`, payload || {}),
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),
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"),
releaseLaunchpad: (channel = "stable") => http.get("/ops/releases/launchpad", { params: { channel } }),
releaseDetail: (releaseId: number) => http.get(`/ops/releases/${releaseId}`),
createRelease: (payload: Record<string, unknown>) => http.post("/ops/releases", payload),
createReleaseFromLatestPackage: (payload?: Record<string, unknown>) => http.post("/ops/releases/from-package/latest", payload || {}),
createReleaseFromLatestPackageAndSmartRollout: (payload?: Record<string, unknown>) =>
http.post("/ops/releases/from-package/latest/smart-rollout", payload || {}),
createSmartRolloutForRelease: (releaseId: number, payload?: Record<string, unknown>) =>
http.post(`/ops/releases/${releaseId}/smart-rollout`, payload || {}),
activateRelease: (releaseId: number) => http.post(`/ops/releases/${releaseId}/activate`),
releaseRollouts: (releaseId: number, params?: Record<string, unknown>) => http.get(`/ops/releases/${releaseId}/rollouts`, { params }),
createRollout: (releaseId: number, payload: Record<string, unknown>) => http.post(`/ops/releases/${releaseId}/rollouts`, payload),
rolloutDetail: (rolloutId: number) => http.get(`/ops/rollouts/${rolloutId}`),
rolloutJobs: (rolloutId: number, params?: Record<string, unknown>) => http.get(`/ops/rollouts/${rolloutId}/jobs`, { params }),
advanceRollout: (rolloutId: number, payload?: Record<string, unknown>) => http.post(`/ops/rollouts/${rolloutId}/advance`, payload || {})
};
export const settingsApi = {
getSettings: () => http.get("/settings"),
updateSettings: (payload: Record<string, unknown>) => http.put("/settings", payload),

View File

@@ -67,6 +67,7 @@ const menuItems = [
{ path: "/dashboard", label: "概览" },
{ path: "/runtime", label: "运行中心" },
{ path: "/runtime-debug", label: "运行调试" },
{ path: "/ops-center", label: "运维中枢" },
{ path: "/settings", label: "系统设置" },
{ path: "/imports", label: "域名导入" },
{ path: "/sensitive-words", label: "敏感词配置" },

View File

@@ -16,6 +16,7 @@ const routes: RouteRecordRaw[] = [
{ path: "dashboard", name: "dashboard", component: () => import("@/views/dashboard/DashboardView.vue"), meta: { title: "概览" } },
{ path: "runtime", name: "runtime", component: () => import("@/views/runtime/RuntimeView.vue"), meta: { title: "运行中心" } },
{ path: "runtime-debug", name: "runtime-debug", component: () => import("@/views/runtime/RuntimeDebugView.vue"), meta: { title: "运行调试" } },
{ path: "ops-center", name: "ops-center", component: () => import("@/views/ops/OpsCenterView.vue"), meta: { title: "运维中枢" } },
{ path: "settings", name: "settings", component: () => import("@/views/settings/SettingsView.vue"), meta: { title: "系统设置" } },
{ path: "imports", name: "imports", component: () => import("@/views/imports/ImportsView.vue"), meta: { title: "域名导入" } },
{ path: "sensitive-words", name: "sensitive-words", component: () => import("@/views/sensitive-words/SensitiveWordsView.vue"), meta: { title: "敏感词配置" } },

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;

View File

@@ -4,6 +4,13 @@ import { fileURLToPath, URL } from "node:url";
export default defineConfig({
plugins: [vue()],
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler"
}
}
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))