18517 lines
771 KiB
Vue
18517 lines
771 KiB
Vue
<template>
|
||
<PageCard title="运维中枢" description="面向海外控制面,统一查看托管节点、运维任务、Release/Rollout 和节点 Agent 接管进度。">
|
||
<template #header-extra>
|
||
<div class="toolbar">
|
||
<el-button plain :loading="loading.all" @click="loadAll">刷新全部</el-button>
|
||
<el-button plain :loading="loading.nodesSync" @click="syncNodesFromCluster">同步节点</el-button>
|
||
<el-button plain @click="openManagedNodeDialog()">新增节点</el-button>
|
||
<el-button plain @click="openAgentTokenDialog()">签发 Agent Token</el-button>
|
||
<el-button plain @click="openReleaseDialog">创建 Release</el-button>
|
||
<el-button type="primary" plain :disabled="!selectedReleaseId" @click="openRolloutDialog()">创建 Rollout</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="summary-grid">
|
||
<div class="summary-card">
|
||
<span class="summary-label">当前模式</span>
|
||
<strong>{{ overview.mode || "centralized-ops" }}</strong>
|
||
<span class="summary-note">{{ overview.vision || "海外控制面统一接管节点运维。" }}</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">托管节点</span>
|
||
<strong>{{ managedNodesSummary.managedTotal }} / {{ managedNodesSummary.total }}</strong>
|
||
<span class="summary-note">
|
||
已启用 {{ managedNodesSummary.enabled }} / 已接管 {{ managedNodesSummary.online }} / 待接入 {{ managedNodesSummary.pendingBootstrap }}
|
||
</span>
|
||
<span class="summary-note">
|
||
待重试节点 {{ managedNodesSummary.queueRetryingNodes }} / 死信节点 {{ managedNodesSummary.queueDeadLetterNodes }}
|
||
</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">运维任务</span>
|
||
<strong>{{ jobsSummary.total }}</strong>
|
||
<span class="summary-note">
|
||
queued {{ jobsSummary.queued }} / running {{ jobsSummary.running }} / approval {{ jobsSummary.awaitingApproval }}
|
||
</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">Release Hub</span>
|
||
<strong>{{ releaseSummary.total }}</strong>
|
||
<span class="summary-note">rollouts {{ releaseSummary.rolloutsTotal }} / active {{ activeReleaseLabel }}</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">当前拓扑</span>
|
||
<strong>{{ topology.nodeCode || "-" }}</strong>
|
||
<span class="summary-note">{{ topology.nodeRegion || "-" }} / {{ topology.nodeRole || "-" }} / {{ topology.workerMode || "-" }}</span>
|
||
</div>
|
||
<div class="summary-card">
|
||
<span class="summary-label">建议重点</span>
|
||
<strong>{{ recommendation.priority || "-" }}</strong>
|
||
<span class="summary-note">{{ recommendation.summary || "暂无建议" }}</span>
|
||
<div v-if="recommendationLevelLabel || recommendationPrimaryLabel || recommendationSecondaryLabel" class="summary-card__meta">
|
||
<el-tag v-if="recommendationLevelLabel" :type="recommendationTagType" effect="plain" size="small">
|
||
{{ recommendationLevelLabel }}
|
||
</el-tag>
|
||
</div>
|
||
<div v-if="recommendationPrimaryLabel || recommendationSecondaryLabel" class="summary-card__actions">
|
||
<el-button
|
||
v-if="recommendationPrimaryLabel"
|
||
size="small"
|
||
:type="recommendationPrimaryType"
|
||
:plain="recommendationPrimaryType !== 'danger'"
|
||
:loading="loading.driverActionKey === 'overview-recommendation-primary'"
|
||
@click="runOverviewRecommendation()"
|
||
>
|
||
{{ recommendationPrimaryLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="recommendationPreviewable"
|
||
size="small"
|
||
plain
|
||
@click="openOverviewRecommendationContractPreview()"
|
||
>
|
||
看契约
|
||
</el-button>
|
||
<el-button
|
||
v-if="recommendationSecondaryLabel"
|
||
size="small"
|
||
plain
|
||
:loading="loading.driverActionKey === 'overview-recommendation-secondary'"
|
||
@click="runOverviewRecommendation({ secondary: true })"
|
||
>
|
||
{{ recommendationSecondaryLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="lastIssuedToken.token"
|
||
type="success"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 18px"
|
||
>
|
||
<template #title>
|
||
已为节点 {{ lastIssuedToken.node_code || "-" }} 生成 Agent 接入方案,过期时间 {{ lastIssuedToken.expires_at || "-" }}
|
||
</template>
|
||
<div class="token-alert-actions">
|
||
<span class="token-preview">{{ lastIssuedToken.token_preview || "-" }}</span>
|
||
<el-button size="small" text @click="copyIssuedToken">复制 Token</el-button>
|
||
<el-button size="small" text @click="copyAgentEnvContent">复制 Env</el-button>
|
||
<el-button size="small" text @click="copyAgentBootstrapSnippet">复制接入片段</el-button>
|
||
</div>
|
||
</el-alert>
|
||
|
||
<section id="ops-codex-brief-panel" class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>总检入口</h4>
|
||
<p>Ops Center 顶部先看这一张总检卡。它会把 contract、总观察面、自动化状态、默认下一步和快检命令压成同一份结论,页面、CLI 和后续海外 Codex 都优先消费这里。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
{{ goLiveSummary.generated_at || opsStackDiagnosis.generated_at || "前端兜底生成" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box" style="margin-bottom: 14px">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>单脑驾驶舱首屏</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="opsCockpitStatusType" size="small">
|
||
{{ opsCockpitStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsCockpitNextActionCode || "待生成下一步" }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="opsCockpitStatusType"
|
||
:title="opsCockpitHeadline"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
|
||
<div class="inspection-summary-grid" style="margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">总检决策</span>
|
||
<strong>{{ doctorDecisionStatusLabel }}</strong>
|
||
<span class="summary-note">{{ doctorDecisionHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">交付证据</span>
|
||
<strong>{{ goLiveBundleStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveBundleHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">正式复核</span>
|
||
<strong>{{ goLiveReviewStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveReviewHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">签收结论</span>
|
||
<strong>{{ goLiveSignoffStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveSignoffHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">上线收口</span>
|
||
<strong>{{ goLiveSummaryStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveSummaryHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">发布闸门</span>
|
||
<strong>{{ goLivePublishStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveSummary.publish_summary || "当前还没有生成发布结论" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">自动化收口</span>
|
||
<strong>{{ codexAutomationCoverageLaunchStatusLabel }}</strong>
|
||
<span class="summary-note">{{ codexAutomationCoverageLaunchSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">主处理车道</span>
|
||
<strong>{{ opsStackDiagnosisOperatorDecisionLaneLabel }}</strong>
|
||
<span class="summary-note">{{ opsStackDiagnosisOperatorDecision.title || "当前还没有主决策" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">默认下一步</span>
|
||
<strong>{{ opsCockpitNextActionCode || "-" }}</strong>
|
||
<span class="summary-note">{{ opsCockpitNextStepReason }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">后端接管</span>
|
||
<strong>{{ codexAutomationCoverage.backend_handled_total || 0 }} / {{ codexBriefSummary.total || 0 }}</strong>
|
||
<span class="summary-note">只读预览 {{ codexAutomationCoverage.preview_only_total || 0 }} / 人工依赖 {{ codexAutomationCoverage.human_dependency_total || 0 }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">观察层</span>
|
||
<strong>{{ opsStackDiagnosisSurfaceCounts.available || 0 }} / {{ opsStackDiagnosisSurfaceCounts.total || 0 }}</strong>
|
||
<span class="summary-note">{{ opsStackDiagnosisSurfaceStatusLabel }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">现场日志</span>
|
||
<strong>{{ goLiveSummary.log_sync_covered_nodes || 0 }} / {{ goLiveSummary.participating_nodes_total || 0 }}</strong>
|
||
<span class="summary-note">{{ goLiveSummary.log_sync_state || "未启用" }} / {{ goLiveSummary.log_sync_mode || "-" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">接入缺口</span>
|
||
<strong>{{ goLiveSummaryLaunchpadRecoveryLabel || "已收口" }}</strong>
|
||
<span class="summary-note">{{ goLiveSummaryLaunchpadGapSummary }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-list" style="margin-bottom: 12px">
|
||
<div
|
||
v-for="(line, index) in opsCockpitHighlights"
|
||
:key="`ops-cockpit-highlight-${index}`"
|
||
:class="[
|
||
'preview-list__item',
|
||
line.type === 'danger'
|
||
? 'preview-list__item--danger'
|
||
: line.type === 'warning'
|
||
? 'preview-list__item--warning'
|
||
: ''
|
||
]"
|
||
>
|
||
{{ line.text }}
|
||
</div>
|
||
<div v-if="!opsCockpitHighlights.length" class="preview-list__item">
|
||
当前首屏没有额外高优先提示,可继续按默认下一步推进。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box" style="margin-bottom: 12px">
|
||
<div class="preview-box__title">驾驶动作条</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!goLiveSummary.next_step_action_code"
|
||
:loading="loading.driverActionKey === 'go-live-next-step'"
|
||
@click="runGoLiveSummaryNextStep"
|
||
>
|
||
执行默认下一步
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisActionHasFocus(opsStackDiagnosisNextStep)"
|
||
@click="focusOpsStackDiagnosisNextStep"
|
||
>
|
||
定位下一步
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsCockpitNextStepPreviewable"
|
||
@click="openOpsCockpitNextStepContractPreview"
|
||
>
|
||
看下一步契约
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisOperatorDecisionNavigable"
|
||
@click="openOpsStackDiagnosisOperatorDecisionWorkspace"
|
||
>
|
||
进入主处理面板
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveSummaryNextStepCommand"
|
||
@click="copyGoLiveSummaryNextStepCommand"
|
||
>
|
||
复制收口命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisQuickCommandBlock"
|
||
@click="copyOpsCockpitQuickCommands"
|
||
>
|
||
复制快检命令
|
||
</el-button>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="opsCockpitActionFeedback.visible"
|
||
:closable="true"
|
||
show-icon
|
||
:type="opsCockpitActionFeedback.type || 'info'"
|
||
:title="opsCockpitActionFeedback.title || '当前还没有动作回执'"
|
||
style="margin-top: 10px"
|
||
@close="clearOpsCockpitActionFeedback"
|
||
>
|
||
<template #default>
|
||
<div>{{ opsCockpitActionFeedback.detail || "动作已处理。" }}</div>
|
||
</template>
|
||
</el-alert>
|
||
|
||
<div
|
||
v-if="opsCockpitPrimaryActionContracts.length || opsCockpitSummaryContracts.length"
|
||
class="preview-chip-list"
|
||
style="margin-top: 10px"
|
||
>
|
||
<el-button
|
||
v-for="contract in opsCockpitPrimaryActionContracts"
|
||
:key="`ops-cockpit-primary-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
<el-button
|
||
v-for="contract in opsCockpitSummaryContracts"
|
||
:key="`ops-cockpit-summary-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="ops-scene-grid" style="margin-bottom: 0">
|
||
<div id="ops-go-live-summary-panel" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>上线收口详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="goLiveSummaryStatusType" size="small">
|
||
{{ goLiveSummaryStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ goLiveSummary.operator_title || "待生成" }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="goLiveSummaryStatusType"
|
||
:title="goLiveSummaryHeadline"
|
||
/>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px; margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">当前动作</span>
|
||
<strong>{{ goLiveSummary.next_step_action_code || "-" }}</strong>
|
||
<span class="summary-note">{{ goLiveSummary.next_step_reason || "当前还没有默认动作" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">发布闸门</span>
|
||
<strong>{{ goLivePublishStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveSummary.publish_summary || "当前还没有生成发布结论" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">执行面接管</span>
|
||
<strong>{{ goLiveSummary.remote_access_ready || 0 }} / {{ goLiveSummary.managed_enabled || 0 }}</strong>
|
||
<span class="summary-note">remote-access-ready / managed-enabled</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">日志回传</span>
|
||
<strong>{{ goLiveSummary.log_sync_covered_nodes || 0 }} / {{ goLiveSummary.participating_nodes_total || 0 }}</strong>
|
||
<span class="summary-note">{{ goLiveSummary.log_sync_state || "未启用" }} / {{ goLiveSummary.log_sync_mode || "-" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">路由面</span>
|
||
<strong>{{ goLiveSummary.route_surface_complete ? "完整" : "缺口" }}</strong>
|
||
<span class="summary-note">
|
||
{{ (goLiveSummary.route_surface_missing_keys || []).length ? (goLiveSummary.route_surface_missing_keys || []).join("、") : "当前运行 API 已吃到最新入口" }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(line, index) in goLiveSummaryBlockingReasons"
|
||
:key="`go-live-blocking-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
阻断:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveSummaryWarnings"
|
||
:key="`go-live-warning-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
关注:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLivePublishBlockingReasons"
|
||
:key="`go-live-publish-blocking-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
发布阻断:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLivePublishWarnings"
|
||
:key="`go-live-publish-warning-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
发布关注:{{ line }}
|
||
</div>
|
||
<div
|
||
v-if="
|
||
!goLiveSummaryBlockingReasons.length &&
|
||
!goLiveSummaryWarnings.length &&
|
||
!goLivePublishBlockingReasons.length &&
|
||
!goLivePublishWarnings.length
|
||
"
|
||
class="preview-list__item"
|
||
>
|
||
当前 go-live summary 没有新的阻断或告警,可继续按发布驾驶舱推进。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!goLiveSummary.next_step_action_code"
|
||
:loading="loading.driverActionKey === 'go-live-next-step'"
|
||
@click="runGoLiveSummaryNextStep"
|
||
>
|
||
{{ goLiveSummary.next_step_action_code || opsStackDiagnosisNextActionLabel }}
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveSummaryNextStepCommand"
|
||
@click="copyGoLiveSummaryNextStepCommand"
|
||
>
|
||
复制收口命令
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="goLiveSummaryRecommendedCommandEntries.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="entry in goLiveSummaryRecommendedCommandEntries.slice(0, 5)"
|
||
:key="`go-live-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" effect="plain">{{ entry.label }}</el-tag>
|
||
<span>{{ entry.command }}</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 6px">
|
||
<el-button size="small" plain @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="opsStackDiagnosisOperatorDecision.title || opsStackDiagnosisNextActions.primary_command"
|
||
class="preview-box"
|
||
style="margin-top: 12px"
|
||
>
|
||
<div class="preview-box__title">当前主决策</div>
|
||
<div class="driver-card__head" style="margin-bottom: 8px">
|
||
<strong>{{ opsStackDiagnosisOperatorDecision.title || "当前还没有主决策" }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="opsStackDiagnosisOperatorDecisionTagType" effect="plain" size="small">
|
||
{{ opsStackDiagnosisOperatorDecisionLaneLabel }}
|
||
</el-tag>
|
||
<el-tag effect="plain" size="small">
|
||
{{ opsStackDiagnosisOperatorDecisionPriorityLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__summary">
|
||
{{ opsStackDiagnosisOperatorDecision.summary || "当前还没有统一主决策摘要。" }}
|
||
</div>
|
||
<div class="driver-card__meta">
|
||
<span v-if="opsStackDiagnosisOperatorDecision.reason_code">
|
||
原因:{{ opsStackDiagnosisOperatorDecision.reason_code }}
|
||
</span>
|
||
<span v-if="opsStackDiagnosisOperatorDecision.primary_command_key">
|
||
主动作:{{ opsStackDiagnosisOperatorDecision.primary_command_key }}
|
||
</span>
|
||
<span v-if="opsStackDiagnosisOperatorDecision.secondary_command_key">
|
||
复核:{{ opsStackDiagnosisOperatorDecision.secondary_command_key }}
|
||
</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 10px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:disabled="!opsStackDiagnosisOperatorDecisionNavigable"
|
||
@click="openOpsStackDiagnosisOperatorDecisionWorkspace"
|
||
>
|
||
进入主处理面板
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisNextActions.primary_command"
|
||
@click="copyOpsStackDiagnosisPrimaryCommand"
|
||
>
|
||
复制主动作命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisOperatorDecisionHasFocus"
|
||
@click="focusOpsStackDiagnosisOperatorDecision"
|
||
>
|
||
定位主焦点
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisNextActions.secondary_command"
|
||
@click="copyOpsStackDiagnosisSecondaryCommand"
|
||
>
|
||
复制复核命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="(line, index) in opsStackDiagnosisOperatorHints"
|
||
:key="`stack-diagnosis-hint-${index}`"
|
||
class="preview-list__item"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
<div v-if="!opsStackDiagnosisOperatorHints.length" class="preview-empty">
|
||
当前还没有额外的总检提示。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!opsStackDiagnosisNextStep.action_code"
|
||
:loading="loading.driverActionKey === 'stack-diagnosis-next-step'"
|
||
@click="runOpsStackDiagnosisNextStep"
|
||
>
|
||
{{ opsStackDiagnosisNextActionLabel }}
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisActionHasFocus(opsStackDiagnosisNextStep)"
|
||
@click="focusOpsStackDiagnosisNextStep"
|
||
>
|
||
定位下一步
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!opsStackDiagnosisNextCommand"
|
||
@click="copyOpsStackDiagnosisNextCommand"
|
||
>
|
||
复制下一步命令
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="opsStackDiagnosisNextStepContracts.length" class="preview-chip-list" style="margin-top: 10px">
|
||
<el-button
|
||
v-for="contract in opsStackDiagnosisNextStepContracts"
|
||
:key="`stack-next-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="opsStackDiagnosisSummaryContracts.length" class="preview-chip-list" style="margin-top: 12px">
|
||
<el-button
|
||
v-for="contract in opsStackDiagnosisSummaryContracts"
|
||
:key="`stack-contract-summary-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ops-doctor-decision-panel" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>总检主决策详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="doctorDecisionStatusType" size="small">
|
||
{{ doctorDecisionStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ doctorDecisionPreferredSurfaceLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="doctorDecisionStatusType"
|
||
:title="doctorDecisionHeadline"
|
||
:description="doctorDecisionDetail"
|
||
/>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px; margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">主动作</span>
|
||
<strong>{{ doctorDecisionNextActionCode }}</strong>
|
||
<span class="summary-note">当前总检判定的默认下一步</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">建议工作区</span>
|
||
<strong>{{ doctorDecisionPreferredSurfaceLabel }}</strong>
|
||
<span class="summary-note">总检主决策建议优先进入的处理面板</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">原因码</span>
|
||
<strong>{{ doctorDecisionReasonCode }}</strong>
|
||
<span class="summary-note">{{ doctorDecisionDetail }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">现场附件</span>
|
||
<strong>{{ doctorDecisionSummary.scene_log_reports_ok || 0 }} / {{ doctorDecisionSummary.scene_log_reports_total || 0 }}</strong>
|
||
<span class="summary-note">{{ doctorDecisionSceneLogSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">launchpad 摘要</span>
|
||
<strong>{{ doctorDecisionSummary.launchpad_recommended_target_node_code || "-" }}</strong>
|
||
<span class="summary-note">{{ doctorDecisionLaunchpadSummary }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small" style="margin-bottom: 12px">
|
||
<el-descriptions-item label="主决策契约">
|
||
{{ contractPrimaryEndpoint(doctorDecisionPrimaryContract, "/api/v1/ops/doctor-decision") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(doctorDecisionPrimaryContract, "docs/schemas/ops_doctor_decision_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="报告目录">
|
||
{{ doctorDecisionReportDirLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Manifest 路径">
|
||
{{ doctorDecisionManifestPathLabel }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(line, index) in doctorDecisionRequiredFailures"
|
||
:key="`doctor-decision-required-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
必需 surface 缺失:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in doctorDecisionOptionalUnavailable"
|
||
:key="`doctor-decision-optional-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
preview 待补:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in doctorDecisionContractSurfaceGaps"
|
||
:key="`doctor-decision-contract-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
contract 缺口:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in doctorDecisionSceneLogProblemNodeCodes"
|
||
:key="`doctor-decision-scene-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
现场附件待补节点:{{ line }}
|
||
</div>
|
||
<div
|
||
v-if="
|
||
!doctorDecisionRequiredFailures.length &&
|
||
!doctorDecisionOptionalUnavailable.length &&
|
||
!doctorDecisionContractSurfaceGaps.length &&
|
||
!doctorDecisionSceneLogProblemNodeCodes.length
|
||
"
|
||
class="preview-list__item"
|
||
>
|
||
当前总检主决策没有额外缺口说明,可直接按主建议继续推进。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
@click="openDoctorDecisionWorkspace"
|
||
>
|
||
进入建议工作区
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!doctorDecisionPrimaryCommand"
|
||
@click="copyDoctorDecisionCommand"
|
||
>
|
||
复制主建议命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!doctorDecisionPrimaryContract.key"
|
||
@click="openOpsContractDetail(doctorDecisionPrimaryContract.key)"
|
||
>
|
||
查看主决策契约
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="doctorDecisionRecommendedCommandEntries.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="entry in doctorDecisionRecommendedCommandEntries.slice(0, 6)"
|
||
:key="`doctor-decision-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" effect="plain">{{ entry.label }}</el-tag>
|
||
<span>{{ entry.command }}</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 6px">
|
||
<el-button size="small" plain @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="doctorDecisionContracts.length" class="preview-chip-list" style="margin-top: 12px">
|
||
<el-button
|
||
v-for="contract in doctorDecisionContracts"
|
||
:key="`doctor-decision-contract-nav-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>最终签收详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="goLiveSignoffStatusType" size="small">
|
||
{{ goLiveSignoffStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ goLiveSignoffPreferredSurfaceLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="goLiveSignoffStatusType"
|
||
:title="goLiveSignoffHeadline"
|
||
/>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px; margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">签收动作</span>
|
||
<strong>{{ goLiveSignoffDecisionActionCode }}</strong>
|
||
<span class="summary-note">{{ goLiveSignoffDecisionReason }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">建议工作区</span>
|
||
<strong>{{ goLiveSignoffPreferredSurfaceLabel }}</strong>
|
||
<span class="summary-note">签收结论建议优先进入的处理面板</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">launchpad 摘要</span>
|
||
<strong>{{ goLiveSignoffAlignmentStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveSignoffAlignmentSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">目标节点</span>
|
||
<strong>{{ goLiveSignoffLaunchpadTargetNodeCode }}</strong>
|
||
<span class="summary-note">{{ goLiveSignoffLaunchpadRecoveryLabel }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">阻断 / 关注</span>
|
||
<strong>{{ goLiveSignoffBlockedReasons.length }} / {{ goLiveSignoffAttentionReasons.length }}</strong>
|
||
<span class="summary-note">最终签收仍需处理的风险数量</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">正式复核 / 主决策</span>
|
||
<strong>{{ goLiveSignoffReviewStatusLabel }} / {{ goLiveSignoffDoctorStatusLabel }}</strong>
|
||
<span class="summary-note">签收层当前引用的正式复核与总检主决策状态</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small" style="margin-bottom: 12px">
|
||
<el-descriptions-item label="签收契约">
|
||
{{ contractPrimaryEndpoint(goLiveSignoffPrimaryContract, "/api/v1/ops/go-live-signoff") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(goLiveSignoffPrimaryContract, "docs/schemas/ops_go_live_signoff_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="操作员结论">
|
||
{{ goLiveSignoffDecisionOperatorTitle }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Stack 主决策">
|
||
{{ goLiveSignoffStackOperatorTitle }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Bundle 目录">
|
||
{{ goLiveSignoffReportDirLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Manifest 路径">
|
||
{{ goLiveSignoffManifestPathLabel }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(line, index) in goLiveSignoffBlockedReasons"
|
||
:key="`go-live-signoff-blocked-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
签收阻断:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveSignoffAttentionReasons"
|
||
:key="`go-live-signoff-attention-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
签收关注:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveSignoffAlignmentLines"
|
||
:key="`go-live-signoff-alignment-${index}`"
|
||
:class="[
|
||
'preview-list__item',
|
||
goLiveSignoffAlignmentConsistent ? '' : 'preview-list__item--warning'
|
||
]"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
<div
|
||
v-if="
|
||
!goLiveSignoffBlockedReasons.length &&
|
||
!goLiveSignoffAttentionReasons.length &&
|
||
!goLiveSignoffAlignmentLines.length
|
||
"
|
||
class="preview-list__item"
|
||
>
|
||
当前最终签收结论没有额外说明项,可直接进入正式签字或发布阶段。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
@click="openGoLiveSignoffWorkspace"
|
||
>
|
||
进入建议工作区
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveSignoffPrimaryCommand"
|
||
@click="copyGoLiveSignoffCommand"
|
||
>
|
||
复制签收命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveSignoffPrimaryContract.key"
|
||
@click="openOpsContractDetail(goLiveSignoffPrimaryContract.key)"
|
||
>
|
||
查看签收契约
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="goLiveSignoffRecommendedCommandEntries.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="entry in goLiveSignoffRecommendedCommandEntries.slice(0, 5)"
|
||
:key="`go-live-signoff-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" effect="plain">{{ entry.label }}</el-tag>
|
||
<span>{{ entry.command }}</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 6px">
|
||
<el-button size="small" plain @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="goLiveSignoffContracts.length" class="preview-chip-list" style="margin-top: 12px">
|
||
<el-button
|
||
v-for="contract in goLiveSignoffContracts"
|
||
:key="`go-live-signoff-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ops-go-live-review-panel" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>正式复核详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="goLiveReviewStatusType" size="small">
|
||
{{ goLiveReviewStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ goLiveReviewManifestReadyLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="goLiveReviewStatusType"
|
||
:title="goLiveReviewHeadline"
|
||
/>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px; margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">复核状态</span>
|
||
<strong>{{ goLiveReviewStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveReviewHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">artifact / failure</span>
|
||
<strong>{{ goLiveReview.artifact_total || 0 }} / {{ goLiveReview.failure_total || 0 }}</strong>
|
||
<span class="summary-note">当前复核看到的证据条目总数 / 失败项总数</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">launchpad 对齐</span>
|
||
<strong>{{ goLiveReviewAlignmentStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveReviewAlignmentSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">环境审计</span>
|
||
<strong>{{ goLiveReviewEnvAuditStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveReviewEnvAuditHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">Manifest</span>
|
||
<strong>{{ goLiveReviewManifestReadyLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveReviewManifestPathLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small" style="margin-bottom: 12px">
|
||
<el-descriptions-item label="复核契约">
|
||
{{ contractPrimaryEndpoint(goLiveReviewPrimaryContract, "/api/v1/ops/go-live-review") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(goLiveReviewPrimaryContract, "docs/schemas/ops_go_live_review_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Bundle 目录">
|
||
{{ goLiveReviewReportDirLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Manifest 路径">
|
||
{{ goLiveReviewManifestPathLabel }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(line, index) in goLiveReviewCriticalFailures"
|
||
:key="`go-live-review-critical-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
关键失败:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveReviewNoncriticalFailures"
|
||
:key="`go-live-review-noncritical-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
补充失败:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveReviewEnvAuditMissingItems"
|
||
:key="`go-live-review-env-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
环境缺口:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveReviewAlignmentLines"
|
||
:key="`go-live-review-alignment-${index}`"
|
||
:class="[
|
||
'preview-list__item',
|
||
goLiveReviewAlignmentConsistent ? '' : 'preview-list__item--warning'
|
||
]"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
<div
|
||
v-if="
|
||
!goLiveReviewCriticalFailures.length &&
|
||
!goLiveReviewNoncriticalFailures.length &&
|
||
!goLiveReviewEnvAuditMissingItems.length &&
|
||
!goLiveReviewAlignmentLines.length
|
||
"
|
||
class="preview-list__item"
|
||
>
|
||
当前正式复核没有额外缺口说明,可继续进入最终签收或发布门禁。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
@click="openGoLiveReviewWorkspace"
|
||
>
|
||
进入复核面板
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveReviewPrimaryCommand"
|
||
@click="copyGoLiveReviewCommand"
|
||
>
|
||
复制复核命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveReviewPrimaryContract.key"
|
||
@click="openOpsContractDetail(goLiveReviewPrimaryContract.key)"
|
||
>
|
||
查看复核契约
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="goLiveReviewRecommendedCommandEntries.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="entry in goLiveReviewRecommendedCommandEntries.slice(0, 5)"
|
||
:key="`go-live-review-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" effect="plain">{{ entry.label }}</el-tag>
|
||
<span>{{ entry.command }}</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 6px">
|
||
<el-button size="small" plain @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="goLiveReviewRecommendedNextSteps.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="(line, index) in goLiveReviewRecommendedNextSteps.slice(0, 6)"
|
||
:key="`go-live-review-next-step-${index}`"
|
||
class="preview-list__item"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="goLiveReviewContracts.length" class="preview-chip-list" style="margin-top: 12px">
|
||
<el-button
|
||
v-for="contract in goLiveReviewContracts"
|
||
:key="`go-live-review-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ops-go-live-bundle-panel" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>交付证据详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="goLiveBundleStatusType" size="small">
|
||
{{ goLiveBundleStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ goLiveBundle.bundle_available ? "manifest 已就绪" : "等待导出" }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="goLiveBundleStatusType"
|
||
:title="goLiveBundleHeadline"
|
||
/>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px; margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">证据状态</span>
|
||
<strong>{{ goLiveBundleStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveBundleSummaryHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">artifact / failure</span>
|
||
<strong>{{ goLiveBundle.artifact_total || 0 }} / {{ goLiveBundle.failure_total || 0 }}</strong>
|
||
<span class="summary-note">导出条目总数 / 当前失败项总数</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">launchpad 对齐</span>
|
||
<strong>{{ goLiveBundleAlignmentStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveBundleLaunchpadSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">环境审计</span>
|
||
<strong>{{ goLiveBundleEnvAuditStatusLabel }}</strong>
|
||
<span class="summary-note">{{ goLiveBundleEnvAuditHeadline }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">manifest</span>
|
||
<strong>{{ goLiveBundle.bundle_available ? "已发现" : "未发现" }}</strong>
|
||
<span class="summary-note">{{ goLiveBundleManifestPathLabel }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small" style="margin-bottom: 12px">
|
||
<el-descriptions-item label="证据契约">
|
||
{{ contractPrimaryEndpoint(goLiveBundlePrimaryContract, "/api/v1/ops/go-live-bundle") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(goLiveBundlePrimaryContract, "docs/schemas/ops_go_live_bundle_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Bundle 目录">
|
||
{{ goLiveBundleReportDirLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Manifest 路径">
|
||
{{ goLiveBundleManifestPathLabel }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(line, index) in goLiveBundleCriticalFailures"
|
||
:key="`go-live-bundle-critical-${index}`"
|
||
class="preview-list__item preview-list__item--danger"
|
||
>
|
||
关键失败:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveBundleNoncriticalFailures"
|
||
:key="`go-live-bundle-noncritical-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
补充失败:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveBundleEnvAuditMissingItems"
|
||
:key="`go-live-bundle-env-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
环境缺口:{{ line }}
|
||
</div>
|
||
<div
|
||
v-for="(line, index) in goLiveBundleContractSurfaceGaps"
|
||
:key="`go-live-bundle-contract-gap-${index}`"
|
||
class="preview-list__item preview-list__item--warning"
|
||
>
|
||
contract 缺口:{{ line }}
|
||
</div>
|
||
<div
|
||
v-if="
|
||
!goLiveBundleCriticalFailures.length &&
|
||
!goLiveBundleNoncriticalFailures.length &&
|
||
!goLiveBundleEnvAuditMissingItems.length &&
|
||
!goLiveBundleContractSurfaceGaps.length
|
||
"
|
||
class="preview-list__item"
|
||
>
|
||
当前 bundle 没有额外失败项,可继续做最终签收或正式交付。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 12px">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!goLiveBundleExportCommand"
|
||
@click="copyOpsStackDiagnosisRecommendedCommand(goLiveBundleExportCommand)"
|
||
>
|
||
复制导出命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveBundleReviewCommand"
|
||
@click="copyOpsStackDiagnosisRecommendedCommand(goLiveBundleReviewCommand)"
|
||
>
|
||
复制复核命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveBundleSignoffCommand"
|
||
@click="copyOpsStackDiagnosisRecommendedCommand(goLiveBundleSignoffCommand)"
|
||
>
|
||
复制签收命令
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!goLiveBundlePrimaryContract.key"
|
||
@click="openOpsContractDetail(goLiveBundlePrimaryContract.key)"
|
||
>
|
||
查看证据契约
|
||
</el-button>
|
||
</div>
|
||
|
||
<div v-if="goLiveBundleRecommendedCommandEntries.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="entry in goLiveBundleRecommendedCommandEntries.slice(0, 5)"
|
||
:key="`go-live-bundle-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" effect="plain">{{ entry.label }}</el-tag>
|
||
<span>{{ entry.command }}</span>
|
||
</div>
|
||
<div class="driver-card__actions" style="justify-content: flex-start; margin-top: 6px">
|
||
<el-button size="small" plain @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="goLiveBundleContracts.length" class="preview-chip-list" style="margin-top: 12px">
|
||
<el-button
|
||
v-for="contract in goLiveBundleContracts"
|
||
:key="`go-live-bundle-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>总检下钻详情</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsStackDiagnosisSurfaceStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsStackDiagnosisAutomationStatusLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid" style="margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">问题总数</span>
|
||
<strong>{{ opsStackDiagnosisDiagnosis.issue_total || 0 }}</strong>
|
||
<span class="summary-note">
|
||
阻断 {{ opsStackDiagnosisDiagnosis.blocking_issue_total || 0 }} / 警告 {{ opsStackDiagnosisDiagnosis.warning_issue_total || 0 }}
|
||
</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">观察层</span>
|
||
<strong>{{ opsStackDiagnosisSurfaceCounts.available || 0 }} / {{ opsStackDiagnosisSurfaceCounts.total || 0 }}</strong>
|
||
<span class="summary-note">缺失 {{ opsStackDiagnosisSurfaceCounts.missing || 0 }} 层</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">默认动作</span>
|
||
<strong>{{ opsStackDiagnosisNextStep.action_code || "-" }}</strong>
|
||
<span class="summary-note">{{ opsStackDiagnosisNextStep.source || "当前还没有默认动作" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">主决策</span>
|
||
<strong>{{ opsStackDiagnosisOperatorDecisionLaneLabel }}</strong>
|
||
<span class="summary-note">{{ opsStackDiagnosisOperatorDecision.title || "当前还没有主决策" }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="主入口">
|
||
{{ contractPrimaryEndpoint(opsStackDiagnosisContract, "/api/v1/ops/stack-diagnosis") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(opsStackDiagnosisContract, "docs/schemas/ops_stack_diagnosis_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缺失观察层">
|
||
{{ opsStackDiagnosisMissingSurfacesText }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Base URL">
|
||
{{ opsStackDiagnosisDiagnosis.base_url || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid preview-grid--triple" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>问题清单</strong>
|
||
<el-tag size="small" effect="plain">{{ opsStackDiagnosisIssues.length }}</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="issue in opsStackDiagnosisIssues.slice(0, 5)"
|
||
:key="issue.code"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<el-tag size="small" :type="opsStackDiagnosisIssueTagType(issue.severity)">
|
||
{{ issue.severity || "info" }}
|
||
</el-tag>
|
||
<span>{{ issue.summary || issue.code || "-" }}</span>
|
||
</div>
|
||
<div v-if="issue.detail" class="stack-diag-line__meta">
|
||
{{ issue.detail }}
|
||
</div>
|
||
<div v-if="contractNavigationContracts(issue).length" class="preview-chip-list" style="margin-top: 8px">
|
||
<el-button
|
||
v-for="contract in contractNavigationContracts(issue)"
|
||
:key="`stack-issue-${issue.code}-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!opsStackDiagnosisIssues.length" class="preview-empty">当前没有结构化问题项。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>推荐动作</strong>
|
||
<el-tag size="small" effect="plain">{{ opsStackDiagnosisRecommendedActions.length }}</el-tag>
|
||
</div>
|
||
<div v-if="opsStackDiagnosisRecommendedActions.length" class="driver-card__actions" style="justify-content: flex-start; margin-bottom: 12px">
|
||
<el-button
|
||
v-for="(action, index) in opsStackDiagnosisRecommendedActions.slice(0, 4)"
|
||
:key="`${action.action_code || action.title}-${index}`"
|
||
size="small"
|
||
:type="index === 0 ? 'primary' : ''"
|
||
:plain="index !== 0"
|
||
:loading="loading.driverActionKey === `stack-diagnosis-action-${index}`"
|
||
:disabled="!action.action_code"
|
||
@click="runOpsStackDiagnosisAction(action, index)"
|
||
>
|
||
{{ action.title || action.action_code || "执行动作" }}
|
||
</el-button>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="(action, index) in opsStackDiagnosisRecommendedActions.slice(0, 4)"
|
||
:key="`stack-diagnosis-action-summary-${index}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<span>{{ action.source || action.action_code || "-" }}</span>
|
||
<el-button
|
||
v-if="opsStackDiagnosisActionHasFocus(action)"
|
||
size="small"
|
||
text
|
||
@click="focusOpsStackDiagnosisAction(action)"
|
||
>
|
||
定位
|
||
</el-button>
|
||
</div>
|
||
<div class="stack-diag-line__meta">
|
||
{{ action.reason || "当前还没有补充说明。" }}
|
||
</div>
|
||
<div v-if="contractNavigationContracts(action).length" class="preview-chip-list" style="margin-top: 8px">
|
||
<el-button
|
||
v-for="contract in contractNavigationContracts(action)"
|
||
:key="`stack-action-${index}-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!opsStackDiagnosisRecommendedActions.length" class="preview-empty">当前没有额外推荐动作。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>快检命令</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">{{ opsStackDiagnosisQuickCommands.length }}</el-tag>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:disabled="!opsStackDiagnosisQuickCommands.length"
|
||
@click="copyOpsStackDiagnosisCommands"
|
||
>
|
||
复制全部
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="opsStackDiagnosisQuickCommands.length" class="code-block stack-command-block">
|
||
<pre>{{ opsStackDiagnosisQuickCommandBlock }}</pre>
|
||
</div>
|
||
<div v-else class="preview-empty">当前还没有可复制的快检命令。</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>推荐命令</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">{{ opsStackDiagnosisRecommendedCommandEntries.length }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div
|
||
v-for="entry in opsStackDiagnosisRecommendedCommandEntries"
|
||
:key="`stack-command-${entry.key}`"
|
||
class="stack-diag-line"
|
||
>
|
||
<div class="stack-diag-line__head">
|
||
<span>{{ entry.label }}</span>
|
||
<el-button size="small" text @click="copyOpsStackDiagnosisRecommendedCommand(entry.command)">
|
||
复制
|
||
</el-button>
|
||
</div>
|
||
<div class="stack-diag-line__meta code-inline-wrap">{{ entry.command }}</div>
|
||
</div>
|
||
<div v-if="!opsStackDiagnosisRecommendedCommandEntries.length" class="preview-empty">
|
||
当前还没有结构化推荐命令。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid preview-grid--double" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>运行中 API 版本面</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" :type="opsStackDiagnosisRuntimeBuildTagType" effect="plain">
|
||
{{ opsStackDiagnosisRuntimeBuildStatusLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="来源">
|
||
{{ opsStackDiagnosisRuntimeBuildInfo.source || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="分支/提交">
|
||
{{ opsStackDiagnosisRuntimeBuildIdentityText }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="生成时间">
|
||
{{ opsStackDiagnosisRuntimeBuildInfo.generated_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="路由面">
|
||
{{ opsStackDiagnosisRuntimeRouteSurfaceText }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div v-if="opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.length" class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="key in opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.slice(0, 8)"
|
||
:key="`runtime-route-gap-${key}`"
|
||
class="preview-list__item"
|
||
>
|
||
缺少入口:{{ key }}
|
||
</div>
|
||
</div>
|
||
<div v-else class="preview-empty" style="margin-top: 12px">
|
||
当前运行中的 API 路由面完整。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>版本面判断</strong>
|
||
<div class="table-actions">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:disabled="!opsStackDiagnosisRuntimeApiRestartCommand"
|
||
@click="copyOpsStackDiagnosisRuntimeApiRestartCommand"
|
||
>
|
||
复制 API 重启命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="stack-diag-line">
|
||
<div class="stack-diag-line__head">
|
||
<span>{{ opsStackDiagnosisRuntimeBuildHeadline }}</span>
|
||
</div>
|
||
<div class="stack-diag-line__meta">
|
||
{{ opsStackDiagnosisRuntimeBuildSummary }}
|
||
</div>
|
||
</div>
|
||
<div v-if="opsStackDiagnosisRuntimeBuildInfo.manifest_path" class="stack-diag-line">
|
||
<div class="stack-diag-line__head">
|
||
<span>Manifest</span>
|
||
</div>
|
||
<div class="stack-diag-line__meta code-inline-wrap">
|
||
{{ opsStackDiagnosisRuntimeBuildInfo.manifest_path }}
|
||
</div>
|
||
</div>
|
||
<div v-if="opsStackDiagnosisRuntimeApiRestartCommand" class="stack-diag-line">
|
||
<div class="stack-diag-line__head">
|
||
<span>建议动作</span>
|
||
</div>
|
||
<div class="stack-diag-line__meta code-inline-wrap">
|
||
{{ opsStackDiagnosisRuntimeApiRestartCommand }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="ops-execution-scene-panel" class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>联调快照</h4>
|
||
<p>把当前控制面、执行面、日志回传和跨地域同步压成一张短卡,海外控制面打开页面先看这里就知道下一步该做什么。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
{{ opsLinkSnapshot.generated_at || "尚未生成" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ops-scene-grid" style="margin-bottom: 0">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>当前判断</strong>
|
||
<el-tag :type="opsLinkSnapshotStatusType" size="small">
|
||
{{ opsLinkSnapshotStatusLabel }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="opsLinkSnapshotStatusType"
|
||
:title="opsLinkSnapshotHeadline"
|
||
/>
|
||
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div
|
||
v-for="line in opsLinkSnapshotSummaryLines"
|
||
:key="line"
|
||
class="preview-list__item"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
<div v-if="!opsLinkSnapshotSummaryLines.length" class="preview-empty">
|
||
当前还没有可展示的联调快照摘要。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>建议动作</strong>
|
||
<div class="table-actions">
|
||
<el-tag effect="plain" size="small">
|
||
{{ opsLinkSnapshotActions.length }} 个
|
||
</el-tag>
|
||
<el-button
|
||
v-if="opsLinkSnapshotPreviewLines.length"
|
||
size="small"
|
||
text
|
||
@click="openRemoteLogDrawer()"
|
||
>
|
||
看日志样本
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="opsLinkSnapshotActions.length" class="driver-card__actions" style="justify-content: flex-start; margin-bottom: 12px">
|
||
<el-button
|
||
v-for="(action, index) in opsLinkSnapshotActions"
|
||
:key="`${action.action_code || action.label}-${index}`"
|
||
size="small"
|
||
:type="index === 0 ? 'primary' : ''"
|
||
:plain="index !== 0"
|
||
:loading="loading.driverActionKey === `link-snapshot-${index}`"
|
||
:disabled="!action.action_code"
|
||
@click="runOpsLinkSnapshotAction(action, index)"
|
||
>
|
||
{{ action.label || action.action_code || "执行动作" }}
|
||
</el-button>
|
||
</div>
|
||
<div v-else class="preview-empty" style="margin-bottom: 12px">
|
||
当前没有额外建议动作,现场已经比较收口。
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="控制面">
|
||
{{ opsLinkSnapshot.control_plane?.node_code || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="托管方式">
|
||
{{ opsLinkSnapshot.control_plane?.worker_mode || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行节点">
|
||
{{ opsLinkSnapshot.cluster?.effective_online_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="独立 Worker">
|
||
{{ opsLinkSnapshot.cluster?.dedicated_online_worker_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="日志覆盖">
|
||
{{ opsLinkSnapshot.log_sync?.covered_participating_node_count || 0 }} / {{ opsLinkSnapshot.log_sync?.participating_node_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="日志模式">
|
||
{{ opsLinkSnapshot.log_sync?.enabled ? (opsLinkSnapshot.log_sync?.mode === "full" ? "全量回传" : "关键回传") : "已关闭" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="同步链路">
|
||
{{ opsLinkSnapshot.sync?.enabled ? "启用" : "未启用" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="同步方向">
|
||
{{ opsLinkSnapshot.sync?.source_region || "-" }} -> {{ opsLinkSnapshot.sync?.target_region || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="ops-execution-scene-panel" class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>驾驶主线</h4>
|
||
<p>把当前优先建议、现场异常和标准作业路径压成一份统一驾驶 feed。后续海外 Codex、后台按钮和命令行都应该优先复用这一份结果。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
当前优先 {{ driverFeedSummary.do_now_total || 0 }} / 标准路径 {{ driverFeedSummary.standard_path_total || 0 }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="driverFeedHeadline"
|
||
:closable="false"
|
||
show-icon
|
||
type="info"
|
||
:title="driverFeedHeadline"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
|
||
<div v-if="driverFeedSummaryContracts.length" class="preview-box" style="margin-bottom: 12px">
|
||
<div class="preview-box__title">主线协议</div>
|
||
<div class="preview-chip-list">
|
||
<el-button
|
||
v-for="contract in driverFeedSummaryContracts"
|
||
:key="`driver-feed-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="driverFeedSceneLogObservation.status !== 'standby'" class="preview-box" style="margin-bottom: 12px">
|
||
<div class="preview-box__title">现场日志观察</div>
|
||
<div class="driver-card__head" style="margin-bottom: 8px">
|
||
<strong>{{ driverFeedSceneLogObservation.title }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="driverFeedSceneLogObservation.tagType" effect="plain" size="small">
|
||
{{ driverFeedSceneLogObservation.statusLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__summary">{{ driverFeedSceneLogObservation.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span v-if="driverFeedSceneLogObservation.targetNodeCode">目标节点:{{ driverFeedSceneLogObservation.targetNodeCode }}</span>
|
||
<span>覆盖 {{ driverFeedSceneLogObservation.coveredCount }} / {{ driverFeedSceneLogObservation.participatingCount }}</span>
|
||
<span>样本 {{ driverFeedSceneLogObservation.lineCount }}</span>
|
||
</div>
|
||
<div v-if="driverFeedSceneLogContracts.length" class="preview-chip-list" style="margin-top: 10px">
|
||
<el-button
|
||
v-for="contract in driverFeedSceneLogContracts"
|
||
:key="`driver-feed-scene-log-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
<div class="driver-card__actions" style="margin-top: 10px">
|
||
<el-button
|
||
size="small"
|
||
:type="driverFeedSceneLogObservation.tagType === 'danger' ? 'danger' : 'primary'"
|
||
:plain="driverFeedSceneLogObservation.tagType !== 'danger'"
|
||
:disabled="!driverFeedSceneLogObservation.focusRef.kind"
|
||
@click="applyDriverFocusRef(driverFeedSceneLogObservation.focusRef)"
|
||
>
|
||
查看现场日志
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="driverFeedCards.length" class="driver-grid">
|
||
<div v-for="card in driverFeedCards" :key="card.key" class="driver-card">
|
||
<div class="driver-card__head">
|
||
<strong>{{ card.title }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="card.tagType" effect="plain" size="small">{{ card.levelLabel }}</el-tag>
|
||
<el-tag effect="plain" size="small">{{ card.laneLabel }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__summary">{{ card.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span>{{ card.reason }}</span>
|
||
<span v-if="card.nodeCodesText">{{ card.nodeCodesText }}</span>
|
||
<span v-if="card.statusText">{{ card.statusText }}</span>
|
||
</div>
|
||
<div v-if="card.detailLines.length" class="preview-list driver-card__preview">
|
||
<div v-for="line in card.detailLines" :key="`${card.key}-${line}`" class="preview-list__item">
|
||
{{ line }}
|
||
</div>
|
||
</div>
|
||
<div v-if="contractNavigationContracts(card).length" class="preview-chip-list" style="margin-bottom: 10px">
|
||
<el-button
|
||
v-for="contract in contractNavigationContracts(card)"
|
||
:key="`driver-feed-card-contract-${card.key}-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
<div class="driver-card__actions">
|
||
<el-button
|
||
size="small"
|
||
:type="card.primaryType || 'primary'"
|
||
:plain="card.primaryPlain !== false"
|
||
:disabled="Boolean(card.disabled)"
|
||
:loading="loading.driverActionKey === `driver-feed-${card.key}-primary`"
|
||
@click="runDriverFeedEntry(card)"
|
||
>
|
||
{{ card.primaryLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.previewable"
|
||
size="small"
|
||
plain
|
||
@click="openDriverActionContractPreview(card, { sourceLabel: 'driver-feed' })"
|
||
>
|
||
看契约
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.secondaryLabel"
|
||
size="small"
|
||
plain
|
||
:disabled="Boolean(card.disabledSecondary)"
|
||
:loading="loading.driverActionKey === `driver-feed-${card.key}-secondary`"
|
||
@click="runDriverFeedEntry(card, { secondary: true })"
|
||
>
|
||
{{ card.secondaryLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="preview-empty">当前还没有统一驾驶主线。</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>自动驾驶判断</h4>
|
||
<p>把驾驶主线进一步收口成“可自动执行 / 建议确认 / 先复核 / 进入工作区 / 当前阻断”,供海外 Codex 和后台人工操作共享同一套执行口径。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
自动 {{ codexBriefSummary.auto_execute_total || 0 }} / 确认 {{ codexBriefSummary.confirm_then_execute_total || 0 }} / 复核 {{ codexBriefSummary.resolve_first_total || 0 }} / UI {{ codexBriefSummary.open_ui_total || 0 }} / 阻断 {{ codexBriefSummary.blocked_total || 0 }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="codexBriefHeadline"
|
||
:closable="false"
|
||
show-icon
|
||
type="info"
|
||
:title="codexBriefHeadline"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
|
||
<div class="inspection-summary-grid" style="margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">自动化收口</span>
|
||
<strong>{{ codexAutomationCoverageLaunchStatusLabel }}</strong>
|
||
<span class="summary-note">{{ codexAutomationCoverageLaunchSummary }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">执行就绪</span>
|
||
<strong>{{ codexAutomationCoverage.execution_ready_total || 0 }}</strong>
|
||
<span class="summary-note">auto_execute + confirm_then_execute</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">人工依赖</span>
|
||
<strong>{{ codexAutomationCoverage.human_dependency_total || 0 }}</strong>
|
||
<span class="summary-note">resolve_first / open_ui / blocked</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">后端接管</span>
|
||
<strong>{{ codexAutomationCoverage.backend_handled_total || 0 }} / {{ codexBriefSummary.total || 0 }}</strong>
|
||
<span class="summary-note">已后端化动作 / 总动作</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">只读预览</span>
|
||
<strong>{{ codexAutomationCoverage.preview_only_total || 0 }}</strong>
|
||
<span class="summary-note">preview / focus / review 类动作</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">自动化等级</span>
|
||
<strong>{{ codexAutomationCoverage.safe_auto_total || 0 }} / {{ codexAutomationCoverage.guarded_auto_total || 0 }}</strong>
|
||
<span class="summary-note">safe_auto / guarded_auto</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">混合与阻断</span>
|
||
<strong>{{ codexAutomationCoverage.mixed_total || 0 }} / {{ codexAutomationCoverage.blocked_total || 0 }}</strong>
|
||
<span class="summary-note">mixed / blocked</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">当前焦点</span>
|
||
<strong>{{ codexBriefFocus.title || "-" }}</strong>
|
||
<span class="summary-note">{{ codexBriefFocus.status_text || "当前还没有焦点判断" }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="codexAutomationCoverageLaunchTagType"
|
||
:title="codexAutomationCoverageLaunchSummary"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
|
||
<div v-if="codexBriefFocusContracts.length || codexBriefSummaryContracts.length" class="preview-grid" style="margin-bottom: 12px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">焦点协议</div>
|
||
<div class="preview-chip-list">
|
||
<el-button
|
||
v-for="contract in codexBriefFocusContracts"
|
||
:key="`codex-focus-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
<span v-if="!codexBriefFocusContracts.length" class="preview-empty">当前焦点还没有附带协议导航。</span>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">驾驶总图</div>
|
||
<div class="preview-chip-list">
|
||
<el-button
|
||
v-for="contract in codexBriefSummaryContracts"
|
||
:key="`codex-summary-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
<span v-if="!codexBriefSummaryContracts.length" class="preview-empty">当前还没有汇总级协议导航。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="codexBriefSceneLogObservation.status !== 'standby'" class="preview-box" style="margin-bottom: 12px">
|
||
<div class="preview-box__title">现场日志观察</div>
|
||
<div class="driver-card__head" style="margin-bottom: 8px">
|
||
<strong>{{ codexBriefSceneLogObservation.title }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="codexBriefSceneLogObservation.tagType" effect="plain" size="small">
|
||
{{ codexBriefSceneLogObservation.statusLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__summary">{{ codexBriefSceneLogObservation.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span v-if="codexBriefSceneLogObservation.targetNodeCode">目标节点:{{ codexBriefSceneLogObservation.targetNodeCode }}</span>
|
||
<span>覆盖 {{ codexBriefSceneLogObservation.coveredCount }} / {{ codexBriefSceneLogObservation.participatingCount }}</span>
|
||
<span>样本 {{ codexBriefSceneLogObservation.lineCount }}</span>
|
||
</div>
|
||
<div v-if="codexBriefSceneLogContracts.length" class="preview-chip-list" style="margin-top: 10px">
|
||
<el-button
|
||
v-for="contract in codexBriefSceneLogContracts"
|
||
:key="`codex-scene-log-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
<div class="driver-card__actions" style="margin-top: 10px">
|
||
<el-button
|
||
size="small"
|
||
:type="codexBriefSceneLogObservation.tagType === 'danger' ? 'danger' : 'primary'"
|
||
:plain="codexBriefSceneLogObservation.tagType !== 'danger'"
|
||
:disabled="!codexBriefSceneLogObservation.focusRef.kind"
|
||
@click="applyDriverFocusRef(codexBriefSceneLogObservation.focusRef)"
|
||
>
|
||
查看现场日志
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="codexBriefCards.length" class="driver-grid">
|
||
<div v-for="card in codexBriefCards" :key="card.key" class="driver-card">
|
||
<div class="driver-card__head">
|
||
<strong>{{ card.title }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="card.recommendationTagType" effect="plain" size="small">{{ card.recommendationLabel }}</el-tag>
|
||
<el-tag :type="card.automationTagType" effect="plain" size="small">{{ card.automationLevelLabel }}</el-tag>
|
||
<el-tag :type="card.riskTagType" effect="plain" size="small">{{ card.riskLevelLabel }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__summary">{{ card.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span>{{ card.reason }}</span>
|
||
<span v-if="card.executorModeText">{{ card.executorModeText }}</span>
|
||
<span v-if="card.nodeCodesText">{{ card.nodeCodesText }}</span>
|
||
</div>
|
||
<div v-if="card.detailLines.length" class="preview-list driver-card__preview">
|
||
<div v-for="line in card.detailLines" :key="`${card.key}-${line}`" class="preview-list__item">
|
||
{{ line }}
|
||
</div>
|
||
</div>
|
||
<div v-if="contractNavigationContracts(card).length" class="preview-chip-list" style="margin-bottom: 10px">
|
||
<el-button
|
||
v-for="contract in contractNavigationContracts(card)"
|
||
:key="`codex-card-contract-${card.key}-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
<div class="driver-card__actions">
|
||
<el-button
|
||
size="small"
|
||
:type="card.primaryType || 'primary'"
|
||
:plain="card.primaryPlain !== false"
|
||
:disabled="Boolean(card.disabled)"
|
||
:loading="loading.driverActionKey === `codex-brief-${card.key}-primary`"
|
||
@click="runCodexBriefEntry(card)"
|
||
>
|
||
{{ card.primaryLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.previewable"
|
||
size="small"
|
||
plain
|
||
@click="openCodexActionContractPreview(card)"
|
||
>
|
||
看契约
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.secondaryLabel"
|
||
size="small"
|
||
plain
|
||
:disabled="Boolean(card.disabledSecondary)"
|
||
:loading="loading.driverActionKey === `codex-brief-${card.key}-secondary`"
|
||
@click="runCodexBriefEntry(card, { secondary: true })"
|
||
>
|
||
{{ card.secondaryLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="preview-empty">当前还没有自动驾驶判断结果。</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>运维驾驶建议</h4>
|
||
<p>根据当前接管态、参与态和发布态,给出下一步最值得执行的动作。这里的动作会尽量落回标准模板或标准任务序列。</p>
|
||
</div>
|
||
<div class="panel-header-note">建议 {{ driverRecommendationCards.length }} 条</div>
|
||
</div>
|
||
|
||
<div class="driver-grid">
|
||
<div v-for="card in driverRecommendationCards" :key="card.key" class="driver-card">
|
||
<div class="driver-card__head">
|
||
<strong>{{ card.title }}</strong>
|
||
<el-tag :type="card.tagType" effect="plain" size="small">{{ card.levelLabel }}</el-tag>
|
||
</div>
|
||
<div class="driver-card__summary">{{ card.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span>{{ card.reason }}</span>
|
||
<span v-if="card.nodeCodesText">{{ card.nodeCodesText }}</span>
|
||
</div>
|
||
<div class="driver-card__actions">
|
||
<el-button
|
||
size="small"
|
||
:type="card.primaryType || 'primary'"
|
||
:plain="card.primaryPlain !== false"
|
||
:disabled="Boolean(card.disabled)"
|
||
:loading="loading.driverActionKey === card.key"
|
||
@click="runDriverRecommendation(card)"
|
||
>
|
||
{{ card.primaryLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.previewable"
|
||
size="small"
|
||
plain
|
||
@click="openDriverActionContractPreview(card, { sourceLabel: 'driver-recommendation' })"
|
||
>
|
||
看契约
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.secondaryLabel"
|
||
size="small"
|
||
plain
|
||
:disabled="Boolean(card.disabled)"
|
||
@click="runDriverRecommendation(card, { secondary: true })"
|
||
>
|
||
{{ card.secondaryLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<el-drawer v-model="driverActionContractVisible" title="驾驶动作契约预览" size="760px">
|
||
<template v-if="activeDriverActionContractPreview">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>{{ activeDriverActionContractPreview.title || "驾驶动作" }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">{{ contractVersionLabel(opsDriverContract) }}</el-tag>
|
||
<el-tag size="small" effect="plain">{{ activeDriverActionContractPreview.source_label || "ops-center" }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ activeDriverActionContractPreview.summary || "当前还没有动作摘要。" }}</div>
|
||
<div v-if="activeDriverActionContractPreview.reason" class="preview-list__item">
|
||
{{ activeDriverActionContractPreview.reason }}
|
||
</div>
|
||
<div v-if="activeDriverActionContractPreview.meta_text" class="preview-list__item">
|
||
{{ activeDriverActionContractPreview.meta_text }}
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small" style="margin-top: 12px">
|
||
<el-descriptions-item label="Contract">
|
||
{{ String(opsDriverContract.key || "ops_driver_contract") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主入口">
|
||
{{ contractPrimaryEndpoint(opsDriverContract, "/api/v1/ops/driver-actions/execute") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="文档">
|
||
{{ contractSchemaDocPath(opsDriverContract, "docs/schemas/ops_driver_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="发现入口">
|
||
{{ contractDiscoveryPreviewText(opsDriverContract) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行器">
|
||
{{ activeDriverActionContractPreview.executor_kind_label || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行链路">
|
||
{{ activeDriverActionContractPreview.execution_chain || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div v-if="activeDriverActionContractPreview.gate" class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>执行门禁</strong>
|
||
<div class="table-actions">
|
||
<el-tag v-if="activeDriverActionContractPreview.gate?.recommendation_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.gate?.recommendation_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.gate?.decision_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.gate?.decision_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.gate?.risk_level" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.gate?.risk_level }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="当前分支">
|
||
{{ activeDriverActionContractPreview.selected_section === "secondary" ? "次动作" : "主动作" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="是否可执行">
|
||
{{ activeDriverActionContractPreview.gate?.will_execute ? "是" : "否" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="门禁结论">
|
||
{{ activeDriverActionContractPreview.gate?.decision_label || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="确认要求">
|
||
{{ activeDriverActionContractPreview.gate?.confirm_required ? "需要确认" : "无需确认" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标 API" :span="2">
|
||
{{ activeDriverActionContractPreview.gate?.target_method || "POST" }} {{ activeDriverActionContractPreview.gate?.target_api || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="门禁说明" :span="2">
|
||
{{ activeDriverActionContractPreview.gate?.decision_reason || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>主动作</strong>
|
||
<div class="table-actions">
|
||
<el-tag v-if="activeDriverActionContractPreview.primary?.recommendation_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.primary?.recommendation_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.primary?.automation_level_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.primary?.automation_level_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.primary?.risk_level_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.primary?.risk_level_label }}
|
||
</el-tag>
|
||
<el-button size="small" text @click="copyDriverActionContractRequest('primary')">复制请求</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="动作标签">
|
||
{{ activeDriverActionContractPreview.primary?.label || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="动作编码">
|
||
{{ activeDriverActionContractPreview.primary?.action_code || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标 API">
|
||
{{ activeDriverActionContractPreview.primary?.target_method || "POST" }} {{ activeDriverActionContractPreview.primary?.target_api || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行建议">
|
||
{{ activeDriverActionContractPreview.primary?.reason || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标节点" :span="2">
|
||
{{ activeDriverActionContractPreview.primary?.node_codes?.join(" / ") || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="code-block">
|
||
<div class="code-title">Request Preview</div>
|
||
<pre>{{ formatJsonBlock(activeDriverActionContractPreview.primary?.request_payload || {}) }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="activeDriverActionContractPreview.secondary?.label || activeDriverActionContractPreview.secondary?.action_code"
|
||
class="result-panel"
|
||
style="margin-top: 16px"
|
||
>
|
||
<div class="result-panel__head">
|
||
<strong>次动作</strong>
|
||
<div class="table-actions">
|
||
<el-tag v-if="activeDriverActionContractPreview.secondary?.recommendation_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.secondary?.recommendation_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.secondary?.automation_level_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.secondary?.automation_level_label }}
|
||
</el-tag>
|
||
<el-tag v-if="activeDriverActionContractPreview.secondary?.risk_level_label" size="small" effect="plain">
|
||
{{ activeDriverActionContractPreview.secondary?.risk_level_label }}
|
||
</el-tag>
|
||
<el-button size="small" text @click="copyDriverActionContractRequest('secondary')">复制请求</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="动作标签">
|
||
{{ activeDriverActionContractPreview.secondary?.label || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="动作编码">
|
||
{{ activeDriverActionContractPreview.secondary?.action_code || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标 API">
|
||
{{ activeDriverActionContractPreview.secondary?.target_method || "POST" }} {{ activeDriverActionContractPreview.secondary?.target_api || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行建议">
|
||
{{ activeDriverActionContractPreview.secondary?.reason || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标节点" :span="2">
|
||
{{ activeDriverActionContractPreview.secondary?.node_codes?.join(" / ") || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="code-block">
|
||
<div class="code-title">Request Preview</div>
|
||
<pre>{{ formatJsonBlock(activeDriverActionContractPreview.secondary?.request_payload || {}) }}</pre>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="preview-empty">当前还没有可展示的驾驶动作契约。</div>
|
||
</el-drawer>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>统一活动流</h4>
|
||
<p>把编排回执、独立运维任务、Rollout 和当前 runbook 路径快照放到同一条时间线里,海外控制面可以直接从“最近变化”或“当前下一步”切进正确入口。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
{{ activityStreamSummary.filtered_total || activityStreamItems.length }} 条{{ Number(activityStreamSummary.unfiltered_total || 0) > Number(activityStreamSummary.filtered_total || activityStreamItems.length) ? ` / 总 ${activityStreamSummary.unfiltered_total}` : "" }}{{ activityStreamSummary.latest_at ? ` / 最近 ${activityStreamSummary.latest_at}` : "" }}{{ activityStreamSummary.status_label ? ` / ${activityStreamSummary.status_label}` : "" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="inspection-summary-grid" style="margin-bottom: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">编排回执</span>
|
||
<strong>{{ activityStreamKindCounts.playbook_run || 0 }}</strong>
|
||
<span class="summary-note">最近的 playbook 收口与异常</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">独立任务</span>
|
||
<strong>{{ activityStreamKindCounts.ops_job || 0 }}</strong>
|
||
<span class="summary-note">脱离编排与 rollout 的标准运维动作</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">Rollout</span>
|
||
<strong>{{ activityStreamKindCounts.rollout || 0 }}</strong>
|
||
<span class="summary-note">发布推进、批次推进与结果回执</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">Runbook 路径</span>
|
||
<strong>{{ activityStreamRunbookCount }}</strong>
|
||
<span class="summary-note">当前固定作业路径解析出的下一步动作</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">需关注</span>
|
||
<strong>{{ activityStreamAttentionCount }}</strong>
|
||
<span class="summary-note">attention / running / failed 类活动</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activityStreamSummaryContracts.length" class="preview-box" style="margin-bottom: 12px">
|
||
<div class="preview-box__title">活动协议</div>
|
||
<div class="preview-chip-list">
|
||
<el-button
|
||
v-for="contract in activityStreamSummaryContracts"
|
||
:key="`activity-stream-contract-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-actions" style="margin-bottom: 12px">
|
||
<el-select v-model="activityStreamFilters.kind" clearable placeholder="全部类型" @change="applyActivityStreamFilters">
|
||
<el-option
|
||
v-for="option in activityStreamKindOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-select v-model="activityStreamFilters.status" clearable placeholder="全部状态" @change="applyActivityStreamFilters">
|
||
<el-option
|
||
v-for="option in activityStreamStatusOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-select v-model="activityStreamFilters.execution_mode" clearable placeholder="全部执行路径" @change="applyActivityStreamFilters">
|
||
<el-option
|
||
v-for="option in activityStreamExecutionModeOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-input
|
||
v-model="activityStreamFilters.query"
|
||
clearable
|
||
placeholder="搜对象 / 摘要 / 编号"
|
||
style="max-width: 240px"
|
||
@keyup.enter="applyActivityStreamFilters"
|
||
@clear="applyActivityStreamFilters"
|
||
/>
|
||
<el-button plain @click="applyActivityStreamFilters">筛选</el-button>
|
||
<el-button plain :disabled="!hasActivityStreamFilters" @click="resetActivityStreamFilters">重置</el-button>
|
||
</div>
|
||
|
||
<el-table :data="activityStreamItems" stripe size="small" max-height="320" empty-text="当前还没有新的活动回执">
|
||
<el-table-column label="类型" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" effect="plain" :type="activityKindTagType(row.kind)">
|
||
{{ activityKindLabel(row.kind) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="对象" min-width="240">
|
||
<template #default="{ row }">
|
||
<div class="activity-title">
|
||
<strong>{{ row.title || "-" }}</strong>
|
||
<span v-if="row.subtitle" class="activity-subtitle">{{ row.subtitle }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="摘要" min-width="360">
|
||
<template #default="{ row }">
|
||
<div class="activity-summary">{{ row.summary_text || row.summary || "-" }}</div>
|
||
<div v-if="row.meta_text" class="activity-meta">{{ row.meta_text }}</div>
|
||
<div v-if="contractNavigationContracts(row).length" class="preview-chip-list" style="margin-top: 8px">
|
||
<el-button
|
||
v-for="contract in contractNavigationContracts(row)"
|
||
:key="`activity-row-contract-${row.activity_key || row.kind}-${contract.key}`"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(contract.key)"
|
||
>
|
||
{{ contract.title || contract.key }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="执行路径" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="activityExecutionModeLabel(row)" size="small" effect="plain">
|
||
{{ activityExecutionModeLabel(row) }}
|
||
</el-tag>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="activityStatusType(row)">
|
||
{{ activityStatusLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="occurred_at" label="最近时间" width="180" />
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" text :disabled="!row.ui_intent" @click="openActivityItem(row)">
|
||
{{ activityActionLabel(row) }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>执行现场</h4>
|
||
<p>把真正参与检测、在线待命和远端日志回传放到一个视角里,方便海外控制面先判断“谁在跑、谁没跑、日志有没有回来”。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
执行/已领 {{ executionParticipationSummary.dispatch_active_nodes || 0 }} / 近窗 {{ executionParticipationSummary.recent_only_nodes || 0 }} / 待命 {{ executionParticipationSummary.standby_nodes || 0 }} / 待确认 {{ executionParticipationSummary.load_syncing_nodes || 0 }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ops-scene-grid">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>远端日志回传</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="opsWorkerLogSyncStatusType" size="small">
|
||
{{ opsWorkerLogSyncStatusLabel }}
|
||
</el-tag>
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsWorkerLogSyncModeLabel }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-actions" style="margin-bottom: 12px">
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:type="!opsWorkerLogSyncEnabled ? 'primary' : ''"
|
||
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'off'"
|
||
@click="updateOpsWorkerLogSync(false, 'key')"
|
||
>
|
||
关闭回传
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:type="opsWorkerLogSyncEnabled && opsWorkerLogSyncMode === 'key' ? 'primary' : ''"
|
||
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'key'"
|
||
@click="updateOpsWorkerLogSync(true, 'key')"
|
||
>
|
||
关键回传
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:type="opsWorkerLogSyncEnabled && opsWorkerLogSyncMode === 'full' ? 'primary' : ''"
|
||
:loading="workerLogSyncLoading && pendingWorkerLogMode === 'full'"
|
||
@click="updateOpsWorkerLogSync(true, 'full')"
|
||
>
|
||
全量回传
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
@click="openRemoteLogDrawer()"
|
||
>
|
||
查看样本
|
||
</el-button>
|
||
</div>
|
||
|
||
<el-alert :closable="false" show-icon type="info" :title="opsWorkerLogSyncDescription" />
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 12px"
|
||
:type="opsWorkerLogSyncStatusType"
|
||
:title="opsWorkerLogSyncActionHint"
|
||
/>
|
||
|
||
<el-descriptions :column="2" border size="small" style="margin-top: 12px">
|
||
<el-descriptions-item label="参与覆盖">
|
||
{{ executionScene.log_sync?.covered_participating_node_count || 0 }} / {{ executionScene.log_sync?.participating_node_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="未回传参与节点">
|
||
{{ executionScene.log_sync?.missing_participating_node_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="样本数">
|
||
{{ executionScene.log_sync?.line_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="来源节点">
|
||
{{ executionScene.log_sync?.source_node_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近回传">
|
||
{{ executionScene.log_sync?.last_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="来源列表">
|
||
{{ (executionScene.log_sync?.source_nodes || []).join("、") || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缺口节点" :span="2">
|
||
{{ (executionScene.log_sync?.missing_participating_nodes || []).join("、") || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近一条" :span="2">
|
||
{{ executionScene.log_sync?.last_line || "当前还没有远端日志样本" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>参与态摘要</strong>
|
||
<el-tag :type="executionParticipatingNodes.length ? 'warning' : 'success'" size="small">
|
||
{{ executionParticipatingNodes.length ? "现场有执行节点" : "当前现场空闲" }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-alert :closable="false" show-icon type="info" :title="executionSceneSummaryText" />
|
||
<el-alert :closable="false" show-icon type="info" style="margin-top: 12px" :title="executionScenePhaseText" />
|
||
|
||
<div class="inspection-summary-grid" style="margin-top: 12px">
|
||
<div class="mini-card">
|
||
<span class="summary-label">真正参与</span>
|
||
<strong>{{ executionParticipationSummary.participating_nodes || 0 }}</strong>
|
||
<span class="summary-note">正在执行、已领待跑或近窗刚有吞吐</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">执行中 / 已领</span>
|
||
<strong>{{ executionParticipationSummary.dispatch_active_nodes || 0 }}</strong>
|
||
<span class="summary-note">最值得优先观察现场输出的节点</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">在线未参与</span>
|
||
<strong>{{ executionParticipationSummary.non_participating_nodes || 0 }}</strong>
|
||
<span class="summary-note">在线但当前没有领任务或近窗吞吐</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">负载待确认</span>
|
||
<strong>{{ executionParticipationSummary.load_syncing_nodes || 0 }}</strong>
|
||
<span class="summary-note">有负载但还没观察到明确领任务记录</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 12px">
|
||
<div class="result-panel__head">
|
||
<strong>执行节点总览</strong>
|
||
<el-tag :type="executionFilteredNodes.length ? 'warning' : 'info'" size="small">
|
||
{{ executionFilteredNodes.length }} / {{ executionAllNodes.length }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<div class="table-actions" style="margin-bottom: 12px">
|
||
<el-select v-model="executionSceneFilters.state_group" clearable placeholder="全部参与状态">
|
||
<el-option
|
||
v-for="option in executionSceneStateOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-select v-model="executionSceneFilters.role" clearable placeholder="全部角色">
|
||
<el-option
|
||
v-for="option in executionSceneRoleOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-button plain :disabled="!hasExecutionSceneFilters" @click="resetExecutionSceneFilters">重置</el-button>
|
||
</div>
|
||
|
||
<el-alert :closable="false" show-icon type="info" style="margin-bottom: 12px" :title="executionFilteredSummaryText" />
|
||
|
||
<el-table :data="executionFilteredNodes" stripe size="small" max-height="320" empty-text="当前筛选条件下没有匹配的执行节点">
|
||
<el-table-column label="节点" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="现场分组" width="130">
|
||
<template #default="{ row }">
|
||
<el-tag :type="executionStateGroupTagType(row)" effect="plain" size="small">
|
||
{{ executionStateGroupLabelForRow(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="executionNodeStateTagType(row)" effect="plain" size="small">
|
||
{{ row.participation_label || "-" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="负载 / 执行" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ executionNodeWorkloadText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="近窗吞吐" min-width="140">
|
||
<template #default="{ row }">
|
||
{{ executionNodeRecentText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前说明" min-width="220">
|
||
<template #default="{ row }">
|
||
{{ executionNodeReasonText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近心跳" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.last_heartbeat_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.fleetActionKey === `row:${row.node_code}`"
|
||
@click="rerunInspectionForRow(row)"
|
||
>
|
||
标准巡检
|
||
</el-button>
|
||
<el-button size="small" text @click="openRemoteLogDrawer(row.node_code)">现场日志</el-button>
|
||
<el-button size="small" text @click="openWorkerLogTemplateForNode(row.node_code)">Worker 日志</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="ops-scene-grid">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>当前参与检测节点</strong>
|
||
<el-tag :type="executionParticipatingNodes.length ? 'warning' : 'info'" size="small">
|
||
{{ executionParticipatingNodes.length ? `${executionParticipatingNodes.length} 台` : "当前无" }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-alert :closable="false" show-icon type="info" style="margin-bottom: 12px" :title="executionParticipatingSummaryText" />
|
||
|
||
<el-table :data="executionParticipatingNodes" stripe size="small" max-height="260" empty-text="当前没有真正参与检测的节点">
|
||
<el-table-column label="节点" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="executionNodeStateTagType(row)" effect="plain" size="small">
|
||
{{ row.participation_label || "-" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="负载 / 执行" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ executionNodeWorkloadText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="近窗吞吐" min-width="140">
|
||
<template #default="{ row }">
|
||
{{ executionNodeRecentText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前说明" min-width="220">
|
||
<template #default="{ row }">
|
||
{{ executionNodeReasonText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近心跳" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.last_heartbeat_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.fleetActionKey === `row:${row.node_code}`"
|
||
@click="rerunInspectionForRow(row)"
|
||
>
|
||
标准巡检
|
||
</el-button>
|
||
<el-button size="small" text @click="openRemoteLogDrawer(row.node_code)">现场日志</el-button>
|
||
<el-button size="small" text @click="openWorkerLogTemplateForNode(row.node_code)">Worker 日志</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>在线未参与节点</strong>
|
||
<el-tag :type="executionStandbyNodes.length ? 'info' : 'success'" size="small">
|
||
{{ executionStandbyNodes.length ? `${executionStandbyNodes.length} 台` : "当前无" }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-alert :closable="false" show-icon type="info" style="margin-bottom: 12px" :title="executionStandbySummaryText" />
|
||
|
||
<el-table :data="executionStandbyNodes" stripe size="small" max-height="260" empty-text="当前没有在线但未参与的节点">
|
||
<el-table-column label="节点" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="executionNodeStateTagType(row)" effect="plain" size="small">
|
||
{{ row.participation_label || "-" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前说明" min-width="220">
|
||
<template #default="{ row }">
|
||
{{ executionNodeReasonText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近心跳" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.last_heartbeat_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.fleetActionKey === `row:${row.node_code}`"
|
||
@click="rerunInspectionForRow(row)"
|
||
>
|
||
标准巡检
|
||
</el-button>
|
||
<el-button size="small" text @click="openRemoteLogDrawer(row.node_code)">现场日志</el-button>
|
||
<el-button size="small" text @click="openWorkerLogTemplateForNode(row.node_code)">Worker 日志</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>标准运维编排</h4>
|
||
<p>把多步动作收口成后端 playbook。适合让页面按钮、Codex 驾驶员和后续自动化都走同一条运维编排链。</p>
|
||
</div>
|
||
<div class="panel-header-note">编排 {{ playbooks.length }} 个</div>
|
||
</div>
|
||
|
||
<div class="template-grid">
|
||
<div v-for="playbook in playbooks" :key="playbook.key" class="template-card">
|
||
<div class="template-card__head">
|
||
<strong>{{ playbook.title || "-" }}</strong>
|
||
<el-tag type="success" effect="plain" size="small">
|
||
{{ (playbook.steps || []).length }} 步
|
||
</el-tag>
|
||
</div>
|
||
<div class="template-card__desc">{{ playbook.description || "-" }}</div>
|
||
<div class="template-card__meta">
|
||
<span>{{ playbook.group_title || "-" }}</span>
|
||
<span>目标 {{ playbook.target_scope || "node-set" }}</span>
|
||
<span>{{ playbook.stop_on_failure ? "失败即停" : "允许继续" }}</span>
|
||
<span>{{ executionModeSummaryText(playbook) }}</span>
|
||
</div>
|
||
<div class="template-card__actions">
|
||
<el-button size="small" type="primary" plain @click="openPlaybookDialog(playbook)">预览 / 执行</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>最近编排回执</h4>
|
||
<p>这里按一次 playbook run 聚合子任务,不再要求操作者自己从原始 ops jobs 里脑补“这一轮标准巡检到底做到了哪一步”。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
筛选后 {{ playbookRunsSummary.filtered_total || playbookRuns.length }} / 总 {{ playbookRunsSummary.unfiltered_total || playbookRuns.length }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="inspection-summary-grid">
|
||
<div class="mini-card">
|
||
<span class="summary-label">总回执</span>
|
||
<strong>{{ playbookRunsSummary.total || 0 }}</strong>
|
||
<span class="summary-note">仅统计已进入 playbook contract 的新编排记录</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">成功</span>
|
||
<strong>{{ playbookRunsSummary.success || 0 }}</strong>
|
||
<span class="summary-note">整轮编排的子任务都已成功回执</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">收口中</span>
|
||
<strong>{{ playbookRunsSummary.running || 0 }}</strong>
|
||
<span class="summary-note">仍有步骤处于排队、执行或待审批</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">待关注</span>
|
||
<strong>{{ playbookRunsSummary.attention || 0 }}</strong>
|
||
<span class="summary-note">存在失败、阻断或取消,建议优先复盘</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="playbook-run-filter-bar">
|
||
<div class="playbook-run-filter-item">
|
||
<div class="summary-label">状态</div>
|
||
<el-select v-model="playbookRunFilters.status" clearable placeholder="全部状态" @change="applyPlaybookRunFilters">
|
||
<el-option
|
||
v-for="option in playbookRunStatusOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
<div class="playbook-run-filter-item">
|
||
<div class="summary-label">分组</div>
|
||
<el-select v-model="playbookRunFilters.group_key" clearable placeholder="全部分组" @change="applyPlaybookRunFilters">
|
||
<el-option
|
||
v-for="option in playbookRunGroupOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
<div class="playbook-run-filter-item playbook-run-filter-item--query">
|
||
<div class="summary-label">关键词</div>
|
||
<el-input
|
||
v-model="playbookRunFilters.query"
|
||
clearable
|
||
placeholder="run_code / playbook / requested_by / 节点"
|
||
@keyup.enter="applyPlaybookRunFilters"
|
||
@clear="applyPlaybookRunFilters"
|
||
/>
|
||
</div>
|
||
<div class="toolbar">
|
||
<el-button size="small" plain @click="applyPlaybookRunFilters">筛选</el-button>
|
||
<el-button size="small" text :disabled="!hasPlaybookRunFilters" @click="resetPlaybookRunFilters">清空</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table :data="playbookRuns" stripe size="small" max-height="340" empty-text="当前还没有新的编排回执">
|
||
<el-table-column type="expand" width="40">
|
||
<template #default="{ row }">
|
||
<el-table :data="row.steps || []" stripe size="small" max-height="280" empty-text="当前编排还没有步骤回执">
|
||
<el-table-column label="步骤" min-width="180">
|
||
<template #default="{ row: step }">
|
||
<div class="node-cell">
|
||
<strong>{{ step.title || step.step_key || "-" }}</strong>
|
||
<span>{{ step.template_key || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row: step }">
|
||
<el-tag :type="playbookRunStatusType(step.status)" size="small">
|
||
{{ step.status_label || playbookRunStatusLabel(step.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="节点 / 任务" min-width="130">
|
||
<template #default="{ row: step }">
|
||
{{ playbookRunStepProgressText(step) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="任务计数" min-width="180">
|
||
<template #default="{ row: step }">
|
||
{{ playbookRunStatusCountsText(step.status_counts) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近任务" min-width="180">
|
||
<template #default="{ row: step }">
|
||
{{ playbookRunLatestJobText(step.latest_job) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="110" fixed="right">
|
||
<template #default="{ row: step }">
|
||
<el-button size="small" text :disabled="!step.latest_job?.id" @click="openPlaybookRunStepEvents(step)">事件</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="编排" min-width="210">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.playbook_title || row.playbook_key || "-" }}</strong>
|
||
<span>{{ row.run_code || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="playbookRunStatusType(row.status)" size="small">
|
||
{{ row.status_label || playbookRunStatusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="执行方式" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag effect="plain" size="small">
|
||
{{ playbookRunExecutionModeLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="目标节点" min-width="220">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.target_nodes_total || 0 }} 台</strong>
|
||
<span>{{ (row.target_node_codes || []).join("、") || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="步骤 / 任务" min-width="160">
|
||
<template #default="{ row }">
|
||
{{ playbookRunProgressText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前焦点" min-width="250">
|
||
<template #default="{ row }">
|
||
<div class="playbook-run-focus">
|
||
<div class="toolbar" style="justify-content: flex-start">
|
||
<el-tag :type="playbookRunFocusTagType(row.focus_level)" size="small">
|
||
{{ row.focus_step_title || "整轮状态" }}
|
||
</el-tag>
|
||
<span class="watch-hint">{{ row.status_label || playbookRunStatusLabel(row.status) }}</span>
|
||
</div>
|
||
<span class="playbook-run-focus__summary">{{ row.summary_text || row.focus_summary || "当前还没有生成焦点摘要。" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建来源" min-width="170">
|
||
<template #default="{ row }">
|
||
{{ row.requested_by || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近更新" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.updated_at || row.created_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button size="small" text @click="openPlaybookRunDetail(row)">详情</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.playbookRunActionKey === `rerun:${row.run_code}`"
|
||
@click="rerunPlaybookRun(row)"
|
||
>
|
||
重跑
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="danger"
|
||
text
|
||
:disabled="!canCancelPlaybookRun(row.status)"
|
||
:loading="loading.playbookRunActionKey === `cancel:${row.run_code}`"
|
||
@click="cancelPlaybookRun(row)"
|
||
>
|
||
取消
|
||
</el-button>
|
||
<el-button size="small" text :disabled="!row.latest_job?.id" @click="openPlaybookRunLatestJobEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>标准动作模板</h4>
|
||
<p>把常用运维动作固化成模板,从海外控制面统一发起;适合做重启、快照、取日志、收诊断包。</p>
|
||
</div>
|
||
<div class="panel-header-note">模板 {{ actionTemplates.length }} 个</div>
|
||
</div>
|
||
|
||
<div class="template-grid">
|
||
<div v-for="template in actionTemplates" :key="template.key" class="template-card">
|
||
<div class="template-card__head">
|
||
<strong>{{ template.title || "-" }}</strong>
|
||
<el-tag :type="riskType(template.risk_level || 'low')" effect="plain" size="small">
|
||
{{ template.risk_level || "-" }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="template-card__desc">{{ template.description || "-" }}</div>
|
||
<div class="template-card__meta">
|
||
<span>{{ template.group_title || "-" }}</span>
|
||
<span>{{ (template.target_roles || []).join(" / ") || "-" }}</span>
|
||
<span>{{ executionModeSummaryText(template, actionTemplateDefaults.execution_mode || "remote-agent") }}</span>
|
||
</div>
|
||
<div class="template-card__actions">
|
||
<el-button size="small" type="primary" plain @click="openActionTemplateDialog(template)">发起动作</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>批量巡检捷径</h4>
|
||
<p>按当前运行态预填“收诊断包”任务,不用再手工挑节点;适合快速定位谁在跑、谁在线但没参与、谁应该先排查。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
已就绪 {{ bulkDiagnosticsEligibleNodes.length }} / 待接管 {{ bulkDiagnosticsExcludedNodes.length }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 14px"
|
||
title="这些捷径现在优先走 playbook;标准巡检不再由前端手工拼三组任务。"
|
||
>
|
||
<template #default>
|
||
只纳入 已纳管、已启用、Agent 在线,且当前被判定为有效执行节点的机器。标准巡检会直接调用后端 `inspection.standard`,原子动作仍可继续走模板。
|
||
</template>
|
||
</el-alert>
|
||
|
||
<div class="fleet-shortcut-grid">
|
||
<div v-for="card in bulkDiagnosticsCards" :key="card.key" class="fleet-shortcut-card">
|
||
<div class="fleet-shortcut-card__head">
|
||
<strong>{{ card.title }}</strong>
|
||
<el-tag :type="card.count ? 'success' : 'info'" effect="plain" size="small">
|
||
{{ card.count }} 台
|
||
</el-tag>
|
||
</div>
|
||
<div class="fleet-shortcut-card__desc">{{ card.description }}</div>
|
||
<div class="fleet-shortcut-card__meta">
|
||
<span>{{ card.summary }}</span>
|
||
<span v-if="card.excludedCount > 0">另有 {{ card.excludedCount }} 台未满足接管条件,本次不直接下发任务。</span>
|
||
</div>
|
||
<div class="fleet-shortcut-card__targets">
|
||
<span class="fleet-shortcut-card__targets-label">目标节点</span>
|
||
<code>{{ card.nodeCodesText }}</code>
|
||
</div>
|
||
<div class="fleet-shortcut-card__actions">
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!card.count"
|
||
@click="openBulkActionTemplateDialog(card.key, 'health.snapshot')"
|
||
>
|
||
健康快照
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!card.count"
|
||
@click="openBulkActionTemplateDialog(card.key, 'logs.collect', { lines: 120 })"
|
||
>
|
||
Worker 日志
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!card.count"
|
||
@click="openBulkActionTemplateDialog(card.key, 'diagnostics.collect', { lines: 200 })"
|
||
>
|
||
收诊断包
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:disabled="!card.count"
|
||
:loading="loading.fleetActionKey === `sequence:${card.key}`"
|
||
@click="runBulkInspectionSequence(card.key)"
|
||
>
|
||
标准巡检
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>巡检结果收口</h4>
|
||
<p>按节点聚合最近一轮健康快照、日志采集和诊断包任务,直接判断这一轮巡检是健康、执行中,还是还有缺口。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
节点 {{ inspectionRows.length }}{{ inspectionRowsTotal > inspectionRows.length ? ` / 总 ${inspectionRowsTotal}` : "" }} / 已载入任务 {{ inspectionJobsTotal }}
|
||
</div>
|
||
<div class="panel-header-note">
|
||
可见执行面 {{ inspectionVisibleNodesTotal }} / 可直接巡检 {{ inspectionEligibleNodesTotal }} / 待补接管 {{ inspectionHandoverGapNodesTotal }} / 参与检测 {{ inspectionParticipatingNodesTotal }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="inspection-summary-grid">
|
||
<div class="mini-card">
|
||
<span class="summary-label">收口健康</span>
|
||
<strong>{{ inspectionSummary.healthy }}</strong>
|
||
<span class="summary-note">三类巡检都已成功回执的节点</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">收口中</span>
|
||
<strong>{{ inspectionSummary.running }}</strong>
|
||
<span class="summary-note">仍有任务排队、执行中或待审批</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">待关注</span>
|
||
<strong>{{ inspectionSummary.attention }}</strong>
|
||
<span class="summary-note">存在失败、阻断、取消或部分缺口</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">未巡检</span>
|
||
<strong>{{ inspectionSummary.missing }}</strong>
|
||
<span class="summary-note">当前已接管待命,但还没形成完整巡检记录的节点</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-filters">
|
||
<el-select v-model="inspectionFilters.status" clearable placeholder="全部状态" @change="applyInspectionFilters">
|
||
<el-option
|
||
v-for="option in inspectionStatusOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-select v-model="inspectionFilters.problem_kind" clearable placeholder="全部问题类型" @change="applyInspectionFilters">
|
||
<el-option
|
||
v-for="option in inspectionProblemKindOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-input
|
||
v-model="inspectionFilters.query"
|
||
clearable
|
||
placeholder="搜索节点 / 问题 / 建议动作"
|
||
@keyup.enter="applyInspectionFilters"
|
||
@clear="applyInspectionFilters"
|
||
/>
|
||
<el-checkbox v-model="inspectionFilters.only_problem" @change="applyInspectionFilters">只看异常</el-checkbox>
|
||
<el-checkbox v-model="inspectionFilters.only_participating" @change="applyInspectionFilters">只看参与检测</el-checkbox>
|
||
<el-button plain @click="applyInspectionFilters">筛选</el-button>
|
||
<el-button text :disabled="!inspectionHasFilters" @click="resetInspectionFilters">重置</el-button>
|
||
</div>
|
||
|
||
<el-table :data="inspectionRows" stripe size="small" max-height="360">
|
||
<el-table-column label="节点" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="总状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="inspectionOverallTagType(row.overall_status)" size="small">
|
||
{{ inspectionOverallLabel(row.overall_status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="问题类型" width="130">
|
||
<template #default="{ row }">
|
||
<el-tag :type="inspectionProblemTagType(row)" effect="plain" size="small">
|
||
{{ inspectionProblemLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="现场状态" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="issue-cell">
|
||
<el-tag :type="inspectionSceneTagType(row)" effect="plain" size="small">
|
||
{{ inspectionSceneLabel(row) }}
|
||
</el-tag>
|
||
<span>{{ inspectionSceneNote(row) }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="健康快照" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="inspectionJobTagType(row.job_map['health.snapshot'])" effect="plain" size="small">
|
||
{{ inspectionJobLabel(row.job_map["health.snapshot"]) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="日志采集" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="inspectionJobTagType(row.job_map['logs.collect'])" effect="plain" size="small">
|
||
{{ inspectionJobLabel(row.job_map["logs.collect"]) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="诊断包" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="inspectionJobTagType(row.job_map['diagnostics.collect'])" effect="plain" size="small">
|
||
{{ inspectionJobLabel(row.job_map["diagnostics.collect"]) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="完成度" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ row.success_count }} / 3
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近更新" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.last_updated_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="210" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="inspectionSuggestedActionVisible(row)"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.fleetActionKey === `inspection-suggested:${row.node_code}`"
|
||
@click="runInspectionSuggestedAction(row)"
|
||
>
|
||
{{ inspectionSuggestedActionLabel(row) }}
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:disabled="!inspectionRerunAvailable(row)"
|
||
:loading="loading.fleetActionKey === `row:${row.node_code}`"
|
||
@click="rerunInspectionForRow(row)"
|
||
>
|
||
重跑巡检
|
||
</el-button>
|
||
<el-button size="small" text @click="openInspectionRowEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>异常节点优先队列</strong>
|
||
<el-tag :type="inspectionPriorityQueue.length ? 'danger' : 'success'" size="small">
|
||
{{ inspectionPriorityQueue.length ? `${inspectionPriorityQueue.length} 台待处理` : "当前无异常优先项" }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-table :data="inspectionPriorityQueue" stripe size="small" max-height="280">
|
||
<el-table-column label="优先级" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.priority_type" size="small">{{ row.priority_label }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="节点" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="当前问题" min-width="240">
|
||
<template #default="{ row }">
|
||
<div class="issue-cell">
|
||
<el-tag :type="inspectionProblemTagType(row)" effect="plain" size="small">
|
||
{{ inspectionProblemLabel(row) }}
|
||
</el-tag>
|
||
<span>{{ row.issue_summary }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="建议动作" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.recommended_action }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近更新" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.last_updated_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="240" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="inspectionSuggestedActionVisible(row)"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.fleetActionKey === `inspection-suggested:${row.node_code}`"
|
||
@click="runInspectionSuggestedAction(row)"
|
||
>
|
||
{{ inspectionSuggestedActionLabel(row) }}
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:disabled="!inspectionRerunAvailable(row)"
|
||
:loading="loading.fleetActionKey === `row:${row.node_code}`"
|
||
@click="rerunInspectionForRow(row)"
|
||
>
|
||
重跑巡检
|
||
</el-button>
|
||
<el-button size="small" text @click="openInspectionRowDiagnostics(row)">诊断模板</el-button>
|
||
<el-button size="small" text @click="openInspectionRowEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="panel-grid">
|
||
<section id="ops-managed-nodes-panel" class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>托管节点</h4>
|
||
<p>这里展示控制面已纳管节点,后续所有安装、更新、诊断、日志采集都应从这里发起。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
已纳管 {{ managedNodesSummary.managedTotal }} / 已启用 {{ managedNodesSummary.enabled }} / 参与中 {{ managedNodesSummary.participating }} / 在线待命 {{ managedNodesSummary.standby }} / 待重试 {{ managedNodesSummary.queuePendingRecords }} / 死信 {{ managedNodesSummary.queueDeadLetterRecords }}
|
||
</div>
|
||
</div>
|
||
|
||
<el-table :data="nodes" stripe size="small" height="340">
|
||
<el-table-column label="节点" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span v-if="row.title && row.title !== row.node_code">{{ row.title }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="region" label="地域" width="90" />
|
||
<el-table-column prop="role" label="角色" width="90" />
|
||
<el-table-column label="接管态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="row.agent_state_reason || '-'" placement="top">
|
||
<el-tag :type="managedNodeAgentStateType(row.agent_state)" size="small">
|
||
{{ row.agent_state_label || "未接管" }}
|
||
</el-tag>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="回执队列" min-width="150">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="managedNodeDeliveryQueueReason(row)" placement="top">
|
||
<div class="node-cell">
|
||
<el-tag :type="managedNodeDeliveryQueueType(row)" effect="plain" size="small">
|
||
{{ managedNodeDeliveryQueueLabel(row) }}
|
||
</el-tag>
|
||
<span v-if="managedNodeDeliveryQueueMeta(row)">{{ managedNodeDeliveryQueueMeta(row) }}</span>
|
||
</div>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="运行态" min-width="140">
|
||
<template #default="{ row }">
|
||
<el-tag :type="managedNodeClusterStateType(row)" effect="plain" size="small">
|
||
{{ managedNodeClusterStateLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="参与态" min-width="140">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="managedNodeParticipationReason(row)" placement="top">
|
||
<el-tag :type="managedNodeParticipationType(row)" effect="plain" size="small">
|
||
{{ managedNodeParticipationLabel(row) }}
|
||
</el-tag>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="SSH" min-width="170">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="row.ssh_access_reason || managedNodeSshEndpoint(row)" placement="top">
|
||
<div class="node-cell">
|
||
<span>{{ managedNodeSshEndpoint(row) }}</span>
|
||
<el-tag :type="managedNodeSshAccessType(row)" effect="plain" size="small">
|
||
{{ row.ssh_access_label || "SSH 未配置" }}
|
||
</el-tag>
|
||
</div>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="渠道" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" effect="plain">{{ row.deploy_channel || "stable" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="纳管" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="managedNodeManagedTagType(row)" size="small">{{ managedNodeManagedLabel(row) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近心跳" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ managedNodeHeartbeatText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近任务" min-width="190">
|
||
<template #default="{ row }">
|
||
{{ managedNodeLatestJobText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="300" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<template v-if="row.is_managed">
|
||
<el-button size="small" text @click="openManagedNodeDialog(row)">编辑</el-button>
|
||
<el-button size="small" text @click="openManagedNodeDeliveryQueueDrawer(row)">队列</el-button>
|
||
<el-button size="small" text type="primary" @click="openAgentTokenDialog(row)">签发 Token</el-button>
|
||
</template>
|
||
<template v-else>
|
||
<el-button size="small" text @click="openManagedNodeDialog(row)">纳管</el-button>
|
||
<el-button size="small" text type="primary" @click="openManagedNodeDialog(row, { generateBootstrapAfterSave: true })">
|
||
纳管接入
|
||
</el-button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section id="ops-jobs-panel" class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>运维任务</h4>
|
||
<p>审批、派发、取消都在这里做。Agent 完成任务后,状态和事件会自动回写。</p>
|
||
</div>
|
||
<div class="panel-header-note">显示 {{ jobsTableRows.length }} / 已载入 {{ jobs.length }} 条</div>
|
||
</div>
|
||
|
||
<el-table :data="jobsTableRows" stripe size="small" height="340">
|
||
<el-table-column prop="job_code" label="任务号" min-width="180" />
|
||
<el-table-column prop="action" label="动作" min-width="150" />
|
||
<el-table-column prop="target_node_code" label="目标节点" min-width="140" />
|
||
<el-table-column label="状态" width="130">
|
||
<template #default="{ row }">
|
||
<el-tag :type="jobStatusType(row.status)" size="small">{{ row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="风险" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="riskType(row.risk_level)" effect="plain" size="small">{{ row.risk_level || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="方式" width="120">
|
||
<template #default="{ row }">
|
||
{{ row.execution_mode_label || executionModeLabel(row.execution_mode) || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.created_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="260" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="row.status === 'awaiting_approval'"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.jobActionKey === `approve-${row.id}`"
|
||
@click="approveJob(row)"
|
||
>
|
||
审批
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'queued'"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.jobActionKey === `dispatch-${row.id}`"
|
||
@click="dispatchJob(row)"
|
||
>
|
||
派发
|
||
</el-button>
|
||
<el-button
|
||
v-if="canCancelJob(row.status)"
|
||
size="small"
|
||
type="danger"
|
||
text
|
||
:loading="loading.jobActionKey === `cancel-${row.id}`"
|
||
@click="cancelJob(row)"
|
||
>
|
||
取消
|
||
</el-button>
|
||
<el-button size="small" text @click="openJobDetail(row)">详情</el-button>
|
||
<el-button size="small" text @click="openJobEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
</div>
|
||
|
||
<section id="ops-release-hub-panel" class="panel panel--release">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>Release Hub</h4>
|
||
<p>这里用于看版本、灰度批次和是否已经具备继续推进下一批的条件。</p>
|
||
</div>
|
||
<div class="release-selector">
|
||
<span>当前查看</span>
|
||
<el-select v-model="selectedReleaseId" placeholder="选择 Release" style="width: 260px" @change="handleReleaseChange">
|
||
<el-option
|
||
v-for="release in releases"
|
||
:key="release.id"
|
||
:label="`${release.release_version} (${release.channel})`"
|
||
:value="release.id"
|
||
/>
|
||
</el-select>
|
||
<el-button plain @click="openReleaseDialog">创建 Release</el-button>
|
||
<el-dropdown
|
||
:disabled="!selectedReleaseId"
|
||
@command="(command) => handleReleaseDeployTemplateCommand(String(command || ''))"
|
||
>
|
||
<el-button plain :disabled="!selectedReleaseId">发节点任务</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="item in releaseDeployTemplateMenuItems"
|
||
:key="item.key"
|
||
:command="item.key"
|
||
>
|
||
{{ item.label }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button type="primary" plain :disabled="!selectedReleaseId" @click="openRolloutDialog()">创建 Rollout</el-button>
|
||
<el-dropdown
|
||
:disabled="!selectedReleaseId || Boolean(loading.releaseSmartRolloutKey)"
|
||
@command="(command) => handleReleaseSmartRolloutCommand(String(command || ''))"
|
||
>
|
||
<el-button
|
||
type="primary"
|
||
plain
|
||
:disabled="!selectedReleaseId || Boolean(loading.releaseSmartRolloutKey)"
|
||
:loading="Boolean(loading.releaseSmartRolloutKey)"
|
||
>
|
||
智能 Rollout
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="item in releaseSmartRolloutMenuItems"
|
||
:key="item.key"
|
||
:command="item.key"
|
||
>
|
||
{{ item.label }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>发布驾驶舱</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="releaseLaunchpadStatusType" size="small">{{ releaseLaunchpadStatusLabel }}</el-tag>
|
||
<el-button size="small" text :loading="loading.releaseLaunchpad" @click="loadReleaseLaunchpad({ showToast: true })">
|
||
刷新驾驶舱
|
||
</el-button>
|
||
<el-button
|
||
v-if="releaseLaunchpadActionRunnable && releaseLaunchpadActionLabel"
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:loading="Boolean(loading.releaseLaunchpadActionKey)"
|
||
@click="runReleaseLaunchpadAction"
|
||
>
|
||
{{ releaseLaunchpadActionLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="releaseLaunchpadRecommendedCommand"
|
||
size="small"
|
||
plain
|
||
@click="copyReleaseLaunchpadCommand"
|
||
>
|
||
复制命令
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-alert
|
||
:type="releaseLaunchpadStatusType"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #title>驾驶舱结论</template>
|
||
{{ releaseLaunchpadSummaryText }}
|
||
<div v-if="releaseLaunchpadRecommendedCommand" class="field-help" style="margin-top: 10px">
|
||
推荐命令:{{ releaseLaunchpadRecommendedCommand }}
|
||
</div>
|
||
</el-alert>
|
||
|
||
<el-descriptions :column="3" border size="small" style="margin-top: 12px">
|
||
<el-descriptions-item label="最新发布包">
|
||
{{ releaseLaunchpadLatestPackage.package_name || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最新 Release">
|
||
{{ releaseLaunchpadLatestRelease.release_version || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推荐动作">
|
||
{{ releaseLaunchpadActionLabel || releaseLaunchpadRecommendedActionCode || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推荐执行路径">
|
||
{{ releaseLaunchpadExecutionModeLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="包自检">
|
||
<el-tag :type="releaseLaunchpadLatestPackage.smoke_test_ok ? 'success' : 'warning'" size="small">
|
||
{{ releaseLaunchpadLatestPackage.smoke_test_ok ? "通过" : "未执行或未通过" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最终签收">
|
||
<el-tag
|
||
:type="
|
||
releaseLaunchpadLatestPackage.final_release_ok
|
||
? 'success'
|
||
: releaseLaunchpadLatestPackage.final_release_report_available
|
||
? 'warning'
|
||
: 'info'
|
||
"
|
||
size="small"
|
||
>
|
||
{{
|
||
releaseLaunchpadLatestPackage.final_release_ok
|
||
? '通过'
|
||
: releaseLaunchpadLatestPackage.final_release_report_available
|
||
? '未通过'
|
||
: '未生成'
|
||
}}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Worker 预发">
|
||
<el-tag :type="releaseLaunchpadRolloutStatusType(releaseLaunchpadWorkerPreview)" size="small">
|
||
{{ releaseLaunchpadRolloutStatusLabel(releaseLaunchpadWorkerPreview) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Control 预发">
|
||
<el-tag :type="releaseLaunchpadRolloutStatusType(releaseLaunchpadControlPreview)" size="small">
|
||
{{ releaseLaunchpadRolloutStatusLabel(releaseLaunchpadControlPreview) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="签收门禁" :span="3">
|
||
{{
|
||
releaseLaunchpadLatestPackage.final_release_gate_decision
|
||
? `${releaseLaunchpadLatestPackage.final_release_gate_decision}${releaseLaunchpadLatestPackage.final_release_report_matches_latest === false && releaseLaunchpadLatestPackage.final_release_report_stale_reason ? ` / ${releaseLaunchpadLatestPackage.final_release_report_stale_reason}` : ''}`
|
||
: (releaseLaunchpadLatestPackage.final_release_report_stale_reason || "-")
|
||
}}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-grid preview-grid--triple" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">最新包预览</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">包名:{{ releaseLaunchpadPackagePreview.package?.package_name || releaseLaunchpadLatestPackage.package_name || "-" }}</div>
|
||
<div class="preview-list__item">预览版本:{{ releaseLaunchpadPackagePreview.release?.release_version || "-" }}</div>
|
||
<div class="preview-list__item">{{ releaseLaunchpadPackagePreview.message || "当前没有发布包预览摘要。" }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">{{ releaseLaunchpadRolloutLabel(releaseLaunchpadWorkerPreview) }}</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ releaseLaunchpadRolloutSummary(releaseLaunchpadWorkerPreview) }}</div>
|
||
<div class="preview-list__item">
|
||
执行路径:{{ releaseLaunchpadWorkerPreview.smart_rollout?.execution_mode_label || "-" }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
目标节点:{{ (releaseLaunchpadWorkerPreview.smart_rollout?.target_nodes || []).length || 0 }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
主区域:{{ releaseLaunchpadWorkerPreview.smart_rollout?.dominant_region || "-" }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">{{ releaseLaunchpadRolloutLabel(releaseLaunchpadControlPreview) }}</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ releaseLaunchpadRolloutSummary(releaseLaunchpadControlPreview) }}</div>
|
||
<div class="preview-list__item">
|
||
执行路径:{{ releaseLaunchpadControlPreview.smart_rollout?.execution_mode_label || "-" }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
目标节点:{{ (releaseLaunchpadControlPreview.smart_rollout?.target_nodes || []).length || 0 }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
主区域:{{ releaseLaunchpadControlPreview.smart_rollout?.dominant_region || "-" }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="release-top-grid">
|
||
<div class="mini-card">
|
||
<span class="summary-label">最新激活版本</span>
|
||
<strong>{{ latestRelease.release_version || "-" }}</strong>
|
||
<span class="summary-note">{{ latestRelease.channel || "-" }} / {{ latestRelease.status_label || latestRelease.status || "-" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">已选版本</span>
|
||
<strong>{{ selectedRelease.release_version || "-" }}</strong>
|
||
<span class="summary-note">{{ selectedRelease.summary_text || selectedRelease.commit_sha || "-" }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">最近 Rollout</span>
|
||
<strong>{{ primaryRollout.rollout_code || "-" }}</strong>
|
||
<span class="summary-note">{{ primaryRollout.status_label || primaryRollout.status || "-" }} / jobs {{ primaryRollout.jobs_created || 0 }}</span>
|
||
</div>
|
||
<div class="mini-card">
|
||
<span class="summary-label">默认 Rollout 门禁</span>
|
||
<strong>{{ releaseGateStatusLabel }}</strong>
|
||
<span class="summary-note">{{ releaseGateSummaryText }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>默认 Rollout 门禁细节</strong>
|
||
<el-tag :type="releaseGateStatusType" size="small">{{ releaseGateStatusLabel }}</el-tag>
|
||
</div>
|
||
|
||
<el-descriptions :column="3" border size="small">
|
||
<el-descriptions-item label="默认 Release">
|
||
{{ releaseGateRelease.release_version || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="通道 / 状态">
|
||
{{ releaseGateRelease.channel || "-" }} / {{ releaseGateRelease.status_label || releaseGateRelease.status || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="默认目标节点">
|
||
{{ releaseGateTargetNodeCodes.length || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推荐执行路径">
|
||
{{ releaseGateExecutionModeLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="`${releaseGateExecutionModeLabel} 就绪`">
|
||
{{ releaseGateReadinessSummary.execution_ready_nodes || 0 }} / {{ releaseGateReadinessSummary.nodes_total || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="巡检通过">
|
||
{{ releaseGateReadinessSummary.inspection_healthy_nodes || 0 }} / {{ releaseGateReadinessSummary.nodes_total || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="巡检待关注 / 缺失">
|
||
{{ releaseGateReadinessSummary.inspection_attention_nodes || 0 }} / {{ releaseGateReadinessSummary.inspection_missing_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<el-alert
|
||
style="margin-top: 12px"
|
||
:type="releaseGateStatusType"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template #title>默认门禁结论</template>
|
||
{{ releaseGateSummaryText }}
|
||
</el-alert>
|
||
|
||
<div class="preview-grid preview-grid--stack" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">阻断项</div>
|
||
<div class="preview-list preview-list--danger">
|
||
<div v-for="item in releaseGateBlockingReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!releaseGateBlockingReasons.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">注意事项</div>
|
||
<div class="preview-list preview-list--warning">
|
||
<div v-for="item in releaseGateWarningReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!releaseGateWarningReasons.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">建议动作</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in releaseGateRecommendations" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!releaseGateRecommendations.length" class="preview-empty">当前无额外建议</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box" style="margin-top: 16px">
|
||
<div class="preview-box__title">默认目标节点收口状态</div>
|
||
<el-table :data="releaseGateReadinessRows" stripe size="small" max-height="260" empty-text="当前没有默认门禁节点数据">
|
||
<el-table-column label="节点" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="接管阶段" width="160">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutReadinessRecoveryTagType(row)" effect="plain" size="small">
|
||
{{ rolloutReadinessRecoveryLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="`${releaseGateExecutionModeLabel} 入口`" width="140">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutPreviewExecutionTagType(row)" effect="plain" size="small">
|
||
{{ rolloutPreviewExecutionLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="说明" min-width="360">
|
||
<template #default="{ row }">
|
||
{{ rolloutReadinessRecoverySummary(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-grid release-grid">
|
||
<section class="subpanel">
|
||
<div class="subpanel-header">
|
||
<h5>Release 列表</h5>
|
||
<span>最新 {{ releases.length }} 个</span>
|
||
</div>
|
||
<el-table :data="releases" stripe size="small" height="280" @row-click="handleReleaseRowClick">
|
||
<el-table-column prop="release_version" label="版本" min-width="180" />
|
||
<el-table-column prop="channel" label="渠道" width="90" />
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="releaseStatusType(row.status)" size="small">{{ row.status_label || row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="commit_sha" label="Commit" min-width="150" />
|
||
<el-table-column label="操作" width="240" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="row.status !== 'active'"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.releaseActionKey === `activate-${row.id}`"
|
||
@click.stop="activateRelease(row)"
|
||
>
|
||
激活
|
||
</el-button>
|
||
<el-dropdown
|
||
@command="(command) => handleReleaseDeployTemplateCommand(String(command || ''), row)"
|
||
>
|
||
<el-button size="small" text @click.stop>发节点任务</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="item in releaseDeployTemplateMenuItems"
|
||
:key="item.key"
|
||
:command="item.key"
|
||
>
|
||
{{ item.label }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-dropdown
|
||
:disabled="Boolean(loading.releaseSmartRolloutKey)"
|
||
@command="(command) => handleReleaseSmartRolloutCommand(String(command || ''), row)"
|
||
>
|
||
<el-button size="small" text :loading="loading.releaseSmartRolloutKey === `release-${row.id}`" @click.stop>
|
||
智能 Rollout
|
||
</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="item in releaseSmartRolloutMenuItems"
|
||
:key="item.key"
|
||
:command="item.key"
|
||
>
|
||
{{ item.label }}
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button size="small" text @click.stop="openRolloutDialog(row)">建 Rollout</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
|
||
<section class="subpanel">
|
||
<div class="subpanel-header">
|
||
<h5>Rollout 批次</h5>
|
||
<span>{{ selectedRelease.release_version || "未选择 Release" }}</span>
|
||
</div>
|
||
<el-table :data="rollouts" stripe size="small" height="280">
|
||
<el-table-column prop="rollout_code" label="Rollout" min-width="190" />
|
||
<el-table-column label="状态" width="150">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutStatusType(row.status)" size="small">{{ row.status_label || row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="批次" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ row.result_summary?.current_batch_index || 0 }} / {{ row.batches_total || 0 }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="目标" min-width="120">
|
||
<template #default="{ row }">
|
||
{{ row.result_summary?.launched_targets || 0 }} / {{ row.jobs_total || row.target_nodes?.length || 0 }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" min-width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="canAdvanceRollout(row)"
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.rolloutActionKey === `advance-${row.id}`"
|
||
@click="advanceRollout(row)"
|
||
>
|
||
推进下一批
|
||
</el-button>
|
||
<el-button size="small" text @click="openRolloutJobs(row)">任务</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>控制面蓝图</h4>
|
||
<p>把海外主控台的角色、入口、快检命令和动作链路收进一个视图里,后续无论是 Codex 驾驶员还是后台按钮,都以这里为统一事实来源。</p>
|
||
</div>
|
||
<div class="panel-header-note">
|
||
{{ opsControlPlaneSummary.daily_entrypoint || "overseas dashboard / ops api" }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ops-scene-grid">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>控制面入口</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsControlPlaneSummary.deployment_mode || "centralized-ops" }}
|
||
</el-tag>
|
||
<el-tag :type="opsCodexDriverReady ? 'success' : 'warning'" size="small">
|
||
{{ opsCodexDriverReady ? "Codex 可接管" : "Codex 待完善" }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="控制面地址">
|
||
{{ opsRunbookEntrypointHost || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="API 前缀">
|
||
{{ opsRunbookApiPrefix || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="页面按钮化">
|
||
{{ opsUiButtonReady ? "已具备" : "待补齐" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="推荐入口">
|
||
{{ opsControlPlaneSummary.daily_entrypoint || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="当前就绪度">
|
||
{{ opsRunbookReadinessLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="当前摘要">
|
||
{{ opsRunbookReadinessSummary }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="toolbar" style="margin-top: 12px; justify-content: flex-start">
|
||
<el-button size="small" plain @click="copyOpsEntrypointHost">复制控制面地址</el-button>
|
||
<el-button size="small" plain @click="copyOpsApiBaseUrl">复制 API Base</el-button>
|
||
<el-button size="small" plain @click="copyOpsFastChecks">复制快检命令</el-button>
|
||
</div>
|
||
|
||
<div class="preview-grid" style="margin-top: 12px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">有人值守</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ opsBlueprintOperatorWithCodex }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">无人值守</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ opsBlueprintOperatorWithoutCodex }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>运行手册</strong>
|
||
<el-tag :type="opsRunbookReadinessTagType" size="small">
|
||
{{ opsRunbookReadinessLabel }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<div class="preview-list">
|
||
<div class="preview-list__item" v-for="service in opsRunbookServices" :key="service.key">
|
||
{{ service.title }}:{{ service.service_name || "-" }} / {{ service.status_label }}
|
||
</div>
|
||
<div v-if="!opsRunbookServices.length" class="preview-empty">当前还没有运行手册服务摘要。</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 12px">
|
||
<div class="result-panel__head">
|
||
<strong>快检命令</strong>
|
||
<el-button size="small" text @click="copyOpsFastChecks">复制全部</el-button>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div v-for="command in opsRunbookFastCheckCommands" :key="command" class="preview-list__item">
|
||
<code>{{ command }}</code>
|
||
</div>
|
||
<div v-if="!opsRunbookFastCheckCommands.length" class="preview-empty">当前还没有快检命令。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-header" style="margin-top: 14px">
|
||
<div>
|
||
<h4>标准作业路径</h4>
|
||
<p>把节点接管、现场观察、标准巡检和版本发布收口成固定路径。海外控制面以后不需要再临时拼命令,而是直接从这里进入正确动作。</p>
|
||
</div>
|
||
<div class="panel-header-note">{{ opsRunbookControlSequenceCards.length }} 条</div>
|
||
</div>
|
||
|
||
<div v-if="opsRunbookControlSequenceCards.length" class="driver-grid">
|
||
<div
|
||
v-for="card in opsRunbookControlSequenceCards"
|
||
:id="`runbook-sequence-card-${card.key}`"
|
||
:key="card.key"
|
||
class="driver-card"
|
||
:class="{ 'driver-card--focused': focusedRunbookSequenceKey === card.key }"
|
||
>
|
||
<div class="driver-card__head">
|
||
<strong>{{ card.title }}</strong>
|
||
<el-tag :type="card.tagType" effect="plain" size="small">{{ card.levelLabel }}</el-tag>
|
||
</div>
|
||
<div class="driver-card__summary">{{ card.summary }}</div>
|
||
<div class="driver-card__meta">
|
||
<span>{{ card.reason }}</span>
|
||
<span v-if="card.nodeCodesText">{{ card.nodeCodesText }}</span>
|
||
<span v-if="card.stepTitlesText">{{ card.stepTitlesText }}</span>
|
||
</div>
|
||
<div v-if="card.resolutionLines.length" class="preview-list driver-card__preview">
|
||
<div
|
||
v-for="line in card.resolutionLines"
|
||
:key="`${card.key}-${line}`"
|
||
class="preview-list__item"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
</div>
|
||
<div class="driver-card__actions">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="Boolean(card.disabled)"
|
||
:loading="loading.driverActionKey === `runbook-sequence-${card.key}-primary`"
|
||
@click="runOpsRunbookControlSequence(card)"
|
||
>
|
||
{{ card.primaryLabel }}
|
||
</el-button>
|
||
<el-button
|
||
v-if="card.secondaryLabel"
|
||
size="small"
|
||
plain
|
||
:disabled="Boolean(card.disabledSecondary)"
|
||
:loading="loading.driverActionKey === `runbook-sequence-${card.key}-secondary`"
|
||
@click="runOpsRunbookControlSequence(card, { secondary: true })"
|
||
>
|
||
{{ card.secondaryLabel }}
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="preview-empty" style="margin-top: 14px">当前还没有标准作业路径。</div>
|
||
|
||
<div class="preview-grid" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">海外控制面职责</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in opsBlueprintControlResponsibilities" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!opsBlueprintControlResponsibilities.length" class="preview-empty">暂无</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">大陆受控节点职责</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in opsBlueprintManagedResponsibilities" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!opsBlueprintManagedResponsibilities.length" class="preview-empty">暂无</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">执行模型</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">默认链路:{{ opsBlueprintExecutionModel.default_transport || "-" }}</div>
|
||
<div class="preview-list__item">未来链路:{{ opsBlueprintExecutionModel.future_transport || "-" }}</div>
|
||
<div class="preview-list__item">兜底链路:{{ opsBlueprintExecutionModel.fallback_transport || "-" }}</div>
|
||
<div class="preview-list__item">
|
||
作业回执字段:{{ opsBlueprintJobResultFields.join(" / ") || "-" }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">落地路线</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">Phase 1:{{ opsBlueprintPhases.phase1.join(" / ") || "-" }}</div>
|
||
<div class="preview-list__item">Phase 2:{{ opsBlueprintPhases.phase2.join(" / ") || "-" }}</div>
|
||
<div class="preview-list__item">Phase 3:{{ opsBlueprintPhases.phase3.join(" / ") || "-" }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>动作目录</strong>
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsActionCatalog.length }} 个动作
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-table :data="opsActionCatalog" stripe size="small" max-height="320" empty-text="当前还没有动作目录">
|
||
<el-table-column prop="action" label="动作编码" min-width="180" />
|
||
<el-table-column prop="title" label="动作名称" min-width="180" />
|
||
<el-table-column prop="transport" label="执行链路" min-width="260" />
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>协议注册表</strong>
|
||
<div class="table-actions">
|
||
<el-tag size="small" effect="plain">
|
||
{{ opsContractRegistryVersion }} / {{ opsContractRegistrySchemaVersion }}
|
||
</el-tag>
|
||
<el-button size="small" text @click="copyOpsContractDiscoveryUrls">复制发现入口</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="Registry 版本">
|
||
{{ opsContractRegistryVersion }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema 版本">
|
||
{{ opsContractRegistrySchemaVersion }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Contract 总数">
|
||
{{ opsContractRegistryTotal }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="文档根目录">
|
||
{{ opsContractDocsRoot || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-grid" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">发现入口</div>
|
||
<div class="preview-list">
|
||
<div v-for="entry in opsContractDiscoveryUrls" :key="entry" class="preview-list__item">
|
||
<code>{{ entry }}</code>
|
||
</div>
|
||
<div v-if="!opsContractDiscoveryUrls.length" class="preview-empty">当前还没有协议发现入口。</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">覆盖范围</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">Agent:注册 / 心跳 / 拉任务 / 完成回执 / 事件补发</div>
|
||
<div class="preview-list__item">Release:Release / Rollout / Launchpad / Gate</div>
|
||
<div class="preview-list__item">Driver:overview / driver-feed / codex-brief / runbook / driver-actions</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="capability-list" style="margin-top: 14px">
|
||
<div v-for="item in opsContracts" :key="item.key" class="capability-item">
|
||
<div class="capability-head">
|
||
<strong>{{ item.title }}</strong>
|
||
<div class="table-actions">
|
||
<el-tag :type="item.status === 'active' ? 'success' : 'warning'" size="small">{{ item.version || "-" }}</el-tag>
|
||
<el-button size="small" text @click="openOpsContractDetail(item.key)">查看详情</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="capability-line">主入口:{{ item.primary_endpoint || "-" }}</div>
|
||
<div class="capability-line">文档:{{ item.schema_doc_path || "-" }}</div>
|
||
<div class="capability-line">消费方:{{ (item.consumers || []).join(" / ") || "-" }}</div>
|
||
<div class="capability-line">服务:{{ (item.service_keys || []).join(" / ") || "-" }}</div>
|
||
<div class="capability-line">摘要:{{ item.summary || "-" }}</div>
|
||
<div class="capability-line">关联:{{ contractRelatedKeysText(item) }}</div>
|
||
<div v-if="(item.discovery_endpoints || []).length" class="capability-line">
|
||
发现:{{ (item.discovery_endpoints || []).slice(0, 3).join(" / ") }}{{ (item.discovery_endpoints || []).length > 3 ? ` / +${(item.discovery_endpoints || []).length - 3}` : "" }}
|
||
</div>
|
||
<div v-if="(item.notes || []).length" class="preview-list" style="margin-top: 8px">
|
||
<div v-for="note in item.notes" :key="`${item.key}-${note}`" class="preview-list__item">
|
||
{{ note }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-header">
|
||
<div>
|
||
<h4>能力清单</h4>
|
||
<p>这块直接对应后续的按钮化运维范围,方便判断当前已经接到了哪一步。</p>
|
||
</div>
|
||
</div>
|
||
<div class="capability-list">
|
||
<div v-for="item in capabilities" :key="item.key" class="capability-item">
|
||
<div class="capability-head">
|
||
<strong>{{ item.title }}</strong>
|
||
<el-tag :type="capabilityType(item.current_state)" size="small">{{ item.current_state || "-" }}</el-tag>
|
||
</div>
|
||
<div class="capability-line">当前:{{ item.today || "-" }}</div>
|
||
<div class="capability-line">目标:{{ item.target || "-" }}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<el-drawer v-model="opsContractDetailVisible" title="Contract 详情" size="760px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>Contract:{{ selectedOpsContractKey || "-" }}</span>
|
||
<span>版本:{{ contractVersionLabel(selectedOpsContract, opsContractRegistrySchemaVersion) }}</span>
|
||
<span>状态:{{ selectedOpsContract.status || "-" }}</span>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>{{ selectedOpsContract.title || selectedOpsContractKey || "-" }}</strong>
|
||
<div class="toolbar">
|
||
<el-button size="small" text :loading="loading.contractDetail" @click="refreshOpsContractDetail">刷新详情</el-button>
|
||
<el-button size="small" text @click="copySelectedOpsContractDetailEndpoint">复制详情入口</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="主入口">
|
||
{{ contractPrimaryEndpoint(selectedOpsContract, "-") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Schema">
|
||
{{ contractSchemaDocPath(selectedOpsContract, "-") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="详情入口">
|
||
{{ selectedOpsContractDetailEndpoint }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="发现入口">
|
||
{{ contractDiscoveryPreviewText(selectedOpsContract) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="消费方">
|
||
{{ contractConsumersText(selectedOpsContract) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="服务">
|
||
{{ contractServiceKeysText(selectedOpsContract) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div class="preview-list__item">{{ contractSummaryText(selectedOpsContract) }}</div>
|
||
<div
|
||
v-for="note in selectedOpsContractNotes"
|
||
:key="`ops-contract-note-${selectedOpsContractKey}-${note}`"
|
||
class="preview-list__item"
|
||
>
|
||
{{ note }}
|
||
</div>
|
||
<div v-if="!selectedOpsContractNotes.length && !selectedOpsContract.summary" class="preview-empty">
|
||
当前 contract 还没有额外说明。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">关联 Contract</div>
|
||
<div class="preview-chip-list">
|
||
<el-button
|
||
v-for="item in selectedOpsContractRelatedContracts"
|
||
:key="item.key"
|
||
size="small"
|
||
plain
|
||
@click="openOpsContractDetail(item.key)"
|
||
>
|
||
{{ item.title || item.key }}
|
||
</el-button>
|
||
<span v-if="!selectedOpsContractRelatedContracts.length" class="preview-empty">当前没有登记关联 contract。</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">原始关联键</div>
|
||
<div class="preview-chip-list">
|
||
<el-tag
|
||
v-for="key in selectedOpsContractRelatedKeys"
|
||
:key="`ops-contract-key-${key}`"
|
||
size="small"
|
||
effect="plain"
|
||
>
|
||
{{ key }}
|
||
</el-tag>
|
||
<span v-if="!selectedOpsContractRelatedKeys.length" class="preview-empty">暂无</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="remoteLogDrawerVisible" :title="remoteLogDrawerTitleText" size="860px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>状态:{{ remoteLogSelectedNodeCode ? remoteLogSelectedNodeStatusLabel : opsWorkerLogSyncStatusLabel }}</span>
|
||
<span>模式:{{ remoteLogSelectedNodeCode ? (remoteLogSelectedNodeDetailData.mode_label || opsWorkerLogSyncModeLabel) : opsWorkerLogSyncModeLabel }}</span>
|
||
<span v-if="remoteLogSelectedNodeCode">
|
||
节点:{{ remoteLogSelectedNodeCode }} / {{ remoteLogSelectedNodeMeta.region || "-" }} / {{ remoteLogSelectedNodeMeta.role || "-" }}
|
||
</span>
|
||
<span v-else>
|
||
参与覆盖:{{ executionScene.log_sync?.covered_participating_node_count || 0 }} / {{ executionScene.log_sync?.participating_node_count || 0 }}
|
||
</span>
|
||
<span>
|
||
最近回传:{{ remoteLogSelectedNodeCode ? (remoteLogSelectedNodeSummary.last_at || "-") : (executionScene.log_sync?.last_at || "-") }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>{{ remoteLogSelectedNodeCode ? "节点现场摘要" : "回传摘要" }}</strong>
|
||
<div class="toolbar">
|
||
<el-select
|
||
v-model="remoteLogSelectedNodeCode"
|
||
clearable
|
||
filterable
|
||
size="small"
|
||
style="width: 260px"
|
||
placeholder="切换到指定节点现场"
|
||
@change="handleRemoteLogNodeSelectionChange"
|
||
>
|
||
<el-option
|
||
v-for="item in opsWorkerLogSceneNodeOptions"
|
||
:key="item.node_code"
|
||
:label="`${item.node_code} / ${item.region || '-'} / ${item.role || '-'}`"
|
||
:value="item.node_code"
|
||
/>
|
||
</el-select>
|
||
<el-button v-if="remoteLogSelectedNodeCode" size="small" text @click="handleRemoteLogNodeSelectionChange('')">
|
||
返回全局
|
||
</el-button>
|
||
<el-button size="small" text :loading="loading.sceneNodeLog" @click="refreshRemoteLogDrawer">刷新样本</el-button>
|
||
</div>
|
||
</div>
|
||
<el-alert
|
||
v-if="remoteLogSelectedNodeCode"
|
||
:closable="false"
|
||
show-icon
|
||
:type="remoteLogSelectedNodeStatusType"
|
||
:title="remoteLogSelectedNodeSummaryText"
|
||
style="margin-bottom: 12px"
|
||
/>
|
||
<el-descriptions v-if="remoteLogSelectedNodeCode" :column="2" border size="small">
|
||
<el-descriptions-item label="参与态">
|
||
{{ remoteLogSelectedNodeParticipation.participation_label || "在线待命" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="当前状态">
|
||
{{ remoteLogSelectedNodeMeta.status || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="样本数">
|
||
{{ remoteLogSelectedNodeSummary.line_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="关键 / 全量">
|
||
{{ `${remoteLogSelectedNodeSummary.key_line_count || 0} / ${remoteLogSelectedNodeSummary.full_line_count || 0}` }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近回传">
|
||
{{ remoteLogSelectedNodeSummary.last_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近心跳">
|
||
{{ remoteLogSelectedNodeMeta.last_heartbeat_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="参与说明" :span="2">
|
||
{{ remoteLogSelectedNodeParticipation.participation_reason || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缺口原因" :span="2">
|
||
{{ remoteLogSelectedNodeDetailData.missing_reason || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-descriptions v-else :column="2" border size="small">
|
||
<el-descriptions-item label="样本数">
|
||
{{ executionScene.log_sync?.line_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="来源节点">
|
||
{{ executionScene.log_sync?.source_node_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="来源列表" :span="2">
|
||
{{ (executionScene.log_sync?.source_nodes || []).join("、") || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缺口节点" :span="2">
|
||
{{ (executionScene.log_sync?.missing_participating_nodes || []).join("、") || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div class="panel-grid" style="margin-bottom: 14px">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>来源节点统计</strong>
|
||
<el-tag size="small" :type="opsWorkerLogSourceNodeSummaries.length ? 'success' : 'info'">
|
||
{{ opsWorkerLogSourceNodeSummaries.length ? `${opsWorkerLogSourceNodeSummaries.length} 台` : "暂无来源" }}
|
||
</el-tag>
|
||
</div>
|
||
<el-empty v-if="!opsWorkerLogSourceNodeSummaries.length" description="当前还没有来源节点统计" />
|
||
<el-table v-else :data="opsWorkerLogSourceNodeSummaries" size="small" border>
|
||
<el-table-column prop="node_code" label="节点" min-width="170" />
|
||
<el-table-column label="地域 / 角色" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ [row.region || "-", row.role || "-"].join(" / ") }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="line_count" label="累计样本" width="92" />
|
||
<el-table-column label="关键 / 全量" width="110">
|
||
<template #default="{ row }">
|
||
{{ `${row.key_line_count || 0} / ${row.full_line_count || 0}` }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="last_at" label="最近回传" min-width="160" />
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" text @click="openRemoteLogDrawer(row.node_code)">查看现场</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>未覆盖参与节点</strong>
|
||
<el-tag size="small" :type="opsWorkerLogMissingNodeSummaries.length ? 'warning' : 'success'">
|
||
{{ opsWorkerLogMissingNodeSummaries.length ? `${opsWorkerLogMissingNodeSummaries.length} 台` : "已全覆盖" }}
|
||
</el-tag>
|
||
</div>
|
||
<el-empty v-if="!opsWorkerLogMissingNodeSummaries.length" description="当前参与节点都已形成远端日志样本" />
|
||
<el-table v-else :data="opsWorkerLogMissingNodeSummaries" size="small" border>
|
||
<el-table-column prop="node_code" label="节点" min-width="170" />
|
||
<el-table-column label="地域 / 角色" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ [row.region || "-", row.role || "-"].join(" / ") }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="participation_label" label="参与态" width="96" />
|
||
<el-table-column prop="missing_reason" label="缺口原因" min-width="240" show-overflow-tooltip />
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" text @click="openRemoteLogDrawer(row.node_code)">查看现场</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>{{ remoteLogSelectedNodeCode ? "节点最近样本" : "最近样本" }}</strong>
|
||
<el-tag size="small" :type="remoteLogCurrentLines.length ? 'success' : 'info'">
|
||
{{ remoteLogCurrentLines.length ? `${remoteLogCurrentLines.length} 条` : "暂无样本" }}
|
||
</el-tag>
|
||
</div>
|
||
<el-empty
|
||
v-if="!remoteLogCurrentLines.length"
|
||
:description="remoteLogSelectedNodeCode ? '当前节点还没有可查看的现场日志样本' : '当前还没有可查看的远端日志样本'"
|
||
/>
|
||
<div v-else class="log-preview-shell">
|
||
<div
|
||
v-for="(line, index) in remoteLogCurrentLines"
|
||
:key="`${index}-${line}`"
|
||
class="log-preview-line"
|
||
>
|
||
{{ line }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="managedNodeDeliveryQueueVisible" title="回执队列治理" size="920px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>节点:{{ managedNodeDeliveryQueueNode.node_code || "-" }}</span>
|
||
<span>地域 / 角色:{{ managedNodeDeliveryQueueNode.region || "-" }} / {{ managedNodeDeliveryQueueNode.role || "-" }}</span>
|
||
<span>Agent:{{ managedNodeDeliveryQueueNode.agent_state_label || "-" }}</span>
|
||
<span>远端入口:{{ managedNodeDeliveryQueueNode.remote_access_label || "-" }}</span>
|
||
</div>
|
||
|
||
<div class="panel-grid" style="margin-bottom: 14px">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>队列概览</strong>
|
||
<div class="toolbar">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:loading="loading.deliveryQueue"
|
||
@click="refreshManagedNodeDeliveryQueueDrawer"
|
||
>
|
||
刷新
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:disabled="!managedNodeDeliveryQueueNode.node_code || managedNodeDeliveryQueueSummary.pending_count <= 0"
|
||
:loading="loading.deliveryQueueActionKey === `flush:${managedNodeDeliveryQueueNode.node_code}`"
|
||
@click="flushManagedNodeDeliveryQueue"
|
||
>
|
||
立即冲刷
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="warning"
|
||
text
|
||
:disabled="!managedNodeDeliveryQueueNode.node_code || managedNodeDeliveryQueueSummary.dead_letter_count <= 0"
|
||
:loading="loading.deliveryQueueActionKey === `replay:${managedNodeDeliveryQueueNode.node_code}`"
|
||
@click="replayManagedNodeDeliveryQueue"
|
||
>
|
||
重放死信
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:disabled="!managedNodeDeliveryQueueNode.node_code"
|
||
@click="openManagedNodeDeliveryQueueTemplate('delivery.queue.replay')"
|
||
>
|
||
标准动作
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="当前状态">
|
||
<el-tag :type="managedNodeDeliveryQueueSummaryType" effect="plain" size="small">
|
||
{{ managedNodeDeliveryQueueSummary.label || managedNodeDeliveryQueueSummary.state || "未知" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近冲刷">
|
||
{{ managedNodeDeliveryQueueSummary.last_flush_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="待重试">
|
||
{{ managedNodeDeliveryQueueSummary.pending_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="死信">
|
||
{{ managedNodeDeliveryQueueSummary.dead_letter_count || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最早积压">
|
||
{{ managedNodeDeliveryQueueSummary.oldest_pending_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最早死信">
|
||
{{ managedNodeDeliveryQueueSummary.oldest_dead_letter_at || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="说明" :span="2">
|
||
{{ managedNodeDeliveryQueueSummary.reason || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>能力边界</strong>
|
||
<el-tag size="small" effect="plain">
|
||
{{ managedNodeDeliveryQueueRecordVisibilityLabel }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">
|
||
当前记录来源:{{ managedNodeDeliveryQueueCapabilities.record_source || "-" }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
{{ managedNodeDeliveryQueueCapabilities.next_step || "当前控制面还没有回执队列额外说明。" }}
|
||
</div>
|
||
<div
|
||
v-for="item in managedNodeDeliveryQueueCapabilities.available_actions || []"
|
||
:key="`${item.key}-${item.record_scope}`"
|
||
class="preview-list__item"
|
||
>
|
||
{{ item.label || item.key }} · {{ item.record_scope || "-" }} · {{ item.execution_mode || "-" }}
|
||
</div>
|
||
</div>
|
||
<div class="preview-chip-list" style="margin-top: 12px">
|
||
<el-tag
|
||
v-if="managedNodeDeliveryQueueHeadPending.record_id"
|
||
size="small"
|
||
type="warning"
|
||
effect="plain"
|
||
>
|
||
待重试头部:{{ managedNodeDeliveryQueueHeadPending.record_id }}
|
||
</el-tag>
|
||
<el-tag
|
||
v-if="managedNodeDeliveryQueueHeadDeadLetter.record_id"
|
||
size="small"
|
||
type="danger"
|
||
effect="plain"
|
||
>
|
||
死信头部:{{ managedNodeDeliveryQueueHeadDeadLetter.record_id }}
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>可见记录</strong>
|
||
<div class="toolbar">
|
||
<el-select
|
||
v-model="managedNodeDeliveryQueueFilters.state"
|
||
size="small"
|
||
style="width: 140px"
|
||
@change="refreshManagedNodeDeliveryQueueRecords"
|
||
>
|
||
<el-option label="全部状态" value="" />
|
||
<el-option label="待重试" value="pending" />
|
||
<el-option label="死信" value="dead_letter" />
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list" style="margin-bottom: 12px">
|
||
<div class="preview-list__item">
|
||
当前记录视图:{{ managedNodeDeliveryQueueRecordVisibilityLabel }}
|
||
</div>
|
||
<div class="preview-list__item">
|
||
{{ managedNodeDeliveryQueueRecordsSummary.available_total || 0 }} 条可见记录,当前筛选后展示 {{ managedNodeDeliveryQueueRecords.length }} 条
|
||
</div>
|
||
</div>
|
||
<el-empty v-if="!managedNodeDeliveryQueueRecords.length" description="当前没有可展示的回执队列记录" />
|
||
<el-table v-else :data="managedNodeDeliveryQueueRecords" stripe size="small">
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="managedNodeDeliveryQueueRecordStateType(row)" effect="plain" size="small">
|
||
{{ managedNodeDeliveryQueueRecordStateLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="记录 ID" min-width="210">
|
||
<template #default="{ row }">
|
||
{{ managedNodeDeliveryQueueRecordId(row) || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ row.request_kind || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时间" min-width="160">
|
||
<template #default="{ row }">
|
||
{{ managedNodeDeliveryQueueRecordTimeText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="来源" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ row.record_source || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="说明" min-width="220">
|
||
<template #default="{ row }">
|
||
{{ row.label || row.error_message || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
v-if="row.state === 'dead_letter'"
|
||
size="small"
|
||
type="warning"
|
||
text
|
||
:loading="loading.deliveryQueueActionKey === `replay:${managedNodeDeliveryQueueNode.node_code}:${managedNodeDeliveryQueueRecordId(row)}`"
|
||
@click="replayManagedNodeDeliveryQueueRecord(row)"
|
||
>
|
||
重放
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.state === 'dead_letter'"
|
||
size="small"
|
||
type="danger"
|
||
text
|
||
:loading="loading.deliveryQueueActionKey === `discard:${managedNodeDeliveryQueueNode.node_code}:${managedNodeDeliveryQueueRecordId(row)}`"
|
||
@click="discardManagedNodeDeliveryQueueRecord(row)"
|
||
>
|
||
丢弃
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
@click="openManagedNodeDeliveryQueueTemplateForRecord(row)"
|
||
>
|
||
用模板处理
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="jobDetailVisible" title="任务详情" size="980px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>任务:{{ activeJob?.job_code || "-" }}</span>
|
||
<span>动作:{{ activeJob?.action || "-" }}</span>
|
||
<span>节点:{{ activeJob?.target_node_code || "-" }}</span>
|
||
<span>状态:{{ activeJobStatusText }}</span>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>任务概览</strong>
|
||
<div class="toolbar">
|
||
<el-button size="small" text :loading="loading.jobDetail" @click="refreshActiveJobDetail">刷新详情</el-button>
|
||
<el-button size="small" text :disabled="!activeJob?.id" @click="openJobEvents(activeJob || {})">查看事件</el-button>
|
||
</div>
|
||
</div>
|
||
<el-alert
|
||
v-if="activeJob?.summary_text"
|
||
:closable="false"
|
||
:type="jobStatusType(activeJob?.status || '') || 'info'"
|
||
show-icon
|
||
style="margin-bottom: 12px"
|
||
>
|
||
{{ activeJob?.summary_text }}
|
||
</el-alert>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="任务号">{{ activeJob?.job_code || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="动作">{{ activeJob?.action || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="目标节点">{{ activeJob?.target_node_code || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="执行方式">{{ activeJob?.execution_mode_label || executionModeLabel(activeJob?.execution_mode || "") || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="风险">
|
||
<el-tag :type="riskType(activeJob?.risk_level || 'low')" effect="plain" size="small">
|
||
{{ activeJob?.risk_level || "-" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="jobStatusType(activeJob?.status || '')" size="small">
|
||
{{ activeJobStatusText }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="审批状态">{{ activeJobApprovalStatusText }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建来源">{{ activeJob?.requested_by || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建时间">{{ activeJob?.created_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="最近更新">{{ activeJob?.updated_at || activeJob?.finished_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="步骤进度">
|
||
{{ activeJobStepProgressText }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="阻断原因" :span="2">
|
||
{{ activeJob?.blocked_reason || activeJob?.error_message || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
|
||
<div v-if="activeJobIsBootstrap" class="result-panel" style="margin-bottom: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>接入工单结果</strong>
|
||
<div class="toolbar">
|
||
<el-button size="small" text @click="copyActiveJobBootstrapToken">复制 Token</el-button>
|
||
<el-button size="small" text @click="copyActiveJobBootstrapEnv">复制 Env</el-button>
|
||
<el-button size="small" text @click="copyActiveJobBootstrapCommandBlock">复制接入片段</el-button>
|
||
<el-button size="small" text @click="copyActiveJobBootstrapScript">复制完整脚本</el-button>
|
||
<el-button size="small" text @click="copyActiveJobBootstrapRunBlock">复制一键落地</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="节点">{{ activeJob?.target_node_code || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="Token 预览">{{ activeJobBootstrapResult.token_preview || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="过期时间">{{ activeJobBootstrapResult.expires_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="控制面地址">{{ activeJobBootstrapResult.control_plane_base_url || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="脚本文件">{{ activeJobBootstrapPlan.bootstrap_script_name || "-" }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div v-if="activeJobBootstrapPlan.env_content" class="code-block">
|
||
<div class="code-title">Agent Env</div>
|
||
<pre>{{ activeJobBootstrapPlan.env_content }}</pre>
|
||
</div>
|
||
<div v-if="activeJobBootstrapPlan.command_block" class="code-block">
|
||
<div class="code-title">接入片段</div>
|
||
<pre>{{ activeJobBootstrapPlan.command_block }}</pre>
|
||
</div>
|
||
<div v-if="activeJobBootstrapPlan.bootstrap_script_content" class="code-block">
|
||
<div class="code-title">完整 Bootstrap 脚本</div>
|
||
<pre>{{ activeJobBootstrapPlan.bootstrap_script_content }}</pre>
|
||
</div>
|
||
<div v-if="activeJobBootstrapPlan.bootstrap_run_script_block" class="code-block">
|
||
<div class="code-title">一键落地命令</div>
|
||
<pre>{{ activeJobBootstrapPlan.bootstrap_run_script_block }}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-grid" style="margin-bottom: 14px">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>任务载荷</strong>
|
||
<el-button size="small" text @click="copyActiveJobPayload">复制 JSON</el-button>
|
||
</div>
|
||
<div class="code-block">
|
||
<pre>{{ activeJobPayloadText }}</pre>
|
||
</div>
|
||
</div>
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>任务结果</strong>
|
||
<el-button size="small" text @click="copyActiveJobResult">复制 JSON</el-button>
|
||
</div>
|
||
<div class="code-block">
|
||
<pre>{{ activeJobResultText }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-grid" style="margin-bottom: 14px">
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>任务元数据</strong>
|
||
<el-button size="small" text @click="copyActiveJobMetadata">复制 JSON</el-button>
|
||
</div>
|
||
<div class="code-block">
|
||
<pre>{{ activeJobMetadataText }}</pre>
|
||
</div>
|
||
</div>
|
||
<div class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>策略快照</strong>
|
||
<el-button size="small" text @click="copyActiveJobPolicy">复制 JSON</el-button>
|
||
</div>
|
||
<div class="code-block">
|
||
<pre>{{ activeJobPolicyText }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="activeJobSteps.length" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>步骤回执</strong>
|
||
<span class="watch-hint">{{ activeJobSteps.length }} 步</span>
|
||
</div>
|
||
<el-table :data="activeJobSteps" stripe size="small" max-height="320">
|
||
<el-table-column prop="title" label="步骤" min-width="180" />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="jobStatusType(row.status || '')" size="small">{{ row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="node_code" label="节点" min-width="140" />
|
||
<el-table-column label="输出摘要" min-width="340">
|
||
<template #default="{ row }">
|
||
{{ jobStepOutputSummary(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="jobEventsVisible" title="任务事件流" size="760px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>任务:{{ activeJob?.job_code || "-" }}</span>
|
||
<span>动作:{{ activeJob?.action || "-" }}</span>
|
||
<span>节点:{{ activeJob?.target_node_code || "-" }}</span>
|
||
<span>返回:{{ jobEventsSummary.returned_total || jobEvents.length || 0 }} 条{{ jobEventsSummary.latest_at ? ` / 最近 ${jobEventsSummary.latest_at}` : "" }}</span>
|
||
</div>
|
||
<el-table :data="jobEvents" stripe size="small" height="520">
|
||
<el-table-column prop="created_at" label="时间" min-width="150" />
|
||
<el-table-column prop="event_type" label="事件" min-width="140" />
|
||
<el-table-column label="级别" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="eventLevelType(row.level)" size="small">{{ row.level_label || row.level || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="node_code" label="节点" min-width="130" />
|
||
<el-table-column label="消息" min-width="260">
|
||
<template #default="{ row }">
|
||
{{ row.summary || row.message || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="rolloutJobsVisible" title="Rollout 任务列表" size="860px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>Rollout:{{ activeRollout?.rollout_code || "-" }}</span>
|
||
<span>状态:{{ activeRollout?.status_label || activeRollout?.status || "-" }}</span>
|
||
<span>版本:{{ selectedRelease.release_version || "-" }}</span>
|
||
</div>
|
||
<el-table :data="rolloutJobs" stripe size="small" height="520">
|
||
<el-table-column prop="job_code" label="任务号" min-width="170" />
|
||
<el-table-column prop="target_node_code" label="目标节点" min-width="140" />
|
||
<el-table-column prop="action" label="动作" min-width="130" />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="jobStatusType(row.status)" size="small">{{ row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="created_at" label="创建时间" min-width="150" />
|
||
<el-table-column label="操作" width="130" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button size="small" text @click="openJobDetail(row)">详情</el-button>
|
||
<el-button size="small" text @click="openJobEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-drawer v-model="playbookRunDetailVisible" title="编排回执详情" size="960px">
|
||
<template #default>
|
||
<div class="drawer-meta">
|
||
<span>回执:{{ activePlaybookRun?.run_code || "-" }}</span>
|
||
<span>编排:{{ activePlaybookRun?.playbook_title || activePlaybookRun?.playbook_key || "-" }}</span>
|
||
<span>分组:{{ playbookRunGroupTitle(activePlaybookRun?.group_key) }}</span>
|
||
<span>状态:{{ activePlaybookRun?.status_label || playbookRunStatusLabel(String(activePlaybookRun?.status || "")) }}</span>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="activePlaybookRun?.focus_summary"
|
||
:type="playbookRunFocusAlertType(activePlaybookRun?.focus_level)"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 14px"
|
||
>
|
||
<template #title>{{ activePlaybookRun?.focus_step_title || "当前焦点" }}</template>
|
||
{{ activePlaybookRun?.summary_text || activePlaybookRun?.focus_summary }}
|
||
</el-alert>
|
||
|
||
<div class="result-panel" style="margin-bottom: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>编排概览</strong>
|
||
<div class="toolbar">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:loading="loading.playbookRunDetail"
|
||
@click="refreshActivePlaybookRunDetail"
|
||
>
|
||
刷新详情
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.playbookRunActionKey === `rerun:${activePlaybookRun?.run_code || ''}`"
|
||
@click="rerunPlaybookRun(activePlaybookRun)"
|
||
>
|
||
重跑
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="danger"
|
||
text
|
||
:disabled="!canCancelPlaybookRun(String(activePlaybookRun?.status || ''))"
|
||
:loading="loading.playbookRunActionKey === `cancel:${activePlaybookRun?.run_code || ''}`"
|
||
@click="cancelPlaybookRun(activePlaybookRun)"
|
||
>
|
||
取消
|
||
</el-button>
|
||
<el-button size="small" text :disabled="!activePlaybookRun?.latest_job?.id" @click="openPlaybookRunLatestJobEvents(activePlaybookRun)">
|
||
最近事件
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="编排">{{ activePlaybookRun?.playbook_title || activePlaybookRun?.playbook_key || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建来源">{{ activePlaybookRun?.requested_by || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="执行方式">{{ playbookRunExecutionModeLabel(activePlaybookRun || {}) }}</el-descriptions-item>
|
||
<el-descriptions-item label="最近更新">{{ activePlaybookRun?.updated_at || activePlaybookRun?.created_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="目标节点">{{ (activePlaybookRun?.target_node_codes || []).join("、") || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="步骤 / 任务">{{ playbookRunProgressText(activePlaybookRun || {}) }}</el-descriptions-item>
|
||
<el-descriptions-item label="摘要" :span="2">{{ activePlaybookRun?.summary_text || "-" }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="playbook-run-chip-list" style="margin-top: 12px">
|
||
<el-tag v-for="step in activePlaybookRun?.problem_steps || []" :key="`problem-${step.step_key}`" type="danger" effect="plain">
|
||
异常:{{ step.title || step.step_key || "-" }}
|
||
</el-tag>
|
||
<el-tag v-for="step in activePlaybookRun?.active_steps || []" :key="`active-${step.step_key}`" type="warning" effect="plain">
|
||
进行中:{{ step.title || step.step_key || "-" }}
|
||
</el-tag>
|
||
<el-tag v-if="!(activePlaybookRun?.problem_steps || []).length && !(activePlaybookRun?.active_steps || []).length" type="success" effect="plain">
|
||
当前无异常或进行中步骤
|
||
</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading.playbookRunDetail"
|
||
:data="activePlaybookRun?.steps || []"
|
||
stripe
|
||
size="small"
|
||
max-height="460"
|
||
empty-text="当前编排还没有步骤回执"
|
||
>
|
||
<el-table-column label="步骤" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.title || row.step_key || "-" }}</strong>
|
||
<span>{{ row.template_key || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="playbookRunStatusType(row.status)" size="small">
|
||
{{ row.status_label || playbookRunStatusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="节点 / 任务" min-width="130">
|
||
<template #default="{ row }">
|
||
{{ playbookRunStepProgressText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="任务计数" min-width="180">
|
||
<template #default="{ row }">
|
||
{{ playbookRunStatusCountsText(row.status_counts) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="最近任务" min-width="180">
|
||
<template #default="{ row }">
|
||
{{ playbookRunLatestJobText(row.latest_job) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" text :disabled="!row.latest_job?.id" @click="openPlaybookRunStepEvents(row)">事件</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="result-panel" style="margin-top: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>整轮事件流</strong>
|
||
<div class="toolbar">
|
||
<span class="watch-hint">
|
||
{{ playbookRunEventsSummary.returned_total || playbookRunEventsSummary.total || 0 }} 条{{ playbookRunEventsSummary.latest_at ? ` / 最近 ${playbookRunEventsSummary.latest_at}` : "" }}
|
||
</span>
|
||
<el-button size="small" text @click="applyPlaybookRunEventFilters">刷新事件</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="playbook-run-filter-bar" style="margin-bottom: 12px">
|
||
<div class="playbook-run-filter-item">
|
||
<div class="summary-label">步骤</div>
|
||
<el-select
|
||
v-model="playbookRunEventFilters.step_key"
|
||
clearable
|
||
placeholder="全部步骤"
|
||
@change="applyPlaybookRunEventFilters"
|
||
>
|
||
<el-option
|
||
v-for="option in playbookRunEventStepOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
<div class="playbook-run-filter-item">
|
||
<div class="summary-label">节点</div>
|
||
<el-select
|
||
v-model="playbookRunEventFilters.node_code"
|
||
clearable
|
||
placeholder="全部节点"
|
||
@change="applyPlaybookRunEventFilters"
|
||
>
|
||
<el-option
|
||
v-for="option in playbookRunEventNodeOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</div>
|
||
<div class="toolbar">
|
||
<el-button size="small" text @click="resetPlaybookRunEventFilters">清空筛选</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading.playbookRunDetail"
|
||
:data="playbookRunEvents"
|
||
stripe
|
||
size="small"
|
||
max-height="360"
|
||
empty-text="当前整轮编排还没有新的事件回执"
|
||
>
|
||
<el-table-column label="时间" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.occurred_at || row.created_at || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="步骤" min-width="170">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.step_title || row.step_key || "-" }}</strong>
|
||
<span>{{ row.step_key || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="节点" min-width="150">
|
||
<template #default="{ row }">
|
||
{{ row.node_code || row.target_node_code || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="级别" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="playbookRunEventLevelType(row.level)" size="small">{{ row.level_label || row.level || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="event_type" label="事件" min-width="150" />
|
||
<el-table-column label="消息" min-width="320">
|
||
<template #default="{ row }">
|
||
{{ row.summary_text || row.summary || row.message || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="来源任务" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.job_code || "-" }}</strong>
|
||
<span>{{ row.action || "-" }} / {{ row.job_status_label || playbookRunStatusLabel(row.job_status || "") }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="130" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:disabled="!row.job_id"
|
||
@click="openJobDetail({ id: row.job_id, job_code: row.job_code, action: row.action, target_node_code: row.target_node_code })"
|
||
>
|
||
详情
|
||
</el-button>
|
||
<el-button size="small" text :disabled="!row.job_id" @click="openJobEvents({ id: row.job_id, job_code: row.job_code, action: row.action, target_node_code: row.target_node_code })">
|
||
事件
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<el-dialog v-model="agentTokenVisible" title="生成 Node Agent 接入方案" width="860px">
|
||
<el-form label-position="top" :model="agentTokenForm" class="dialog-form">
|
||
<div class="dialog-grid">
|
||
<el-form-item label="节点编码">
|
||
<el-input v-model="agentTokenForm.node_code" placeholder="例如 mainland-worker-02" />
|
||
</el-form-item>
|
||
<el-form-item label="有效时长(小时)">
|
||
<el-input-number v-model="agentTokenForm.expires_in_hours" :min="1" :max="720" />
|
||
</el-form-item>
|
||
<el-form-item label="签发人">
|
||
<el-input v-model="agentTokenForm.issued_by" placeholder="web-ui" />
|
||
</el-form-item>
|
||
<el-form-item label="节点角色提示">
|
||
<el-select v-model="agentTokenForm.node_role" placeholder="可选">
|
||
<el-option label="control" value="control" />
|
||
<el-option label="worker" value="worker" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="节点地域提示">
|
||
<el-select v-model="agentTokenForm.node_region" placeholder="可选">
|
||
<el-option label="mainland" value="mainland" />
|
||
<el-option label="overseas" value="overseas" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="控制面地址" class="full-width">
|
||
<el-input v-model="agentTokenForm.control_plane_base_url" placeholder="例如 https://ops.example.com" />
|
||
</el-form-item>
|
||
<el-form-item label="目标安装目录" class="full-width">
|
||
<el-input v-model="agentTokenForm.root_dir" placeholder="/opt/domaincheck" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>接入契约</strong>
|
||
<el-tag size="small" effect="plain">{{ contractVersionLabel(opsAgentContract) }}</el-tag>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="Contract">
|
||
{{ String(opsAgentContract.key || "ops_agent_protocol") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主入口">
|
||
{{ contractPrimaryEndpoint(opsAgentContract, "/api/v1/ops/agent/bootstrap-plan") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="文档">
|
||
{{ contractSchemaDocPath(opsAgentContract, "docs/schemas/ops_agent_protocol.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行链路">
|
||
{{ opsAgentContractExecutionChain }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="消费方">
|
||
{{ contractConsumersText(opsAgentContract) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="服务域">
|
||
{{ contractServiceKeysText(opsAgentContract) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div class="preview-list__item">发现入口:{{ contractDiscoveryPreviewText(opsAgentContract) }}</div>
|
||
<div class="preview-list__item">摘要:{{ contractSummaryText(opsAgentContract, "这一步负责 bootstrap-plan、接入 token、心跳注册和后续 job 回执的统一协定。") }}</div>
|
||
<div class="preview-list__item">契约要求:接管完成后,节点后续所有运维动作都应落到 `ops job -> node-agent` 链路,而不是页面直连 shell。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="agentTargetNodeCode" class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>接管观察</strong>
|
||
<div class="toolbar">
|
||
<span class="watch-hint">自动刷新 5 秒</span>
|
||
<el-button size="small" text :loading="loading.agentOnboardingRefresh" @click="refreshAgentOnboardingState()">
|
||
刷新状态
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="节点">{{ agentTargetNodeCode }}</el-descriptions-item>
|
||
<el-descriptions-item label="接管阶段">
|
||
<el-tag :type="agentHandoverStageTagType(agentHandoverStage.code || '')" size="small" effect="plain">
|
||
{{ agentHandoverStage.label || "待判断" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="接管态">
|
||
<el-tag :type="managedNodeAgentStateType(agentTargetNode.agent_state || '')" size="small">
|
||
{{ agentTargetNode.agent_state_label || "未知" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="运行态">
|
||
<el-tag :type="managedNodeClusterStateType(agentTargetNode)" effect="plain" size="small">
|
||
{{ managedNodeClusterStateLabel(agentTargetNode) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最近心跳">{{ managedNodeHeartbeatText(agentTargetNode) }}</el-descriptions-item>
|
||
<el-descriptions-item label="Token">{{ agentTokenStatusLabel }}</el-descriptions-item>
|
||
<el-descriptions-item label="最新任务">{{ managedNodeLatestJobText(agentTargetNode) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div v-if="agentHandoverSummary" class="field-help" style="margin-top: 10px">
|
||
{{ agentHandoverSummary }}
|
||
</div>
|
||
<div v-if="agentHandoverBlockingItems.length" class="preview-list" style="margin-top: 10px">
|
||
<div v-for="item in agentHandoverBlockingItems" :key="item" class="preview-list__item">
|
||
阻断:{{ item }}
|
||
</div>
|
||
</div>
|
||
<div v-else-if="agentHandoverAttentionItems.length" class="preview-list" style="margin-top: 10px">
|
||
<div v-for="item in agentHandoverAttentionItems" :key="item" class="preview-list__item">
|
||
提醒:{{ item }}
|
||
</div>
|
||
</div>
|
||
<div class="field-help" style="margin-top: 10px">
|
||
{{ agentOnboardingHint }}
|
||
</div>
|
||
<div v-if="agentOnboardingRecoveryDecision.action" class="result-panel" style="margin-top: 14px">
|
||
<div class="result-panel__head">
|
||
<strong>自动收口建议</strong>
|
||
<div class="toolbar">
|
||
<el-tag :type="agentOnboardingRecoveryDecisionTagType" size="small" effect="plain">
|
||
{{ agentOnboardingRecoveryDecision.label || "待判断" }}
|
||
</el-tag>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:loading="loading.onboardingRecoveryPreview"
|
||
@click="previewAgentOnboardingRecovery"
|
||
>
|
||
预览自动收口
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
text
|
||
:loading="loading.onboardingRecoveryExecute"
|
||
@click="executeAgentOnboardingRecovery"
|
||
>
|
||
执行自动收口
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ agentOnboardingRecoveryDecision.summary || "当前还没有恢复建议。" }}</div>
|
||
<div v-if="agentOnboardingRecoveryDecision.command_hint" class="preview-list__item code-inline-wrap">
|
||
{{ agentOnboardingRecoveryDecision.command_hint }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="lastIssuedToken.token" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>签发结果</strong>
|
||
<div class="toolbar">
|
||
<el-button size="small" text @click="copyIssuedToken">复制 Token</el-button>
|
||
<el-button size="small" text @click="copyAgentEnvContent">复制 Env</el-button>
|
||
<el-button size="small" text @click="copyAgentBootstrapSnippet">复制接入片段</el-button>
|
||
<el-button size="small" text @click="copyAgentBootstrapScript">复制完整脚本</el-button>
|
||
<el-button size="small" text @click="copyAgentBootstrapRunBlock">复制一键落地</el-button>
|
||
<el-button size="small" text @click="openNodeScopedPlaybookDialog('onboarding.bootstrap')">预览纳管编排</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="节点">{{ lastIssuedToken.node_code || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="过期">{{ lastIssuedToken.expires_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="预览">{{ lastIssuedToken.token_preview || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="脚本文件">{{ agentBootstrapScriptName || "-" }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="code-block">
|
||
<div class="code-title">Agent Env</div>
|
||
<pre>{{ agentEnvContent }}</pre>
|
||
</div>
|
||
<div class="code-block">
|
||
<div class="code-title">Agent 接入片段</div>
|
||
<pre>{{ agentBootstrapSnippet }}</pre>
|
||
</div>
|
||
<div v-if="agentBootstrapScriptContent" class="code-block">
|
||
<div class="code-title">完整 Bootstrap 脚本</div>
|
||
<pre>{{ agentBootstrapScriptContent }}</pre>
|
||
</div>
|
||
<div v-if="agentBootstrapRunBlock" class="code-block">
|
||
<div class="code-title">一键落地命令</div>
|
||
<pre>{{ agentBootstrapRunBlock }}</pre>
|
||
</div>
|
||
<div class="field-help" style="margin-top: 10px">
|
||
如需把这次接管动作纳入可回放、可协作的运维链,可直接打开“纳管编排”;该编排会在控制面重新签发一枚新的 Agent Token,并生成新的接入工单。
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="agentTargetNodeCode" class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>接入后快捷动作</strong>
|
||
<div class="toolbar">
|
||
<el-tag :type="agentOnboardingReadyForOps ? 'success' : 'info'" size="small">
|
||
{{ agentOnboardingReadyForOps ? "已可发任务" : "等待接管完成" }}
|
||
</el-tag>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingSequence"
|
||
@click="runAgentOnboardingAcceptanceSequence"
|
||
>
|
||
一键跑验收
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
@click="openNodeScopedPlaybookDialog('onboarding.acceptance')"
|
||
>
|
||
预览验收编排
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">
|
||
节点接入完成后,可直接从这里发起第一轮巡检和日志采集,避免再切回运维任务列表手动创建。
|
||
</div>
|
||
<div class="preview-list__item">
|
||
当前“一键跑验收”已经走后端 `onboarding.acceptance` playbook,不再由页面自己循环创建 5 组模板任务。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>接管验收结论</strong>
|
||
<el-tag :type="agentOnboardingAcceptanceSummary.type" size="small">
|
||
{{ agentOnboardingAcceptanceSummary.label }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ agentOnboardingAcceptanceSummary.summary }}</div>
|
||
<div class="preview-list__item">
|
||
通过 {{ agentOnboardingAcceptanceSummary.counts.successCount }} / {{ agentOnboardingAcceptanceSummary.counts.total }}
|
||
,执行中 {{ agentOnboardingAcceptanceSummary.counts.runningCount }}
|
||
,异常 {{ agentOnboardingAcceptanceSummary.counts.failedCount }}
|
||
,待执行 {{ agentOnboardingAcceptanceSummary.counts.pendingCount }}
|
||
</div>
|
||
<div
|
||
v-if="agentOnboardingAcceptanceSummary.nextStep?.step?.title"
|
||
class="preview-list__item"
|
||
>
|
||
推荐下一步:{{ agentOnboardingAcceptanceSummary.nextStep.step.title }}
|
||
</div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top: 12px; justify-content: flex-start">
|
||
<el-button
|
||
v-if="agentOnboardingAcceptanceSummary.nextStep?.step?.key && agentOnboardingReadyForOps"
|
||
size="small"
|
||
plain
|
||
@click="
|
||
runAgentOnboardingQuickAction(
|
||
agentOnboardingAcceptanceSummary.nextStep.step.key,
|
||
agentOnboardingAcceptanceSummary.nextStep.step.payload || {}
|
||
)
|
||
"
|
||
>
|
||
执行推荐下一步
|
||
</el-button>
|
||
<el-button
|
||
v-if="agentOnboardingLatestFailedJob.job_code"
|
||
size="small"
|
||
plain
|
||
@click="openJobEvents(agentOnboardingLatestFailedJob)"
|
||
>
|
||
查看最近失败事件
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="agentOnboardingAcceptanceSummary.status === 'passed'"
|
||
class="result-panel"
|
||
style="margin-top: 16px"
|
||
>
|
||
<div class="result-panel__head">
|
||
<strong>接管后正式运维</strong>
|
||
<el-tag type="success" size="small">可进入巡检 / 发布</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">
|
||
当前节点:{{ agentTargetNodeCode || "-" }};接管验收已经通过,后续可以直接从这里进入标准巡检或定向发布。
|
||
</div>
|
||
<div class="preview-list__item">
|
||
推荐巡检:先收一份诊断包,确认 Agent / API / Worker / Sync Agent 的状态和近期日志。
|
||
</div>
|
||
<div class="preview-list__item">
|
||
推荐发布:当前将默认锁定该节点做定向 Rollout,使用版本 {{ preferredNodeOpsReleaseLabel }}。
|
||
</div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top: 12px; justify-content: flex-start">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="loading.nodeOpsSequence"
|
||
@click="runNodeScopedInspectionSequence"
|
||
>
|
||
执行标准巡检
|
||
</el-button>
|
||
<el-button size="small" type="primary" plain @click="openNodeScopedDiagnosticsDialog">
|
||
打开诊断模板
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!preferredNodeOpsRelease.id"
|
||
@click="openNodeScopedReleaseDeployDialog"
|
||
>
|
||
打开点状发布模板
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:disabled="!preferredNodeOpsRelease.id"
|
||
@click="openNodeScopedRolloutDialog"
|
||
>
|
||
进入发布模式
|
||
</el-button>
|
||
<el-button
|
||
v-if="!preferredNodeOpsRelease.id"
|
||
size="small"
|
||
plain
|
||
@click="openReleaseDialog"
|
||
>
|
||
先创建 Release
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid" style="margin-top: 14px">
|
||
<div v-for="step in agentOnboardingAcceptanceDefinitions" :key="`${step.key}-${step.payload?.service_name || 'default'}`" class="preview-box">
|
||
<div class="result-panel__head" style="margin-bottom: 8px">
|
||
<strong>{{ step.title }}</strong>
|
||
<el-tag :type="agentOnboardingStepStatusType(step)" size="small">
|
||
{{ agentOnboardingStepStatusLabel(step) }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ step.description }}</div>
|
||
<div class="preview-list__item" v-if="findAgentOnboardingStepJob(step).job_code">
|
||
最近任务:{{ findAgentOnboardingStepJob(step).job_code }} / {{ findAgentOnboardingStepJob(step).created_at || "-" }}
|
||
</div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top: 10px; justify-content: flex-start">
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === step.loadingKey"
|
||
@click="runAgentOnboardingQuickAction(step.key, step.payload)"
|
||
>
|
||
执行这一步
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="agentOnboardingRecentJobs.length" class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>最近验收记录</strong>
|
||
<span class="watch-hint">仅展示当前节点最近 8 条验收任务</span>
|
||
</div>
|
||
<el-table :data="agentOnboardingRecentJobs" stripe size="small" max-height="260">
|
||
<el-table-column prop="job_code" label="任务号" min-width="170" />
|
||
<el-table-column prop="action" label="动作" min-width="130" />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="jobStatusType(row.status)" size="small">{{ row.status || "-" }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="created_at" label="创建时间" min-width="150" />
|
||
<el-table-column label="操作" width="130" fixed="right">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button size="small" text @click="openJobDetail(row)">详情</el-button>
|
||
<el-button size="small" text @click="openJobEvents(row)">事件</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<div class="result-panel__head" style="margin-top: 16px">
|
||
<strong>手动快捷动作</strong>
|
||
<el-tag :type="agentOnboardingReadyForOps ? 'success' : 'info'" size="small">
|
||
{{ agentOnboardingReadyForOps ? "已可发任务" : "等待接管完成" }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="toolbar" style="margin-top: 12px; justify-content: flex-start">
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === 'health.snapshot'"
|
||
@click="runAgentOnboardingQuickAction('health.snapshot')"
|
||
>
|
||
健康快照
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === `service.status:${agentObservedServiceNames.nodeAgent}`"
|
||
@click="runAgentOnboardingQuickAction('service.status', { service_name: agentObservedServiceNames.nodeAgent })"
|
||
>
|
||
Node Agent 状态
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === `logs.collect:${agentObservedServiceNames.nodeAgent}`"
|
||
@click="runAgentOnboardingQuickAction('logs.collect', { service_name: agentObservedServiceNames.nodeAgent, lines: 120 })"
|
||
>
|
||
Node Agent 日志
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === `service.status:${agentObservedServiceNames.worker}`"
|
||
@click="runAgentOnboardingQuickAction('service.status', { service_name: agentObservedServiceNames.worker })"
|
||
>
|
||
Worker 状态
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!agentOnboardingReadyForOps"
|
||
:loading="loading.onboardingActionKey === `logs.collect:${agentObservedServiceNames.worker}`"
|
||
@click="runAgentOnboardingQuickAction('logs.collect', { service_name: agentObservedServiceNames.worker, lines: 120 })"
|
||
>
|
||
Worker 日志
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="agentTokenVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="loading.tokenIssue" @click="issueAgentToken">生成接入方案</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="releaseDialogVisible" title="创建 Release" width="780px">
|
||
<el-form label-position="top" :model="releaseForm" class="dialog-form">
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>发版契约</strong>
|
||
<el-tag size="small" effect="plain">{{ contractVersionLabel(opsReleaseHubContract) }}</el-tag>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="Contract">
|
||
{{ String(opsReleaseHubContract.key || "release_hub_contract") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主入口">
|
||
{{ contractPrimaryEndpoint(opsReleaseHubContract, "/api/v1/ops/releases") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Launchpad">
|
||
{{ opsReleaseHubLaunchpadEndpoint }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="文档">
|
||
{{ contractSchemaDocPath(opsReleaseHubContract, "docs/schemas/release_hub_contract.md") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="发布链路">
|
||
{{ opsReleaseContractExecutionChain }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="服务域">
|
||
{{ contractServiceKeysText(opsReleaseHubContract) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div class="preview-list__item">发现入口:{{ contractDiscoveryPreviewText(opsReleaseHubContract) }}</div>
|
||
<div class="preview-list__item">摘要:{{ contractSummaryText(opsReleaseHubContract, "Release 先固化版本、artifact_url、checksum,再由 rollout gate 决定如何发到节点。") }}</div>
|
||
<div class="preview-list__item">界面约束:创建 Release 只定义“可发布的版本实体”,真正的批次、健康检查、回滚策略交给 Rollout 阶段处理。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>本机最新发布包</strong>
|
||
<div class="table-actions">
|
||
<el-button size="small" text :loading="loading.releasePackageMetadata" @click="loadLatestReleasePackageMetadata({ showToast: true })">
|
||
刷新包信息
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="!latestReleasePackageAvailable"
|
||
:loading="loading.releasePackageImport"
|
||
@click="importLatestReleasePackage"
|
||
>
|
||
导入最新包
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="success"
|
||
plain
|
||
:disabled="!latestReleasePackageReleaseActionAllowed || loading.releaseCreateAndRollout"
|
||
:loading="loading.releaseCreate"
|
||
@click="createReleaseFromLatestPackage"
|
||
>
|
||
直接创建 Release
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="warning"
|
||
plain
|
||
:disabled="!latestReleasePackageReleaseActionAllowed || loading.releaseCreate || Boolean(loading.releasePackageSmartRolloutKey)"
|
||
:loading="loading.releaseCreateAndRollout"
|
||
@click="createReleaseFromLatestPackageAndOpenRollout"
|
||
>
|
||
创建并去 Rollout
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="danger"
|
||
plain
|
||
:disabled="!latestReleasePackageReleaseActionAllowed || loading.releaseCreate || loading.releaseCreateAndRollout || Boolean(loading.releasePackageSmartRolloutKey)"
|
||
:loading="loading.releasePackageSmartRolloutKey === 'worker'"
|
||
@click="createReleaseFromLatestPackageAndCreateSmartRollout('worker')"
|
||
>
|
||
一键 Worker 灰度
|
||
</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="!latestReleasePackageReleaseActionAllowed || loading.releaseCreate || loading.releaseCreateAndRollout || Boolean(loading.releasePackageSmartRolloutKey)"
|
||
:loading="loading.releasePackageSmartRolloutKey === 'control'"
|
||
@click="createReleaseFromLatestPackageAndCreateSmartRollout('control')"
|
||
>
|
||
一键 Control 发布
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<el-descriptions v-if="latestReleasePackageAvailable" :column="2" border size="small">
|
||
<el-descriptions-item label="包名">{{ latestReleasePackage.package_name || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="类型">{{ latestReleasePackage.package_type || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="建议版本号">
|
||
{{ latestReleasePackage.release_version_suggestion || latestReleasePackage.package_name || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="生成时间">{{ latestReleasePackage.generated_at || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="提交号">{{ latestReleasePackage.commit_sha || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="自检状态">
|
||
<el-tag :type="latestReleasePackage.smoke_test_ok ? 'success' : 'warning'" size="small">
|
||
{{ latestReleasePackage.smoke_test_ok ? "通过" : "未执行或未通过" }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="最终签收">
|
||
<el-tag
|
||
:type="
|
||
latestReleasePackage.final_release_ok
|
||
? 'success'
|
||
: latestReleasePackage.final_release_report_available
|
||
? 'warning'
|
||
: 'info'
|
||
"
|
||
size="small"
|
||
>
|
||
{{
|
||
latestReleasePackage.final_release_ok
|
||
? "通过"
|
||
: latestReleasePackage.final_release_report_available
|
||
? "未通过"
|
||
: "未生成"
|
||
}}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="归档文件">{{ latestReleasePackage.archive_filename || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="下载地址" :span="2">
|
||
{{ latestReleasePackageDownloadUrl || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="SHA256" :span="2">
|
||
{{ latestReleasePackage.sha256 || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="签收门禁" :span="2">
|
||
{{
|
||
latestReleasePackage.final_release_gate_decision
|
||
? `${latestReleasePackage.final_release_gate_decision}${latestReleasePackage.final_release_report_matches_latest === false && latestReleasePackage.final_release_report_stale_reason ? ` / ${latestReleasePackage.final_release_report_stale_reason}` : ""}`
|
||
: (latestReleasePackage.final_release_report_stale_reason || "-")
|
||
}}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Manifest 摘要" :span="2">
|
||
{{
|
||
latestReleasePackageManifest.package_type
|
||
? `${latestReleasePackageManifest.package_type} / docs ${latestReleasePackageManifest.included?.docs?.length || 0} / scripts ${latestReleasePackageManifest.included?.scripts?.length || 0}`
|
||
: "当前未读取到 release_manifest.json 摘要"
|
||
}}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<el-alert
|
||
v-if="latestReleasePackageAvailable && latestReleasePackageReleaseActionBlockMessage"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
:title="latestReleasePackageReleaseActionBlockMessage"
|
||
style="margin-top: 12px"
|
||
>
|
||
当前仍可导入包信息和预填 Release 表单,但“创建 Release / 一键智能 Rollout”会被门禁拦截,直到最终签收通过。
|
||
</el-alert>
|
||
|
||
<el-alert
|
||
v-else
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
:title="latestReleasePackage.reason || '当前还没有可导入的本机发布包'"
|
||
>
|
||
先运行 `package_domain_release.sh` 或 `package_domain_release.ps1`,控制面就能直接导入最新产物。
|
||
</el-alert>
|
||
</div>
|
||
|
||
<div class="dialog-grid dialog-grid--release">
|
||
<el-form-item label="版本号">
|
||
<el-input v-model="releaseForm.release_version" placeholder="例如 2026.04.17-ops1" />
|
||
</el-form-item>
|
||
<el-form-item label="渠道">
|
||
<el-select v-model="releaseForm.channel">
|
||
<el-option label="stable" value="stable" />
|
||
<el-option label="beta" value="beta" />
|
||
<el-option label="canary" value="canary" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-select v-model="releaseForm.status">
|
||
<el-option label="draft" value="draft" />
|
||
<el-option label="ready" value="ready" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="Commit SHA">
|
||
<el-input v-model="releaseForm.commit_sha" placeholder="例如 bd6bcb2" />
|
||
</el-form-item>
|
||
<el-form-item label="Artifact URL" class="full-width">
|
||
<el-input v-model="releaseForm.artifact_url" placeholder="例如 https://release.example.com/domaincheck-2026.04.17.tar.gz" />
|
||
</el-form-item>
|
||
<el-form-item label="Checksum (sha256)" class="full-width">
|
||
<el-input v-model="releaseForm.checksum" placeholder="可选,但正式环境强烈建议填写" />
|
||
</el-form-item>
|
||
<el-form-item label="说明" class="full-width">
|
||
<el-input v-model="releaseForm.notes" type="textarea" :rows="4" placeholder="描述本次发布包含哪些变更" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="releaseDialogVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="loading.releaseCreate" @click="createRelease">创建</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="rolloutDialogVisible" title="创建 Rollout" width="920px">
|
||
<el-form label-position="top" :model="rolloutForm" class="dialog-form">
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>Rollout 契约</strong>
|
||
<el-tag size="small" effect="plain">{{ contractVersionLabel(opsReleaseHubContract) }}</el-tag>
|
||
</div>
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="Contract">
|
||
{{ String(opsReleaseHubContract.key || "release_hub_contract") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Rollout 主入口">
|
||
{{ contractPrimaryEndpoint(opsReleaseHubContract, "/api/v1/ops/releases") }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Launchpad">
|
||
{{ opsReleaseHubLaunchpadEndpoint }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行方式">
|
||
{{ rolloutExecutionModeLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行链路">
|
||
{{ opsRolloutContractExecutionChain }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="文档">
|
||
{{ contractSchemaDocPath(opsReleaseHubContract, "docs/schemas/release_hub_contract.md") }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="preview-list" style="margin-top: 12px">
|
||
<div class="preview-list__item">发现入口:{{ contractDiscoveryPreviewText(opsReleaseHubContract) }}</div>
|
||
<div class="preview-list__item">摘要:{{ contractSummaryText(opsReleaseHubContract, "Rollout preview、gate、dispatch 和回滚都必须共享同一份 release contract,避免页面和执行面口径漂移。") }}</div>
|
||
<div class="preview-list__item">当前视图会把 execution_mode、批次大小、健康检查服务和 URL 一起固化进 rollout payload,后续应直接由 Node Agent / 执行器消费。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>目标 Release</strong>
|
||
<div class="table-actions">
|
||
<el-button size="small" text @click="applySmartRolloutTargetPlan('auto')">智能预填</el-button>
|
||
<el-button size="small" text @click="applySmartRolloutTargetPlan('worker')">Worker 灰度</el-button>
|
||
<el-button size="small" text @click="applySmartRolloutTargetPlan('control')">Control 发布</el-button>
|
||
</div>
|
||
</div>
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="版本">{{ rolloutTargetRelease.release_version || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="渠道">{{ rolloutTargetRelease.channel || "-" }}</el-descriptions-item>
|
||
<el-descriptions-item label="Commit">{{ rolloutTargetRelease.commit_sha || "-" }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-alert
|
||
v-if="rolloutSmartPrefillHint"
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-top: 12px"
|
||
:title="rolloutSmartPrefillHint"
|
||
/>
|
||
</div>
|
||
|
||
<div class="dialog-grid dialog-grid--rollout">
|
||
<el-form-item label="目标节点(逗号/换行分隔)" class="full-width">
|
||
<el-input v-model="rolloutForm.node_codes_text" type="textarea" :rows="3" placeholder="例如 mainland-controller-01, mainland-worker-01" />
|
||
</el-form-item>
|
||
<el-form-item label="地域">
|
||
<el-select v-model="rolloutForm.region" clearable placeholder="不限制">
|
||
<el-option label="mainland" value="mainland" />
|
||
<el-option label="overseas" value="overseas" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="角色">
|
||
<el-select v-model="rolloutForm.role" clearable placeholder="不限制">
|
||
<el-option label="control" value="control" />
|
||
<el-option label="worker" value="worker" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="执行方式">
|
||
<el-select v-model="rolloutForm.execution_mode">
|
||
<el-option label="remote-agent" value="remote-agent" />
|
||
<el-option label="ssh" value="ssh" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="第一批">
|
||
<el-input-number v-model="rolloutForm.first_batch_size" :min="1" :max="200" />
|
||
</el-form-item>
|
||
<el-form-item label="后续批次">
|
||
<el-input-number v-model="rolloutForm.batch_size" :min="1" :max="500" />
|
||
</el-form-item>
|
||
<el-form-item label="健康检查超时(秒)">
|
||
<el-input-number v-model="rolloutForm.health_check_timeout_seconds" :min="2" :max="60" />
|
||
</el-form-item>
|
||
<el-form-item label="健康检查重试">
|
||
<el-input-number v-model="rolloutForm.health_check_retries" :min="0" :max="10" />
|
||
</el-form-item>
|
||
<el-form-item label="重启服务(逗号/换行分隔)" class="full-width">
|
||
<el-input v-model="rolloutForm.restart_services_text" type="textarea" :rows="2" placeholder="例如 domaincheck-api, domaincheck-worker" />
|
||
</el-form-item>
|
||
<el-form-item label="健康检查 URL(逗号/换行分隔)" class="full-width">
|
||
<el-input
|
||
v-model="rolloutForm.health_check_urls_text"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="例如 http://127.0.0.1:8100/health"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="健康检查服务(逗号/换行分隔)" class="full-width">
|
||
<el-input
|
||
v-model="rolloutForm.health_check_services_text"
|
||
type="textarea"
|
||
:rows="2"
|
||
placeholder="例如 domaincheck-api, domaincheck-worker"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div class="switch-grid">
|
||
<el-switch v-model="rolloutForm.only_enabled" active-text="仅已启用节点" />
|
||
<el-switch v-model="rolloutForm.only_online" active-text="仅在线节点" />
|
||
<el-switch v-model="rolloutForm.only_effective_workers" active-text="仅有效 Worker" />
|
||
<el-switch v-model="rolloutForm.auto_start" active-text="创建后立即发第一批" />
|
||
<el-switch v-model="rolloutForm.auto_dispatch" active-text="自动派发任务" />
|
||
<el-switch v-model="rolloutForm.auto_advance" active-text="自动推进下一批" />
|
||
<el-switch v-model="rolloutForm.auto_approve" active-text="自动审批" />
|
||
<el-switch v-model="rolloutForm.continue_on_failure" active-text="失败继续推进" />
|
||
<el-switch v-model="rolloutForm.continue_on_partial" active-text="部分成功继续推进" />
|
||
<el-switch v-model="rolloutForm.rollback_on_failure" active-text="健康失败自动回滚" />
|
||
<el-switch v-model="rolloutForm.switch_current" active-text="切换 current 软链" />
|
||
</div>
|
||
|
||
<div v-if="rolloutPreviewHasData" class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>Rollout 预检</strong>
|
||
<el-tag
|
||
:type="rolloutPreview.blocked ? 'danger' : rolloutPreview.approval_required || rolloutPreviewWarnings.length ? 'warning' : 'success'"
|
||
size="small"
|
||
>
|
||
{{ rolloutPreview.blocked ? "存在阻断" : rolloutPreview.approval_required ? "建议审批" : "可推进" }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-alert
|
||
v-if="rolloutPreviewReleaseGate.status_label || rolloutPreviewReleaseGate.summary"
|
||
:type="rolloutPreviewReleaseGateStatusType"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 12px"
|
||
>
|
||
<template #title>Release 门禁:{{ rolloutPreviewReleaseGate.status_label || "未评估" }}</template>
|
||
{{ rolloutPreviewReleaseGate.summary || "当前没有额外的 Release 门禁摘要。" }}
|
||
</el-alert>
|
||
|
||
<el-descriptions :column="3" border size="small">
|
||
<el-descriptions-item label="目标 Release">
|
||
{{ rolloutPreviewRelease.release_version || selectedRolloutTargetRelease.release_version || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="发布通道">
|
||
{{ rolloutPreviewRelease.channel || selectedRolloutTargetRelease.channel || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Release 状态">
|
||
{{ rolloutPreviewRelease.status || selectedRolloutTargetRelease.status || "-" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="目标节点">{{ rolloutPreviewTargetSummary.nodes_total || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="Worker / Control">
|
||
{{ rolloutPreviewTargetSummary.worker_nodes || 0 }} / {{ rolloutPreviewTargetSummary.control_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="首批 / 总批次">
|
||
{{ rolloutPreviewBatchPlan.first_batch_size || 0 }} / {{ rolloutPreviewBatchPlan.batches_total || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="有效 Worker">
|
||
{{ rolloutPreviewTargetSummary.effective_worker_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="忙碌 / 参与检测">
|
||
{{ rolloutPreviewTargetSummary.busy_nodes || 0 }} / {{ rolloutPreviewTargetSummary.participating_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="控制面护栏">
|
||
{{ rolloutPreviewGuardrails.online_control_nodes || 0 }} control / {{ rolloutPreviewGuardrails.online_worker_nodes || 0 }} worker
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<el-descriptions :column="3" border size="small" style="margin-top: 12px">
|
||
<el-descriptions-item label="已纳管 / Agent 在线">
|
||
{{ rolloutPreviewReadinessSummary.managed_nodes || 0 }} / {{ rolloutPreviewReadinessSummary.agent_online_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="执行方式">
|
||
{{ rolloutPreviewExecutionModeLabel }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="`${rolloutPreviewExecutionModeLabel} 就绪`">
|
||
{{ rolloutPreviewReadinessSummary.execution_ready_nodes || 0 }} / {{ rolloutPreviewReadinessSummary.nodes_total || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="巡检通过">
|
||
{{ rolloutPreviewReadinessSummary.inspection_healthy_nodes || 0 }} / {{ rolloutPreviewReadinessSummary.nodes_total || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="巡检进行中">
|
||
{{ rolloutPreviewReadinessSummary.inspection_running_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="巡检待关注">
|
||
{{ rolloutPreviewReadinessSummary.inspection_attention_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="缺少巡检">
|
||
{{ rolloutPreviewReadinessSummary.inspection_missing_nodes || 0 }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-grid">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">首批节点</div>
|
||
<div class="preview-chip-list">
|
||
<el-tag
|
||
v-for="item in rolloutPreviewBatchPlan.first_batch_nodes || []"
|
||
:key="item.node_code"
|
||
size="small"
|
||
effect="plain"
|
||
>
|
||
{{ item.node_code }} / {{ item.role || "-" }}
|
||
</el-tag>
|
||
<span v-if="!(rolloutPreviewBatchPlan.first_batch_nodes || []).length" class="preview-empty">暂无</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">建议</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in rolloutPreviewRecommendations" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!rolloutPreviewRecommendations.length" class="preview-empty">当前无额外建议</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-grid preview-grid--stack">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">阻断项</div>
|
||
<div class="preview-list preview-list--danger">
|
||
<div v-for="item in rolloutPreviewBlockingReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!rolloutPreviewBlockingReasons.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">注意事项</div>
|
||
<div class="preview-list preview-list--warning">
|
||
<div v-for="item in rolloutPreviewWarnings" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!rolloutPreviewWarnings.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">审批依据</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in rolloutPreviewApprovalReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!rolloutPreviewApprovalReasons.length" class="preview-empty">当前不要求额外审批</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="preview-box" style="margin-top: 16px">
|
||
<div class="preview-box__title">节点接管 / 巡检准备</div>
|
||
<el-table :data="rolloutPreviewReadinessRows" stripe size="small" max-height="260" empty-text="当前没有预检准备度数据">
|
||
<el-table-column label="节点" min-width="180">
|
||
<template #default="{ row }">
|
||
<div class="node-cell">
|
||
<strong>{{ row.node_code || "-" }}</strong>
|
||
<span>{{ row.region || "-" }} / {{ row.role || "-" }}</span>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="接管状态" width="140">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutPreviewAgentTagType(row)" effect="plain" size="small">
|
||
{{ row.agent_state_label || "-" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="`${rolloutPreviewExecutionModeLabel} 入口`" width="140">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutPreviewExecutionTagType(row)" effect="plain" size="small">
|
||
{{ rolloutPreviewExecutionLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="巡检状态" width="140">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutPreviewInspectionTagType(row)" effect="plain" size="small">
|
||
{{ row.inspection_status_label || "-" }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="接管恢复" width="160">
|
||
<template #default="{ row }">
|
||
<el-tag :type="rolloutReadinessRecoveryTagType(row)" effect="plain" size="small">
|
||
{{ rolloutReadinessRecoveryLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="说明" min-width="320">
|
||
<template #default="{ row }">
|
||
<div>{{ rolloutPreviewReadinessReasonText(row) }}</div>
|
||
<div v-if="row.recovery_summary" class="field-help" style="margin-top: 4px">
|
||
{{ row.recovery_summary }}
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="动作" width="140" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
v-if="['bootstrap_run', 'run_acceptance'].includes(String(row.recovery_action || '').trim())"
|
||
size="small"
|
||
text
|
||
type="primary"
|
||
:loading="loading.rolloutPreviewActionKey === `recovery:${String(row.node_code || '').trim()}`"
|
||
@click="runRolloutReadinessRecovery(row)"
|
||
>
|
||
{{ String(row.recovery_action || '').trim() === 'run_acceptance' ? '跑验收' : '跑接入' }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="rolloutDialogVisible = false">关闭</el-button>
|
||
<el-button plain :loading="loading.rolloutPreview" @click="previewRollout">预检 Rollout</el-button>
|
||
<el-button type="primary" :loading="loading.rolloutCreate" @click="createRollout">创建 Rollout</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="managedNodeVisible" :title="managedNodeDialogTitle" width="760px">
|
||
<el-form label-position="top" :model="managedNodeForm" class="dialog-form">
|
||
<div class="dialog-grid">
|
||
<el-form-item label="节点编码">
|
||
<el-input v-model="managedNodeForm.node_code" :disabled="managedNodeEditing" placeholder="例如 mainland-worker-02" />
|
||
</el-form-item>
|
||
<el-form-item label="显示名称">
|
||
<el-input v-model="managedNodeForm.title" placeholder="例如 上海 worker 02" />
|
||
</el-form-item>
|
||
<el-form-item label="地域">
|
||
<el-select v-model="managedNodeForm.region">
|
||
<el-option label="mainland" value="mainland" />
|
||
<el-option label="overseas" value="overseas" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="角色">
|
||
<el-select v-model="managedNodeForm.role">
|
||
<el-option label="worker" value="worker" />
|
||
<el-option label="control" value="control" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="SSH Host">
|
||
<el-input v-model="managedNodeForm.ssh_host" placeholder="例如 121.204.244.248" />
|
||
</el-form-item>
|
||
<el-form-item label="SSH Port">
|
||
<el-input-number v-model="managedNodeForm.ssh_port" :min="1" :max="65535" />
|
||
</el-form-item>
|
||
<el-form-item label="SSH User">
|
||
<el-input v-model="managedNodeForm.ssh_user" placeholder="例如 root / www" />
|
||
</el-form-item>
|
||
<el-form-item label="认证方式">
|
||
<el-select v-model="managedNodeForm.auth_mode">
|
||
<el-option label="key" value="key" />
|
||
<el-option label="password" value="password" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="发布渠道">
|
||
<el-select v-model="managedNodeForm.deploy_channel">
|
||
<el-option label="stable" value="stable" />
|
||
<el-option label="beta" value="beta" />
|
||
<el-option label="canary" value="canary" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="启用状态">
|
||
<el-switch v-model="managedNodeForm.is_enabled" active-text="已启用" inactive-text="已停用" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="managedNodeVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="loading.nodeSave" @click="saveManagedNode">
|
||
{{ managedNodePostAction === "bootstrap-plan" ? "保存并生成接入方案" : "保存节点" }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="actionTemplateVisible" title="发起标准动作" width="860px">
|
||
<el-form label-position="top" :model="actionTemplateForm" class="dialog-form">
|
||
<div class="dialog-grid">
|
||
<el-form-item label="动作模板" class="full-width">
|
||
<el-select v-model="actionTemplateForm.template_key" placeholder="选择动作模板" @change="handleActionTemplateChange">
|
||
<el-option
|
||
v-for="template in actionTemplates"
|
||
:key="template.key"
|
||
:label="`${template.title} (${template.group_title})`"
|
||
:value="template.key"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="目标节点" class="full-width">
|
||
<el-select v-model="actionTemplateForm.target_node_codes" multiple filterable placeholder="至少选择一个节点">
|
||
<el-option
|
||
v-for="node in actionTargetNodes"
|
||
:key="node.node_code"
|
||
:label="`${node.node_code} / ${node.region} / ${node.role}${node.metadata?.imported_from_cluster_fallback ? ' / 未纳管' : node.is_enabled === false ? ' / 已停用' : ''}`"
|
||
:value="node.node_code"
|
||
/>
|
||
</el-select>
|
||
<div class="field-help">
|
||
当前来源:{{ actionTargetNodeSourceLabel }}。{{ actionTargetNodes.length ? "可直接创建任务。" : "暂无可用节点,请先同步节点或检查集群心跳。" }}
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="执行方式">
|
||
<el-select v-model="actionTemplateForm.execution_mode" @change="handleActionTemplateExecutionModeChange">
|
||
<el-option
|
||
v-for="mode in selectedActionTemplateModeOptions"
|
||
:key="mode.value"
|
||
:label="mode.label"
|
||
:value="mode.value"
|
||
:disabled="Boolean(mode.disabled)"
|
||
/>
|
||
</el-select>
|
||
<div class="field-help">{{ actionTemplateExecutionModeHelpText }}</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="自动审批">
|
||
<el-switch v-model="actionTemplateForm.auto_approve" active-text="开启" inactive-text="关闭" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div v-if="selectedActionTemplate.key" class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>{{ selectedActionTemplate.title || "-" }}</strong>
|
||
<el-tag :type="riskType(selectedActionTemplate.risk_level || 'low')" effect="plain" size="small">
|
||
{{ selectedActionTemplate.risk_level || "-" }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ selectedActionTemplate.description || "-" }}</div>
|
||
<div class="preview-list__item">目标角色:{{ (selectedActionTemplate.target_roles || []).join(" / ") || "-" }}</div>
|
||
<div class="preview-list__item">默认执行:{{ selectedActionTemplateDefaultExecutionModeLabel }}</div>
|
||
<div class="preview-list__item">可选执行:{{ selectedActionTemplateModesText || "-" }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedActionTemplateFields.length" class="dialog-grid">
|
||
<el-form-item
|
||
v-for="field in selectedActionTemplateFields"
|
||
:key="field.key"
|
||
:label="field.label || field.key"
|
||
:class="field.wide ? 'full-width' : ''"
|
||
>
|
||
<el-select
|
||
v-if="field.type === 'select'"
|
||
v-model="actionTemplateForm.payload[field.key]"
|
||
:placeholder="field.help || '请选择'"
|
||
>
|
||
<el-option
|
||
v-for="option in field.options || []"
|
||
:key="option.value"
|
||
:label="option.label || option.value"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
<el-input-number
|
||
v-else-if="field.type === 'number'"
|
||
v-model="actionTemplateForm.payload[field.key]"
|
||
:min="field.min ?? 0"
|
||
:max="field.max ?? 9999"
|
||
/>
|
||
<el-switch
|
||
v-else-if="field.type === 'boolean'"
|
||
v-model="actionTemplateForm.payload[field.key]"
|
||
active-text="开启"
|
||
inactive-text="关闭"
|
||
/>
|
||
<el-input
|
||
v-else-if="field.type === 'textarea' || field.type === 'text_list'"
|
||
v-model="actionTemplateForm.payload[field.key]"
|
||
type="textarea"
|
||
:rows="field.rows || (field.type === 'text_list' ? 3 : 2)"
|
||
:placeholder="field.help || '请输入'"
|
||
/>
|
||
<el-input
|
||
v-else
|
||
v-model="actionTemplateForm.payload[field.key]"
|
||
:placeholder="field.help || '请输入'"
|
||
/>
|
||
<div v-if="field.help" class="field-help">{{ field.help }}</div>
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div v-if="actionTemplatePreviewHasData" class="result-panel" style="margin-top: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>动作预检</strong>
|
||
<el-tag :type="actionTemplatePreviewStatusType" size="small">{{ actionTemplatePreviewStatusLabel }}</el-tag>
|
||
</div>
|
||
|
||
<el-alert
|
||
:closable="false"
|
||
show-icon
|
||
:type="actionTemplatePreviewStatusType"
|
||
style="margin-bottom: 12px"
|
||
>
|
||
<template #title>预检结论</template>
|
||
{{ actionTemplatePreviewSummaryText }}
|
||
</el-alert>
|
||
|
||
<el-descriptions :column="4" border size="small">
|
||
<el-descriptions-item label="目标节点">{{ actionTemplatePreviewSummary.nodes_total || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="阻断节点">{{ actionTemplatePreviewSummary.blocked_nodes || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="建议审批">{{ actionTemplatePreviewSummary.approval_nodes || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="需关注">{{ actionTemplatePreviewSummary.warning_nodes || 0 }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="preview-grid preview-grid--stack" style="margin-top: 14px">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">阻断项</div>
|
||
<div class="preview-list preview-list--danger">
|
||
<div v-for="item in actionTemplatePreviewBlockingReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!actionTemplatePreviewBlockingReasons.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">审批建议</div>
|
||
<div class="preview-list preview-list--warning">
|
||
<div v-for="item in actionTemplatePreviewApprovalReasons" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!actionTemplatePreviewApprovalReasons.length" class="preview-empty">当前不要求额外审批</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">注意事项</div>
|
||
<div class="preview-list preview-list--warning">
|
||
<div v-for="item in actionTemplatePreviewWarnings" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!actionTemplatePreviewWarnings.length" class="preview-empty">无</div>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">建议动作</div>
|
||
<div class="preview-list">
|
||
<div v-for="item in actionTemplatePreviewRecommendations" :key="item" class="preview-list__item">{{ item }}</div>
|
||
<div v-if="!actionTemplatePreviewRecommendations.length" class="preview-empty">当前无额外建议</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
:data="actionTemplatePreviewRows"
|
||
stripe
|
||
size="small"
|
||
max-height="260"
|
||
empty-text="当前没有节点级预检结果"
|
||
style="margin-top: 14px"
|
||
>
|
||
<el-table-column prop="node_code" label="节点" min-width="180" />
|
||
<el-table-column label="角色 / 状态" min-width="140">
|
||
<template #default="{ row }">
|
||
{{ row.role || "-" }} / {{ row.status || "-" }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="预检结论" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="actionTemplateNodePreviewTagType(row)" effect="plain" size="small">
|
||
{{ actionTemplateNodePreviewLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="原因" min-width="320">
|
||
<template #default="{ row }">
|
||
{{ actionTemplateNodePreviewReasonText(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="actionTemplateVisible = false">关闭</el-button>
|
||
<el-button plain :loading="loading.actionTemplatePreview" @click="previewActionTemplateExecution">预检动作</el-button>
|
||
<el-button type="primary" :loading="loading.batchJobCreate" @click="createActionTemplateJobs">创建任务</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="playbookVisible" title="发起标准编排" width="920px">
|
||
<el-form label-position="top" :model="playbookForm" class="dialog-form">
|
||
<div class="dialog-grid">
|
||
<el-form-item label="Playbook" class="full-width">
|
||
<el-select v-model="playbookForm.playbook_key" placeholder="选择 playbook" @change="handlePlaybookChange">
|
||
<el-option
|
||
v-for="playbook in playbooks"
|
||
:key="playbook.key"
|
||
:label="`${playbook.title} (${playbook.group_title})`"
|
||
:value="playbook.key"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="目标节点" class="full-width">
|
||
<el-select v-model="playbookForm.target_node_codes" multiple filterable placeholder="至少选择一个节点">
|
||
<el-option
|
||
v-for="node in playbookTargetNodes"
|
||
:key="node.node_code"
|
||
:label="`${node.node_code} / ${node.region} / ${node.role}${node.metadata?.imported_from_cluster_fallback ? ' / 未纳管' : node.is_enabled === false ? ' / 已停用' : ''}`"
|
||
:value="node.node_code"
|
||
/>
|
||
</el-select>
|
||
<div class="field-help">
|
||
当前来源:{{ playbookTargetNodeSourceLabel }}。{{ playbookTargetNodes.length ? "后端会把 playbook 展开成标准 ops jobs。" : "暂无可用节点,请先同步节点或检查集群心跳。" }}
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="执行方式">
|
||
<el-select v-model="playbookForm.execution_mode" @change="handlePlaybookExecutionModeChange">
|
||
<el-option
|
||
v-for="mode in selectedPlaybookModeOptions"
|
||
:key="mode.value"
|
||
:label="mode.label"
|
||
:value="mode.value"
|
||
:disabled="Boolean(mode.disabled)"
|
||
/>
|
||
</el-select>
|
||
<div class="field-help">{{ playbookExecutionModeHelpText }}</div>
|
||
</el-form-item>
|
||
|
||
<el-form-item label="自动审批">
|
||
<el-switch v-model="playbookForm.auto_approve" active-text="开启" inactive-text="关闭" />
|
||
</el-form-item>
|
||
</div>
|
||
|
||
<div v-if="selectedPlaybook.key" class="result-panel" style="margin-bottom: 16px">
|
||
<div class="result-panel__head">
|
||
<strong>{{ selectedPlaybook.title || "-" }}</strong>
|
||
<el-tag type="success" effect="plain" size="small">{{ (selectedPlaybook.steps || []).length }} 步</el-tag>
|
||
</div>
|
||
<div class="preview-list">
|
||
<div class="preview-list__item">{{ selectedPlaybook.description || "-" }}</div>
|
||
<div class="preview-list__item">分组:{{ selectedPlaybook.group_title || "-" }}</div>
|
||
<div class="preview-list__item">默认执行:{{ selectedPlaybookDefaultExecutionModeLabel }}</div>
|
||
<div class="preview-list__item">可选执行:{{ selectedPlaybookModesText || "-" }}</div>
|
||
<div class="preview-list__item">当前推荐:{{ playbookExecutionModeRecommendation.recommendedModeLabel }}</div>
|
||
<div class="preview-list__item">执行策略:{{ selectedPlaybook.stop_on_failure ? "任一步失败即停止后续展开" : "允许继续展开" }}</div>
|
||
</div>
|
||
<div class="preview-chip-list" style="margin-top: 10px">
|
||
<el-tag v-for="title in selectedPlaybookStepTitles" :key="title" size="small" effect="plain">{{ title }}</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="playbookPreviewHasData" class="result-panel">
|
||
<div class="result-panel__head">
|
||
<strong>Playbook 预览</strong>
|
||
<el-tag type="warning" size="small">{{ playbookPreview.expected_jobs_total || 0 }} 个预期 jobs</el-tag>
|
||
</div>
|
||
<el-descriptions :column="4" border size="small">
|
||
<el-descriptions-item label="目标节点">{{ playbookPreview.target_nodes_total || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="步骤数">{{ playbookPreview.step_count || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="预期任务">{{ playbookPreview.expected_jobs_total || 0 }}</el-descriptions-item>
|
||
<el-descriptions-item label="执行方式">{{ playbookPreview.execution_mode_label || executionModeLabel(playbookPreview.execution_mode || playbookForm.execution_mode) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="preview-grid">
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">目标节点</div>
|
||
<div class="preview-chip-list">
|
||
<el-tag v-for="nodeCode in playbookPreview.target_node_codes || []" :key="nodeCode" size="small" effect="plain">{{ nodeCode }}</el-tag>
|
||
<span v-if="!(playbookPreview.target_node_codes || []).length" class="preview-empty">暂无</span>
|
||
</div>
|
||
</div>
|
||
<div class="preview-box">
|
||
<div class="preview-box__title">展开步骤</div>
|
||
<div class="preview-list">
|
||
<div v-for="step in playbookPreview.steps || []" :key="`${step.step_key}-${step.template_key}`" class="preview-list__item">
|
||
{{ step.order }}. {{ step.title || step.template_key }} -> {{ step.template_key }}
|
||
</div>
|
||
<div v-if="!(playbookPreview.steps || []).length" class="preview-empty">暂无</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="playbookVisible = false">关闭</el-button>
|
||
<el-button plain :loading="loading.playbookPreview" @click="previewPlaybookExecution">预览编排</el-button>
|
||
<el-button type="primary" :loading="loading.playbookExecute" @click="createPlaybookJobs">创建任务</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</PageCard>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import PageCard from "@/components/PageCard.vue";
|
||
import { opsApi, settingsApi } from "@/api/modules";
|
||
|
||
type GenericRecord = Record<string, any>;
|
||
|
||
const OPS_JOB_FETCH_LIMIT = 80;
|
||
const OPS_JOB_TABLE_LIMIT = 20;
|
||
const INSPECTION_ACTION_KEYS = ["health.snapshot", "logs.collect", "diagnostics.collect"] as const;
|
||
|
||
const loading = ref({
|
||
all: false,
|
||
nodesSync: false,
|
||
deliveryQueue: false,
|
||
deliveryQueueActionKey: "",
|
||
nodeSave: false,
|
||
driverActionKey: "",
|
||
driverActionContractPreview: false,
|
||
jobActionKey: "",
|
||
jobDetail: false,
|
||
rolloutActionKey: "",
|
||
fleetActionKey: "",
|
||
onboardingActionKey: "",
|
||
onboardingSequence: false,
|
||
onboardingRecoveryPreview: false,
|
||
onboardingRecoveryExecute: false,
|
||
nodeOpsSequence: false,
|
||
tokenIssue: false,
|
||
releaseCreate: false,
|
||
releaseCreateAndRollout: false,
|
||
releasePackageSmartRolloutKey: "",
|
||
releaseSmartRolloutKey: "",
|
||
releaseLaunchpad: false,
|
||
releaseLaunchpadActionKey: "",
|
||
releasePackageMetadata: false,
|
||
releasePackageImport: false,
|
||
releaseActionKey: "",
|
||
rolloutCreate: false,
|
||
rolloutPreview: false,
|
||
rolloutPreviewActionKey: "",
|
||
playbookPreview: false,
|
||
playbookRunDetail: false,
|
||
playbookExecute: false,
|
||
playbookRunActionKey: "",
|
||
batchJobCreate: false,
|
||
actionTemplatePreview: false,
|
||
agentOnboardingRefresh: false,
|
||
contractDetail: false,
|
||
sceneNodeLog: false
|
||
});
|
||
|
||
const overview = ref<GenericRecord>({});
|
||
const goLiveSummaryPayload = ref<GenericRecord>({});
|
||
const doctorDecisionPayload = ref<GenericRecord>({});
|
||
const goLiveSignoffPayload = ref<GenericRecord>({});
|
||
const goLiveBundlePayload = ref<GenericRecord>({});
|
||
const goLiveReviewPayload = ref<GenericRecord>({});
|
||
const stackDiagnosisPayload = ref<GenericRecord>({});
|
||
const linkSnapshotPayload = ref<GenericRecord>({});
|
||
const nodes = ref<GenericRecord[]>([]);
|
||
const nodesSummary = ref<GenericRecord>({});
|
||
const jobs = ref<GenericRecord[]>([]);
|
||
const releases = ref<GenericRecord[]>([]);
|
||
const rollouts = ref<GenericRecord[]>([]);
|
||
const latestRelease = ref<GenericRecord>({});
|
||
const latestReleasePackage = ref<GenericRecord>({});
|
||
const releaseLaunchpad = ref<GenericRecord>({});
|
||
const selectedReleaseId = ref<number | null>(null);
|
||
const rolloutTargetReleaseId = ref<number | null>(null);
|
||
const workerLogSyncLoading = ref(false);
|
||
const pendingWorkerLogMode = ref<"off" | "key" | "full">("off");
|
||
const remoteLogDrawerVisible = ref(false);
|
||
const remoteLogSelectedNodeCode = ref("");
|
||
const remoteLogNodeDetail = ref<GenericRecord>({});
|
||
const opsContractDetailVisible = ref(false);
|
||
const managedNodeDeliveryQueueVisible = ref(false);
|
||
const managedNodeVisible = ref(false);
|
||
const managedNodeEditing = ref(false);
|
||
const managedNodePostAction = ref<"" | "bootstrap-plan">("");
|
||
const driverActionContractVisible = ref(false);
|
||
const jobDetailVisible = ref(false);
|
||
const jobEventsVisible = ref(false);
|
||
const rolloutJobsVisible = ref(false);
|
||
const agentTokenVisible = ref(false);
|
||
const releaseDialogVisible = ref(false);
|
||
const rolloutDialogVisible = ref(false);
|
||
const actionTemplateVisible = ref(false);
|
||
const playbookVisible = ref(false);
|
||
const playbookRunDetailVisible = ref(false);
|
||
const rolloutSmartPrefillHint = ref("");
|
||
const activeJob = ref<GenericRecord | null>(null);
|
||
const activeRollout = ref<GenericRecord | null>(null);
|
||
const activePlaybookRun = ref<GenericRecord | null>(null);
|
||
const activeManagedNodeDeliveryQueue = ref<GenericRecord>({});
|
||
const activeDriverActionContractPreview = ref<GenericRecord | null>(null);
|
||
const opsCockpitActionFeedback = ref<GenericRecord>({
|
||
visible: false,
|
||
type: "info",
|
||
title: "",
|
||
detail: "",
|
||
});
|
||
const jobEvents = ref<GenericRecord[]>([]);
|
||
const jobEventsSummary = ref<GenericRecord>({});
|
||
const rolloutJobs = ref<GenericRecord[]>([]);
|
||
const managedNodeDeliveryQueueRecordsPayload = ref<GenericRecord>({});
|
||
const playbookRunEventsPayload = ref<GenericRecord>({});
|
||
const capabilitiesState = ref<GenericRecord>({});
|
||
const contractsPayload = ref<GenericRecord>({});
|
||
const opsContractDetailPayload = ref<GenericRecord>({});
|
||
const blueprintPayload = ref<GenericRecord>({});
|
||
const runbookPayload = ref<GenericRecord>({});
|
||
const actionTemplatesPayload = ref<GenericRecord>({});
|
||
const playbooksPayload = ref<GenericRecord>({});
|
||
const actionTemplateExecutionModeTouched = ref(false);
|
||
const playbookExecutionModeTouched = ref(false);
|
||
const playbookRunsPayload = ref<GenericRecord>({});
|
||
const activityStreamPayload = ref<GenericRecord>({});
|
||
const codexBriefPayload = ref<GenericRecord>({});
|
||
const driverFeedPayload = ref<GenericRecord>({});
|
||
const inspectionPayloadState = ref<GenericRecord>({});
|
||
const lastIssuedToken = ref<GenericRecord>({});
|
||
const agentBootstrapPlan = ref<GenericRecord>({});
|
||
const agentHandoverPayload = ref<GenericRecord>({});
|
||
const onboardingState = ref<GenericRecord>({});
|
||
const rolloutPreview = ref<GenericRecord>({});
|
||
const actionTemplatePreview = ref<GenericRecord>({});
|
||
const playbookPreview = ref<GenericRecord>({});
|
||
const focusedRunbookSequenceKey = ref("");
|
||
const selectedOpsContractKey = ref("");
|
||
let focusedRunbookSequenceTimer: ReturnType<typeof window.setTimeout> | null = null;
|
||
|
||
const managedNodeForm = ref({
|
||
node_code: "",
|
||
title: "",
|
||
region: "mainland",
|
||
role: "worker",
|
||
ssh_host: "",
|
||
ssh_port: 22,
|
||
ssh_user: "root",
|
||
auth_mode: "key",
|
||
deploy_channel: "stable",
|
||
is_enabled: true,
|
||
metadata: {} as GenericRecord
|
||
});
|
||
|
||
const agentTokenForm = ref({
|
||
node_code: "",
|
||
expires_in_hours: 72,
|
||
issued_by: "web-ui",
|
||
node_role: "",
|
||
node_region: "",
|
||
control_plane_base_url: "",
|
||
root_dir: "/opt/domaincheck"
|
||
});
|
||
|
||
const releaseForm = ref({
|
||
release_version: "",
|
||
channel: "stable",
|
||
status: "ready",
|
||
commit_sha: "",
|
||
artifact_url: "",
|
||
checksum: "",
|
||
notes: "",
|
||
metadata: {} as GenericRecord,
|
||
});
|
||
|
||
const rolloutForm = ref({
|
||
node_codes_text: "",
|
||
region: "",
|
||
role: "",
|
||
only_enabled: true,
|
||
only_online: true,
|
||
only_effective_workers: false,
|
||
execution_mode: "remote-agent",
|
||
first_batch_size: 1,
|
||
batch_size: 2,
|
||
auto_start: true,
|
||
auto_dispatch: false,
|
||
auto_advance: true,
|
||
auto_approve: false,
|
||
continue_on_failure: false,
|
||
continue_on_partial: false,
|
||
restart_services_text: "domaincheck-api\ndomaincheck-worker",
|
||
health_check_urls_text: "http://127.0.0.1:8100/health",
|
||
health_check_services_text: "domaincheck-api\ndomaincheck-worker",
|
||
health_check_timeout_seconds: 10,
|
||
health_check_retries: 2,
|
||
health_check_interval_seconds: 2,
|
||
rollback_on_failure: true,
|
||
switch_current: true
|
||
});
|
||
|
||
const actionTemplateForm = ref({
|
||
template_key: "",
|
||
target_node_codes: [] as string[],
|
||
execution_mode: "remote-agent",
|
||
auto_approve: false,
|
||
payload: {} as GenericRecord
|
||
});
|
||
|
||
const playbookForm = ref({
|
||
playbook_key: "",
|
||
target_node_codes: [] as string[],
|
||
execution_mode: "remote-agent",
|
||
auto_approve: true
|
||
});
|
||
|
||
const playbookRunFilters = ref({
|
||
status: "",
|
||
group_key: "",
|
||
query: "",
|
||
});
|
||
|
||
const playbookRunEventFilters = ref({
|
||
step_key: "",
|
||
node_code: "",
|
||
});
|
||
|
||
const activityStreamFilters = ref({
|
||
kind: "",
|
||
status: "",
|
||
execution_mode: "",
|
||
query: "",
|
||
});
|
||
|
||
const managedNodeDeliveryQueueFilters = ref({
|
||
state: "",
|
||
limit: 20,
|
||
});
|
||
|
||
const executionSceneFilters = ref({
|
||
state_group: "",
|
||
role: "",
|
||
});
|
||
|
||
const inspectionFilters = ref({
|
||
status: "",
|
||
problem_kind: "",
|
||
query: "",
|
||
only_problem: false,
|
||
only_participating: false,
|
||
});
|
||
|
||
const managedNodeDialogTitle = computed(() => {
|
||
if (managedNodeEditing.value) {
|
||
return "编辑托管节点";
|
||
}
|
||
if (managedNodePostAction.value === "bootstrap-plan") {
|
||
return "纳管节点并生成接入方案";
|
||
}
|
||
return "新增托管节点";
|
||
});
|
||
|
||
const managedNodesSummary = computed(() => ({
|
||
total: Number(nodesSummary.value.total || overview.value.managed_nodes?.total || nodes.value.length || 0),
|
||
managedTotal: Number(
|
||
nodesSummary.value.managed_total ||
|
||
overview.value.managed_nodes?.managed_total ||
|
||
nodes.value.filter((item) => Boolean(item.is_managed)).length ||
|
||
0
|
||
),
|
||
enabled: Number(overview.value.managed_nodes?.enabled || nodes.value.filter((item) => Boolean(item.is_enabled)).length || 0),
|
||
online: Number(nodesSummary.value.online || overview.value.managed_nodes?.online || 0),
|
||
stale: Number(nodesSummary.value.stale || overview.value.managed_nodes?.stale || 0),
|
||
pendingBootstrap: Number(nodesSummary.value.pending_bootstrap || overview.value.managed_nodes?.pending_bootstrap || 0),
|
||
runtimeOnly: Number(nodesSummary.value.runtime_only || overview.value.managed_nodes?.runtime_only || 0),
|
||
unmanaged: Number(nodesSummary.value.unmanaged || overview.value.managed_nodes?.unmanaged || 0),
|
||
tokenIssue: Number(nodesSummary.value.token_issue || overview.value.managed_nodes?.token_issue || 0),
|
||
queueRetryingNodes: Number(
|
||
nodesSummary.value.queue_retrying_nodes || overview.value.managed_nodes?.queue_retrying_nodes || 0
|
||
),
|
||
queueDeadLetterNodes: Number(
|
||
nodesSummary.value.queue_dead_letter_nodes || overview.value.managed_nodes?.queue_dead_letter_nodes || 0
|
||
),
|
||
queuePendingRecords: Number(
|
||
nodesSummary.value.queue_pending_records || overview.value.managed_nodes?.queue_pending_records || 0
|
||
),
|
||
queueDeadLetterRecords: Number(
|
||
nodesSummary.value.queue_dead_letter_records || overview.value.managed_nodes?.queue_dead_letter_records || 0
|
||
),
|
||
participating: Number(executionParticipationSummary.value.participating_nodes || 0),
|
||
standby: Number(executionParticipationSummary.value.non_participating_nodes || 0)
|
||
}));
|
||
|
||
const managedNodeDeliveryQueueNode = computed<GenericRecord>(() => activeManagedNodeDeliveryQueue.value.node || {});
|
||
const managedNodeDeliveryQueueSummary = computed<GenericRecord>(() => activeManagedNodeDeliveryQueue.value.summary || {});
|
||
const managedNodeDeliveryQueueCapabilities = computed<GenericRecord>(
|
||
() => activeManagedNodeDeliveryQueue.value.records_capability || {}
|
||
);
|
||
const managedNodeDeliveryQueueHeadRecords = computed<GenericRecord>(() => activeManagedNodeDeliveryQueue.value.head_records || {});
|
||
const managedNodeDeliveryQueueHeadPending = computed<GenericRecord>(() => managedNodeDeliveryQueueHeadRecords.value.pending || {});
|
||
const managedNodeDeliveryQueueHeadDeadLetter = computed<GenericRecord>(() => managedNodeDeliveryQueueHeadRecords.value.dead_letter || {});
|
||
const managedNodeDeliveryQueueRecords = computed<GenericRecord[]>(() => managedNodeDeliveryQueueRecordsPayload.value.records || []);
|
||
const managedNodeDeliveryQueueRecordsSummary = computed<GenericRecord>(
|
||
() => managedNodeDeliveryQueueRecordsPayload.value.summary || {}
|
||
);
|
||
const managedNodeDeliveryQueueSummaryType = computed(() => {
|
||
const state = String(managedNodeDeliveryQueueSummary.value.state || "").trim();
|
||
if (state === "dead_letter") return "danger";
|
||
if (state === "retrying") return "warning";
|
||
if (state === "healthy") return "success";
|
||
return "info";
|
||
});
|
||
const managedNodeDeliveryQueueRecordVisibilityLabel = computed(() => {
|
||
const visibility =
|
||
String(managedNodeDeliveryQueueRecordsSummary.value.record_visibility || managedNodeDeliveryQueueCapabilities.value.record_visibility || "").trim() ||
|
||
"head_only";
|
||
if (visibility === "head_only") return "仅头部记录";
|
||
return visibility;
|
||
});
|
||
|
||
const clusterSummary = computed(() => ({
|
||
onlineControlNodes: Number(overview.value.cluster_summary?.online_control_nodes || 0),
|
||
onlineWorkerNodes: Number(overview.value.cluster_summary?.online_worker_nodes || 0)
|
||
}));
|
||
const clusterNodes = computed<GenericRecord[]>(() => overview.value.cluster_nodes || []);
|
||
|
||
const jobsSummary = computed(() => ({
|
||
total: Number(overview.value.ops_jobs?.total || 0),
|
||
queued: Number(overview.value.ops_jobs?.queued || 0),
|
||
running: Number(overview.value.ops_jobs?.running || 0),
|
||
awaitingApproval: Number(overview.value.ops_jobs?.awaiting_approval || 0)
|
||
}));
|
||
|
||
const jobsTableRows = computed(() => jobs.value.slice(0, OPS_JOB_TABLE_LIMIT));
|
||
|
||
const releaseSummary = computed(() => ({
|
||
total: Number(overview.value.release_hub?.total || 0),
|
||
rolloutsTotal: Number(overview.value.release_hub?.rollouts_total || 0)
|
||
}));
|
||
const releaseLaunchpadStatus = computed<GenericRecord>(() => releaseLaunchpad.value.launchpad_status || {});
|
||
const releaseLaunchpadLatestPackage = computed<GenericRecord>(() => releaseLaunchpad.value.latest_package || {});
|
||
const releaseLaunchpadLatestRelease = computed<GenericRecord>(() => releaseLaunchpad.value.latest_release || {});
|
||
const releaseLaunchpadPackagePreview = computed<GenericRecord>(() => releaseLaunchpad.value.latest_package_preview || {});
|
||
const releaseLaunchpadWorkerPreview = computed<GenericRecord>(() => releaseLaunchpad.value.worker_rollout_preview || {});
|
||
const releaseLaunchpadControlPreview = computed<GenericRecord>(() => releaseLaunchpad.value.control_rollout_preview || {});
|
||
const releaseLaunchpadStatusType = computed(() => {
|
||
const status = String(releaseLaunchpadStatus.value.status || "").trim();
|
||
if (status === "ready") return "success";
|
||
if (status === "blocked") return "danger";
|
||
if (status === "attention") return "warning";
|
||
return "info";
|
||
});
|
||
const releaseLaunchpadStatusLabel = computed(() => String(releaseLaunchpadStatus.value.status_label || "").trim() || "待评估");
|
||
const releaseLaunchpadSummaryText = computed(
|
||
() => String(releaseLaunchpadStatus.value.summary_text || releaseLaunchpadStatus.value.summary || "").trim() || "当前还没有生成发布驾驶舱结论。"
|
||
);
|
||
const releaseLaunchpadExecutionModeLabel = computed(
|
||
() => String(releaseLaunchpadStatus.value.recommended_execution_mode_label || "").trim() || "-"
|
||
);
|
||
const releaseLaunchpadRecommendedCommand = computed(
|
||
() => String(releaseLaunchpadStatus.value.recommended_command || "").trim()
|
||
);
|
||
const releaseLaunchpadRecommendedActionCode = computed(
|
||
() => String(releaseLaunchpadStatus.value.recommended_action_code || "").trim()
|
||
);
|
||
const releaseLaunchpadActionLabel = computed(() => {
|
||
const actionCode = releaseLaunchpadRecommendedActionCode.value;
|
||
if (actionCode === "api-restart") return "刷新控制面";
|
||
if (actionCode === "release_prepare") return "执行最终签收";
|
||
if (actionCode === "publish_latest_worker") return "一键 Worker 灰度";
|
||
if (actionCode === "review_smart_rollout_preview") return "检查 Worker 预案";
|
||
if (actionCode === "review_control_rollout") return "检查 Control 预案";
|
||
if (actionCode === "fix_rollout_blockers") return "查看 Rollout 门禁";
|
||
if (actionCode === "fix_managed_nodes") return "查看节点接管";
|
||
if (actionCode === "bootstrap_run") return "执行接入收口";
|
||
if (actionCode === "run_acceptance") return "执行接管验收";
|
||
if (actionCode === "release_package") return "打开 Release";
|
||
return "";
|
||
});
|
||
const releaseLaunchpadActionRunnable = computed(() =>
|
||
[
|
||
"api-restart",
|
||
"release_prepare",
|
||
"publish_latest_worker",
|
||
"review_smart_rollout_preview",
|
||
"review_control_rollout",
|
||
"fix_rollout_blockers",
|
||
"fix_managed_nodes",
|
||
"bootstrap_run",
|
||
"run_acceptance",
|
||
"release_package",
|
||
].includes(releaseLaunchpadRecommendedActionCode.value)
|
||
);
|
||
const releaseGate = computed<GenericRecord>(() => overview.value.release_hub?.default_rollout_gate || {});
|
||
const defaultRolloutExecution = computed<GenericRecord>(() => overview.value.release_hub?.default_rollout_execution || {});
|
||
const releaseGateRelease = computed<GenericRecord>(() => releaseGate.value.release || {});
|
||
const releaseGateStatusLabel = computed(() => String(releaseGate.value.status_label || "").trim() || "门禁未评估");
|
||
const releaseGateStatusType = computed(() => String(releaseGate.value.status_type || "").trim() || "info");
|
||
const releaseGateExecutionModeLabel = computed(
|
||
() => String(releaseGate.value.execution_mode_label || releaseGate.value.execution_mode || "").trim() || "执行方式未定"
|
||
);
|
||
const releaseGateReadinessSummary = computed<GenericRecord>(() => (releaseGate.value.operational_readiness || {}).summary || {});
|
||
const releaseGateReadinessRows = computed<GenericRecord[]>(() => (releaseGate.value.operational_readiness || {}).rows || []);
|
||
const releaseGateBlockingReasons = computed<string[]>(() => releaseGate.value.blocking_reasons || []);
|
||
const releaseGateWarningReasons = computed<string[]>(() => releaseGate.value.warning_reasons || []);
|
||
const releaseGateRecommendations = computed<string[]>(() => releaseGate.value.recommendations || []);
|
||
const releaseGateTargetNodeCodes = computed<string[]>(() => releaseGate.value.default_target_node_codes || releaseGate.value.target_node_codes || []);
|
||
const releaseGateSummaryText = computed(() => {
|
||
const summary = String(releaseGate.value.summary_text || releaseGate.value.summary || "").trim();
|
||
if (summary) {
|
||
return summary;
|
||
}
|
||
const readiness = releaseGateReadinessSummary.value || {};
|
||
const nodesTotal = Number(readiness.nodes_total || 0);
|
||
if (nodesTotal > 0) {
|
||
return `${releaseGateExecutionModeLabel.value} ${Number(readiness.execution_ready_nodes || 0)}/${nodesTotal} / 巡检通过 ${Number(readiness.inspection_healthy_nodes || 0)}/${nodesTotal}`;
|
||
}
|
||
return "当前还没有默认 Rollout 门禁摘要";
|
||
});
|
||
|
||
const topology = computed(() => overview.value.current_topology || {});
|
||
const recommendation = computed(() => overview.value.recommendation || {});
|
||
const recommendationLevelLabel = computed(() => String(recommendation.value.level_label || "").trim());
|
||
const recommendationTagType = computed(() => String(recommendation.value.tag_type || "").trim() || "info");
|
||
const recommendationPrimaryLabel = computed(() => String(recommendation.value.primary_label || "").trim());
|
||
const recommendationSecondaryLabel = computed(() => String(recommendation.value.secondary_label || "").trim());
|
||
const recommendationPrimaryType = computed(() => {
|
||
const tagType = recommendationTagType.value;
|
||
if (["danger", "warning", "success", "primary"].includes(tagType)) {
|
||
return tagType;
|
||
}
|
||
return "primary";
|
||
});
|
||
const opsLinkSnapshot = computed<GenericRecord>(() => linkSnapshotPayload.value || {});
|
||
const opsLinkSnapshotStatusLabel = computed(() => {
|
||
const status = String(opsLinkSnapshot.value.status || "").trim();
|
||
if (status === "blocking") return "阻断";
|
||
if (status === "attention") return "待处理";
|
||
if (status === "ready") return "就绪";
|
||
return status || "未知";
|
||
});
|
||
const opsLinkSnapshotStatusType = computed(() => {
|
||
const status = String(opsLinkSnapshot.value.status || "").trim();
|
||
if (status === "blocking") return "danger";
|
||
if (status === "attention") return "warning";
|
||
if (status === "ready") return "success";
|
||
return "info";
|
||
});
|
||
const opsLinkSnapshotHeadline = computed(
|
||
() => String(opsLinkSnapshot.value.headline || "").trim() || "当前还没有联调快照结论。"
|
||
);
|
||
const opsLinkSnapshotSummaryLines = computed<string[]>(() =>
|
||
(opsLinkSnapshot.value.summary_lines || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const opsLinkSnapshotActions = computed<GenericRecord[]>(() => opsLinkSnapshot.value.next_actions || []);
|
||
const opsLinkSnapshotPreviewLines = computed<string[]>(() =>
|
||
(opsLinkSnapshot.value.log_sync?.preview_lines || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const executionScene = computed<GenericRecord>(() => overview.value.execution_scene || {});
|
||
const executionParticipationSummary = computed<GenericRecord>(() => executionScene.value.participation_summary || {});
|
||
const executionParticipatingNodes = computed<GenericRecord[]>(() => executionScene.value.participating_nodes || []);
|
||
const executionStandbyNodes = computed<GenericRecord[]>(() => executionScene.value.standby_nodes || []);
|
||
const executionDispatchActiveNodes = computed<GenericRecord[]>(() =>
|
||
executionParticipatingNodes.value.filter((row) => Boolean(row?.is_dispatch_active))
|
||
);
|
||
const executionRecentOnlyNodes = computed<GenericRecord[]>(() =>
|
||
executionParticipatingNodes.value.filter((row) => String(row?.participation_state || "").trim() === "recent_throughput")
|
||
);
|
||
const executionLoadSyncingNodes = computed<GenericRecord[]>(() =>
|
||
executionStandbyNodes.value.filter((row) => String(row?.participation_state || "").trim() === "load_syncing")
|
||
);
|
||
const executionPureStandbyNodes = computed<GenericRecord[]>(() =>
|
||
executionStandbyNodes.value.filter((row) => String(row?.participation_state || "").trim() === "standby")
|
||
);
|
||
const OPS_MULTI_REGION_DIR = "domain-api/deploy/multi-region";
|
||
const buildOpsScriptCommand = (scriptName: string, ...args: Array<string | number>) =>
|
||
["bash", `${OPS_MULTI_REGION_DIR}/${String(scriptName || "").trim().replace(/^\/+/, "")}`]
|
||
.concat(args.map((item) => String(item ?? "").trim()).filter(Boolean))
|
||
.join(" ");
|
||
|
||
const buildFallbackOpsStackDiagnosisData = (): GenericRecord => {
|
||
const stackBaseUrl = String(controlPlaneBaseUrl.value || "").trim() || "http://127.0.0.1:8100";
|
||
const logSync = (executionScene.value.log_sync || {}) as GenericRecord;
|
||
const contractsAvailable = Boolean(opsContracts.value.length || contractsPayload.value.registry_version);
|
||
const linkSnapshotAvailable = Boolean(
|
||
String(opsLinkSnapshot.value.status || "").trim() ||
|
||
String(opsLinkSnapshot.value.headline || "").trim() ||
|
||
opsLinkSnapshotActions.value.length
|
||
);
|
||
const overviewAvailable = Boolean(Object.keys(overview.value || {}).length);
|
||
const managedNodesAvailable = Boolean(nodes.value.length || Object.keys(nodesSummary.value || {}).length);
|
||
const releaseHubAvailable = Boolean(
|
||
Object.keys(releaseLaunchpad.value || {}).length || Object.keys(releaseGate.value || {}).length
|
||
);
|
||
const playbookRunsAvailable = Boolean(Object.keys(playbookRunsPayload.value || {}).length);
|
||
const activityStreamAvailable = Boolean(Object.keys(activityStreamPayload.value || {}).length);
|
||
const surfaceItems = [
|
||
{ name: "api", available: true },
|
||
{ name: "contracts", available: contractsAvailable },
|
||
{ name: "link_snapshot", available: linkSnapshotAvailable },
|
||
{ name: "overview", available: overviewAvailable },
|
||
{ name: "managed_nodes", available: managedNodesAvailable },
|
||
{ name: "release_hub", available: releaseHubAvailable },
|
||
{ name: "playbook_runs", available: playbookRunsAvailable },
|
||
{ name: "activity_stream", available: activityStreamAvailable },
|
||
];
|
||
const missingSurfaces = surfaceItems.filter((item) => !item.available).map((item) => item.name);
|
||
const participatingNodesTotal = Number(
|
||
logSync.participating_nodes_total ||
|
||
logSync.participating_node_count ||
|
||
executionParticipationSummary.value.participating_nodes ||
|
||
executionParticipatingNodes.value.length ||
|
||
0
|
||
);
|
||
const issues: GenericRecord[] = [];
|
||
const addIssue = (
|
||
code: string,
|
||
severity: "blocked" | "warning" | "info",
|
||
layer: string,
|
||
summary: string,
|
||
detail = "",
|
||
actionCode = ""
|
||
) => {
|
||
issues.push({
|
||
code,
|
||
severity,
|
||
layer,
|
||
summary,
|
||
detail,
|
||
action_code: actionCode,
|
||
focus_ref: {},
|
||
commands: actionCode
|
||
? [buildOpsScriptCommand("drive_ops_center.sh", "driver-resolve", stackBaseUrl, actionCode)]
|
||
: [],
|
||
});
|
||
};
|
||
|
||
if (!contractsAvailable) {
|
||
addIssue(
|
||
"ops_contracts_unavailable",
|
||
"warning",
|
||
"contracts",
|
||
"页面还没有拿到正式 contract registry,当前总检由前端兜底拼装。",
|
||
"建议先恢复 /api/v1/ops/contracts 与 /api/v1/ops/stack-diagnosis。",
|
||
);
|
||
}
|
||
if (missingSurfaces.some((name) => ["link_snapshot", "overview", "managed_nodes", "release_hub"].includes(name))) {
|
||
addIssue(
|
||
"ops_surface_partial",
|
||
"warning",
|
||
"stack",
|
||
"Ops Center 关键观察层还没全部到位,当前只能给出部分总检结论。",
|
||
`缺失层:${missingSurfaces.join("、")}`,
|
||
);
|
||
}
|
||
if (!Boolean(logSync.enabled) && participatingNodesTotal > 0) {
|
||
addIssue(
|
||
"remote_log_sync_disabled",
|
||
"warning",
|
||
"overview.log_sync",
|
||
"现场已有参与检测节点,但远端日志回传仍关闭,海外控制面仍处于半盲态。",
|
||
`参与节点 ${participatingNodesTotal} 台,已覆盖 ${Number(logSync.covered_participating_nodes || logSync.covered_participating_node_count || 0)} 台。`,
|
||
"enable_log_sync_key"
|
||
);
|
||
}
|
||
if (String(releaseGate.value.status || "").trim() === "blocked") {
|
||
addIssue(
|
||
"release_rollout_gate_blocked",
|
||
"warning",
|
||
"release_hub",
|
||
"默认 Rollout 门禁仍处于阻断态,正式放量还不能直接开始。",
|
||
String(releaseGate.value.summary_text || releaseGate.value.summary || "").trim(),
|
||
String(releaseLaunchpad.value?.launchpad_status?.recommended_action_code || "").trim()
|
||
);
|
||
}
|
||
|
||
const orderedActionCandidates = [
|
||
{
|
||
action_code: String(recommendation.value.primary_action_code || "").trim(),
|
||
source: "overview.primary",
|
||
title: String(recommendation.value.primary_label || recommendation.value.title || "").trim(),
|
||
reason: String(recommendation.value.summary || recommendation.value.reason || "").trim(),
|
||
focus_ref: { ...(recommendation.value.focus_ref || {}) },
|
||
},
|
||
{
|
||
action_code: String(recommendation.value.secondary_action_code || "").trim(),
|
||
source: "overview.secondary",
|
||
title: String(recommendation.value.secondary_label || recommendation.value.title || "").trim(),
|
||
reason: String(recommendation.value.summary || recommendation.value.reason || "").trim(),
|
||
focus_ref: { ...(recommendation.value.focus_ref || {}) },
|
||
},
|
||
...(opsLinkSnapshotActions.value || []).map((action: GenericRecord, index: number) => ({
|
||
action_code: String(action?.action_code || "").trim(),
|
||
source: `link_snapshot:${index}`,
|
||
title: String(action?.label || action?.action_code || "").trim(),
|
||
reason: String(action?.reason || opsLinkSnapshotHeadline.value || "").trim(),
|
||
focus_ref: { ...(action?.focus_ref || {}) },
|
||
})),
|
||
...issues.map((issue) => ({
|
||
action_code: String(issue.action_code || "").trim(),
|
||
source: `issue:${String(issue.code || "").trim()}`,
|
||
title: String(issue.summary || "").trim(),
|
||
reason: String(issue.detail || issue.summary || "").trim(),
|
||
focus_ref: { ...(issue.focus_ref || {}) },
|
||
})),
|
||
];
|
||
|
||
const recommendedActions: GenericRecord[] = [];
|
||
const seenActionCodes = new Set<string>();
|
||
orderedActionCandidates.forEach((item) => {
|
||
const actionCode = String(item.action_code || "").trim();
|
||
if (!actionCode || seenActionCodes.has(actionCode)) {
|
||
return;
|
||
}
|
||
seenActionCodes.add(actionCode);
|
||
recommendedActions.push({
|
||
action_code: actionCode,
|
||
source: String(item.source || "").trim(),
|
||
title: String(item.title || actionCode).trim(),
|
||
reason: String(item.reason || "").trim(),
|
||
focus_ref: { ...(item.focus_ref || {}) },
|
||
command: buildOpsScriptCommand("drive_ops_center.sh", "driver-resolve", stackBaseUrl, actionCode),
|
||
});
|
||
});
|
||
|
||
const blockingIssueTotal = issues.filter((item) => String(item.severity || "").trim() === "blocked").length;
|
||
const warningIssueTotal = issues.filter((item) => String(item.severity || "").trim() === "warning").length;
|
||
const infoIssueTotal = issues.filter((item) => String(item.severity || "").trim() === "info").length;
|
||
const surfaceStatus = missingSurfaces.length ? "partial" : "healthy";
|
||
const automationStatus =
|
||
blockingIssueTotal > 0
|
||
? "blocked"
|
||
: warningIssueTotal > 0 || String(opsLinkSnapshot.value.status || "").trim() === "attention"
|
||
? "attention"
|
||
: "ready";
|
||
const stackStatus =
|
||
automationStatus === "blocked"
|
||
? "blocked"
|
||
: surfaceStatus !== "healthy" || automationStatus !== "ready"
|
||
? "attention"
|
||
: "ready";
|
||
const nextStep = recommendedActions[0]
|
||
? {
|
||
action_code: String(recommendedActions[0].action_code || "").trim(),
|
||
source: String(recommendedActions[0].source || "").trim(),
|
||
reason: String(recommendedActions[0].reason || "").trim(),
|
||
command: String(recommendedActions[0].command || "").trim(),
|
||
focus_ref: { ...(recommendedActions[0].focus_ref || {}) },
|
||
}
|
||
: {};
|
||
const quickCommands = [
|
||
buildOpsScriptCommand("drive_ops_center.sh", "stack-diagnosis", stackBaseUrl, "summary"),
|
||
buildOpsScriptCommand("drive_ops_center.sh", "doctor", stackBaseUrl),
|
||
...recommendedActions.slice(0, 2).map((item) => String(item.command || "").trim()).filter(Boolean),
|
||
].filter((item, index, values) => values.indexOf(item) === index);
|
||
const recommendedCommands = {
|
||
stack_summary: buildOpsScriptCommand("check_ops_center_stack.sh", stackBaseUrl, "summary"),
|
||
doctor: buildOpsScriptCommand("drive_ops_center.sh", "doctor", stackBaseUrl),
|
||
driver_feed: buildOpsScriptCommand("drive_ops_center.sh", "driver-feed", stackBaseUrl),
|
||
driver_focus_preview: buildOpsScriptCommand("drive_ops_center.sh", "driver-focus-preview", stackBaseUrl),
|
||
scene_log: buildOpsScriptCommand("drive_ops_center.sh", "scene-node-log", stackBaseUrl, "mainland-worker-01", 120, "key"),
|
||
release_launchpad: buildOpsScriptCommand("drive_release_hub.sh", stackBaseUrl, "launchpad"),
|
||
jobs: buildOpsScriptCommand("drive_ops_center.sh", "activity-stream", stackBaseUrl, "kind=ops_job"),
|
||
node_handover: buildOpsScriptCommand("drive_ops_center.sh", "stack-first-gap-handover", stackBaseUrl),
|
||
};
|
||
const operatorDecision = {
|
||
lane: "ops_jobs",
|
||
priority: stackStatus === "blocked" ? "high" : stackStatus === "attention" ? "medium" : "low",
|
||
reason_code: stackStatus === "blocked" ? "fallback_attention" : "fallback_ready",
|
||
title: stackStatus === "blocked" ? "先按总检建议收口阻断问题" : "当前进入稳定观察态",
|
||
summary:
|
||
stackStatus === "blocked"
|
||
? "当前 fallback 总检检测到仍有阻断问题,建议先按默认下一步和推荐动作收口。"
|
||
: stackStatus === "attention"
|
||
? "当前 fallback 总检仍有待处理缺口,建议先按默认下一步继续推进。"
|
||
: "当前 fallback 总检未发现新的阻断问题,可继续按既有链路观察与推进。",
|
||
next_focus: { ...(nextStep.focus_ref || {}) },
|
||
primary_command_key: stackStatus === "ready" ? "driver_feed" : "doctor",
|
||
secondary_command_key: stackStatus === "ready" ? "jobs" : "driver_focus_preview",
|
||
};
|
||
const nextActions = {
|
||
primary_command: String(recommendedCommands[operatorDecision.primary_command_key as keyof typeof recommendedCommands] || "").trim(),
|
||
secondary_command: String(recommendedCommands[operatorDecision.secondary_command_key as keyof typeof recommendedCommands] || "").trim(),
|
||
};
|
||
const operatorHints = [
|
||
stackStatus === "blocked"
|
||
? "当前总检仍有阻断问题,建议先处理 next_step,再继续发布或批量联调。"
|
||
: stackStatus === "attention"
|
||
? "当前总检已经可用,但仍有缺口;建议先按 next_step 收口,再进入细分面板。"
|
||
: "当前总检已经基本就绪,可以把它作为海外单脑控制面的固定起手式。",
|
||
...(opsLinkSnapshotSummaryLines.value || []).slice(0, 2),
|
||
].filter(Boolean);
|
||
|
||
return {
|
||
generated_at: String(opsLinkSnapshot.value.generated_at || "").trim(),
|
||
diagnosis: {
|
||
contract_key: "ops_stack_diagnosis_contract",
|
||
contract_version: String(contractVersionLabel(opsStackDiagnosisContract.value, "fallback")).trim(),
|
||
registry_version: String(opsContractRegistryVersion.value || "").trim(),
|
||
base_url: stackBaseUrl,
|
||
surface_status: surfaceStatus,
|
||
automation_status: automationStatus,
|
||
stack_status: stackStatus,
|
||
issue_total: issues.length,
|
||
blocking_issue_total: blockingIssueTotal,
|
||
warning_issue_total: warningIssueTotal,
|
||
info_issue_total: infoIssueTotal,
|
||
missing_surfaces: missingSurfaces,
|
||
next_step: nextStep,
|
||
recommended_actions: recommendedActions,
|
||
operator_decision: operatorDecision,
|
||
next_actions: nextActions,
|
||
issues,
|
||
operator_hints: operatorHints,
|
||
quick_commands: quickCommands,
|
||
recommended_commands: recommendedCommands,
|
||
},
|
||
api: {
|
||
http_status: "200",
|
||
available: true,
|
||
service: "domain-api",
|
||
},
|
||
surface_matrix: {
|
||
status_counts: {
|
||
total: surfaceItems.length,
|
||
available: surfaceItems.filter((item) => item.available).length,
|
||
missing: surfaceItems.filter((item) => !item.available).length,
|
||
},
|
||
items: surfaceItems,
|
||
},
|
||
contracts: {
|
||
http_status: contractsAvailable ? "200" : "fallback",
|
||
available: contractsAvailable,
|
||
registry_version: String(opsContractRegistryVersion.value || "").trim(),
|
||
schema_version: String(opsContractRegistrySchemaVersion.value || "").trim(),
|
||
contracts_total: Number(opsContractRegistryTotal.value || 0),
|
||
},
|
||
};
|
||
};
|
||
const opsStackDiagnosis = computed<GenericRecord>(() => {
|
||
if (Object.keys(stackDiagnosisPayload.value || {}).length) {
|
||
return stackDiagnosisPayload.value || {};
|
||
}
|
||
return buildFallbackOpsStackDiagnosisData();
|
||
});
|
||
const buildFallbackGoLiveSummaryData = (): GenericRecord => {
|
||
const stackStatus = String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim() || "unknown";
|
||
const routeSurfaceComplete = Boolean(opsStackDiagnosisRuntimeRouteSurface.value.surface_complete);
|
||
const launchpadStatus = String(releaseLaunchpadStatus.value.status || "").trim();
|
||
const launchpadRecommendedTargetNodeCode = String(
|
||
releaseLaunchpadStatus.value.recommended_target_node_code || ""
|
||
).trim();
|
||
const launchpadRecommendedRecoveryLabel = String(
|
||
releaseLaunchpadStatus.value.recommended_recovery_label || ""
|
||
).trim();
|
||
const launchpadRecommendedRecoverySummary = String(
|
||
releaseLaunchpadStatus.value.recommended_recovery_summary || ""
|
||
).trim();
|
||
const launchpadOnboardingBootstrapPendingNodes = Number(
|
||
releaseLaunchpadStatus.value.onboarding_bootstrap_pending_nodes || 0
|
||
);
|
||
const launchpadOnboardingAcceptanceReadyNodes = Number(
|
||
releaseLaunchpadStatus.value.onboarding_acceptance_ready_nodes || 0
|
||
);
|
||
const managedEnabled = Number(nodesSummary.value.managed_enabled || managedNodesSummary.value.enabled || 0);
|
||
const remoteAccessReady = Number(nodesSummary.value.remote_access_ready || 0);
|
||
const queueDeadLetterNodes = Number(
|
||
nodesSummary.value.queue_dead_letter_nodes || managedNodesSummary.value.queueDeadLetterNodes || 0
|
||
);
|
||
const participatingNodesTotal = Number(opsWorkerLogSyncParticipatingNodeCount.value || 0);
|
||
const logSyncCoveredNodes = Number(opsWorkerLogSyncCoveredParticipatingNodeCount.value || 0);
|
||
const logSyncEnabled = Boolean(opsWorkerLogSyncEnabled.value);
|
||
const blockingReasons: string[] = [];
|
||
const warnings: string[] = [];
|
||
const publishBlockingReasons: string[] = [];
|
||
const publishWarnings: string[] = [];
|
||
|
||
if (stackStatus === "blocked") {
|
||
blockingReasons.push("stack_diagnosis=blocked");
|
||
publishBlockingReasons.push("stack_diagnosis=blocked");
|
||
} else if (stackStatus === "attention") {
|
||
warnings.push("stack_diagnosis=attention");
|
||
publishWarnings.push("stack_diagnosis=attention");
|
||
}
|
||
if (!routeSurfaceComplete) {
|
||
const missingKeys = opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.join(",");
|
||
blockingReasons.push(`route_surface_incomplete${missingKeys ? `:${missingKeys}` : ""}`);
|
||
publishBlockingReasons.push(`route_surface_incomplete${missingKeys ? `:${missingKeys}` : ""}`);
|
||
}
|
||
if (launchpadStatus === "blocked") {
|
||
blockingReasons.push("release_launchpad=blocked");
|
||
publishBlockingReasons.push("release_launchpad=blocked");
|
||
} else if (launchpadStatus === "attention") {
|
||
warnings.push("release_launchpad=attention");
|
||
publishWarnings.push("release_launchpad=attention");
|
||
}
|
||
if (managedEnabled > 0 && remoteAccessReady === 0) {
|
||
blockingReasons.push("remote_agent_ready=0");
|
||
publishBlockingReasons.push("remote_agent_ready=0");
|
||
} else if (managedEnabled > 0 && remoteAccessReady < managedEnabled) {
|
||
warnings.push(`remote_agent_ready=${remoteAccessReady}/${managedEnabled}`);
|
||
publishWarnings.push(`remote_agent_ready=${remoteAccessReady}/${managedEnabled}`);
|
||
}
|
||
if (queueDeadLetterNodes > 0) {
|
||
warnings.push(`queue_dead_letter_nodes=${queueDeadLetterNodes}`);
|
||
publishWarnings.push(`queue_dead_letter_nodes=${queueDeadLetterNodes}`);
|
||
}
|
||
if (logSyncEnabled && participatingNodesTotal > 0) {
|
||
if (logSyncCoveredNodes === 0) {
|
||
warnings.push(`log_sync_waiting_sample=0/${participatingNodesTotal}`);
|
||
publishWarnings.push(`log_sync_waiting_sample=0/${participatingNodesTotal}`);
|
||
} else if (logSyncCoveredNodes < participatingNodesTotal) {
|
||
warnings.push(`log_sync_partial=${logSyncCoveredNodes}/${participatingNodesTotal}`);
|
||
publishWarnings.push(`log_sync_partial=${logSyncCoveredNodes}/${participatingNodesTotal}`);
|
||
}
|
||
}
|
||
|
||
const goLiveStatus = blockingReasons.length ? "blocked" : warnings.length ? "attention" : "ready";
|
||
const publishStatus = publishBlockingReasons.length ? "blocked" : publishWarnings.length ? "attention" : "ready";
|
||
const nextStepActionCode = String(opsStackDiagnosisNextStep.value.action_code || recommendation.value.primary_action_code || "").trim();
|
||
const nextStepReason = String(opsStackDiagnosisNextStep.value.reason || recommendation.value.reason || "").trim();
|
||
|
||
return {
|
||
generated_at: String(opsStackDiagnosis.value.generated_at || "").trim(),
|
||
base_url: String(controlPlaneBaseUrl.value || "").trim() || "http://127.0.0.1:8100",
|
||
go_live_status: goLiveStatus,
|
||
publish_ready: publishStatus === "ready",
|
||
publish_status: publishStatus,
|
||
publish_status_label:
|
||
publishStatus === "ready" ? "可发布" : publishStatus === "attention" ? "可发布但建议先复核" : "暂不可发布",
|
||
publish_summary:
|
||
publishStatus === "ready"
|
||
? "当前执行面、路由面、合同面与发布门禁已收口,可以进入正式发版。"
|
||
: publishStatus === "attention"
|
||
? "当前没有硬阻断,但仍有上线前关注项,建议先完成复核再正式发版。"
|
||
: "当前仍有硬阻断项,不能直接进入正式发版。",
|
||
stack_status: stackStatus,
|
||
contracts_ready: Boolean(opsContractRegistryTotal.value),
|
||
contracts_total: Number(opsContractRegistryTotal.value || 0),
|
||
launchpad_status: launchpadStatus,
|
||
launchpad_status_label: String(releaseLaunchpadStatus.value.status_label || "").trim(),
|
||
launchpad_recommended_action_code: String(releaseLaunchpadStatus.value.recommended_action_code || "").trim(),
|
||
launchpad_recommended_target_node_code: launchpadRecommendedTargetNodeCode,
|
||
launchpad_recommended_recovery_label: launchpadRecommendedRecoveryLabel,
|
||
launchpad_recommended_recovery_summary: launchpadRecommendedRecoverySummary,
|
||
launchpad_onboarding_bootstrap_pending_nodes: launchpadOnboardingBootstrapPendingNodes,
|
||
launchpad_onboarding_acceptance_ready_nodes: launchpadOnboardingAcceptanceReadyNodes,
|
||
route_surface_complete: routeSurfaceComplete,
|
||
route_surface_missing_keys: [...opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value],
|
||
managed_enabled: managedEnabled,
|
||
remote_access_ready: remoteAccessReady,
|
||
queue_dead_letter_nodes: queueDeadLetterNodes,
|
||
participating_nodes_total: participatingNodesTotal,
|
||
log_sync_enabled: logSyncEnabled,
|
||
log_sync_state: String(opsWorkerLogSyncState.value || "").trim(),
|
||
log_sync_mode: String(opsWorkerLogSyncMode.value || "").trim(),
|
||
log_sync_covered_nodes: logSyncCoveredNodes,
|
||
log_sync_missing_node_codes: [...opsWorkerLogSyncMissingParticipatingNodeCodes.value],
|
||
next_step_action_code: nextStepActionCode,
|
||
next_step_reason: nextStepReason,
|
||
operator_lane:
|
||
String(opsStackDiagnosisOperatorDecision.value.lane || "").trim() ||
|
||
(blockingReasons.length ? "recovery" : warnings.length ? "verification" : "go-live"),
|
||
operator_title:
|
||
String(opsStackDiagnosisOperatorDecision.value.title || recommendation.value.title || "").trim() ||
|
||
(blockingReasons.length ? "先处理阻塞项" : "可以进入上线复核"),
|
||
operator_primary_command_key:
|
||
String(opsStackDiagnosisOperatorDecision.value.primary_command_key || nextStepActionCode).trim(),
|
||
publish_blocking_reasons: publishBlockingReasons,
|
||
publish_warnings: publishWarnings,
|
||
blocking_reasons: blockingReasons,
|
||
warnings,
|
||
recommended_commands: {
|
||
...(opsStackDiagnosisRecommendedCommands.value || {}),
|
||
next_step:
|
||
String(opsStackDiagnosisNextCommand.value || "").trim() ||
|
||
(nextStepActionCode
|
||
? buildOpsScriptCommand(
|
||
"drive_ops_center.sh",
|
||
"driver-resolve",
|
||
String(controlPlaneBaseUrl.value || "").trim() || "http://127.0.0.1:8100",
|
||
nextStepActionCode
|
||
)
|
||
: ""),
|
||
},
|
||
};
|
||
};
|
||
const goLiveSummary = computed<GenericRecord>(() => {
|
||
if (Object.keys(goLiveSummaryPayload.value || {}).length) {
|
||
return goLiveSummaryPayload.value || {};
|
||
}
|
||
return buildFallbackGoLiveSummaryData();
|
||
});
|
||
const goLiveSummaryLaunchpadTargetNodeCode = computed(() =>
|
||
String(goLiveSummary.value.launchpad_recommended_target_node_code || "").trim()
|
||
);
|
||
const goLiveSummaryLaunchpadRecoveryLabel = computed(() =>
|
||
String(goLiveSummary.value.launchpad_recommended_recovery_label || "").trim()
|
||
);
|
||
const goLiveSummaryLaunchpadRecoverySummary = computed(() =>
|
||
String(goLiveSummary.value.launchpad_recommended_recovery_summary || "").trim()
|
||
);
|
||
const goLiveSummaryLaunchpadBootstrapPendingNodes = computed(() =>
|
||
Number(goLiveSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0)
|
||
);
|
||
const goLiveSummaryLaunchpadAcceptanceReadyNodes = computed(() =>
|
||
Number(goLiveSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0)
|
||
);
|
||
const goLiveSummaryLaunchpadGapSummary = computed(() => {
|
||
const targetNodeCode = goLiveSummaryLaunchpadTargetNodeCode.value;
|
||
const recoveryLabel = goLiveSummaryLaunchpadRecoveryLabel.value;
|
||
const recoverySummary = goLiveSummaryLaunchpadRecoverySummary.value;
|
||
const bootstrapPendingNodes = goLiveSummaryLaunchpadBootstrapPendingNodes.value;
|
||
const acceptanceReadyNodes = goLiveSummaryLaunchpadAcceptanceReadyNodes.value;
|
||
const countSummary =
|
||
bootstrapPendingNodes > 0 || acceptanceReadyNodes > 0
|
||
? `待补安装 ${bootstrapPendingNodes} / 待验收 ${acceptanceReadyNodes}`
|
||
: "";
|
||
|
||
if (targetNodeCode && recoveryLabel && recoverySummary) {
|
||
return `${recoveryLabel} ${targetNodeCode};${recoverySummary}`;
|
||
}
|
||
if (targetNodeCode && recoveryLabel) {
|
||
return `${recoveryLabel} ${targetNodeCode}${countSummary ? `;${countSummary}` : ""}`;
|
||
}
|
||
if (recoverySummary) {
|
||
return recoverySummary;
|
||
}
|
||
if (countSummary) {
|
||
return countSummary;
|
||
}
|
||
return "当前 launchpad 没有新的接入缺口。";
|
||
});
|
||
const goLiveSummaryStatus = computed(() => String(goLiveSummary.value.go_live_status || "").trim());
|
||
const goLiveSummaryStatusLabel = computed(() => {
|
||
const status = goLiveSummaryStatus.value;
|
||
if (status === "blocked") return "阻断";
|
||
if (status === "attention") return "待收口";
|
||
if (status === "ready") return "可上线";
|
||
return status || "未知";
|
||
});
|
||
const goLiveSummaryStatusType = computed(() => {
|
||
const status = goLiveSummaryStatus.value;
|
||
if (status === "blocked") return "danger";
|
||
if (status === "attention") return "warning";
|
||
if (status === "ready") return "success";
|
||
return "info";
|
||
});
|
||
const goLiveSummaryBlockingReasons = computed<string[]>(() =>
|
||
(goLiveSummary.value.blocking_reasons || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveSummaryWarnings = computed<string[]>(() =>
|
||
(goLiveSummary.value.warnings || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLivePublishStatus = computed(() => String(goLiveSummary.value.publish_status || "").trim());
|
||
const goLivePublishStatusLabel = computed(() => {
|
||
const status = goLivePublishStatus.value;
|
||
if (status === "blocked") return "暂不可发布";
|
||
if (status === "attention") return "可发布但建议先复核";
|
||
if (status === "ready") return "可发布";
|
||
return status || "未知";
|
||
});
|
||
const goLivePublishBlockingReasons = computed<string[]>(() =>
|
||
(goLiveSummary.value.publish_blocking_reasons || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const goLivePublishWarnings = computed<string[]>(() =>
|
||
(goLiveSummary.value.publish_warnings || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveSummaryRecommendedCommandEntries = computed(() =>
|
||
Object.entries((goLiveSummary.value.recommended_commands || {}) as Record<string, unknown>)
|
||
.map(([key, command]) => ({ key, label: String(key || "").trim(), command: String(command || "").trim() }))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const goLiveSummaryHeadline = computed(() => {
|
||
const targetNodeCode = goLiveSummaryLaunchpadTargetNodeCode.value;
|
||
const recoveryLabel = goLiveSummaryLaunchpadRecoveryLabel.value;
|
||
const blocking = goLiveSummaryBlockingReasons.value[0];
|
||
if (blocking) {
|
||
return targetNodeCode && recoveryLabel
|
||
? `当前上线仍有阻断:${blocking};优先处理 ${recoveryLabel} ${targetNodeCode}`
|
||
: `当前上线仍有阻断:${blocking}`;
|
||
}
|
||
const warning = goLiveSummaryWarnings.value[0];
|
||
if (warning) {
|
||
return targetNodeCode && recoveryLabel
|
||
? `当前进入联调收口阶段:${warning};建议先处理 ${recoveryLabel} ${targetNodeCode}`
|
||
: `当前进入联调收口阶段:${warning}`;
|
||
}
|
||
if (targetNodeCode && recoveryLabel) {
|
||
return `当前上线总检接近收口,剩余重点:${recoveryLabel} ${targetNodeCode}`;
|
||
}
|
||
return "当前上线总检已进入可复核、可发版状态。";
|
||
});
|
||
const goLiveSummaryNextStepCommand = computed(() =>
|
||
String((goLiveSummary.value.recommended_commands || {}).next_step || "").trim()
|
||
);
|
||
const opsStackDiagnosisDiagnosis = computed<GenericRecord>(() => opsStackDiagnosis.value.diagnosis || {});
|
||
const opsStackDiagnosisSurfaceMatrix = computed<GenericRecord>(() => opsStackDiagnosis.value.surface_matrix || {});
|
||
const opsStackDiagnosisSurfaceCounts = computed<GenericRecord>(() => opsStackDiagnosisSurfaceMatrix.value.status_counts || {});
|
||
const opsStackDiagnosisSurfaceItems = computed<GenericRecord[]>(() => opsStackDiagnosisSurfaceMatrix.value.items || []);
|
||
const opsStackDiagnosisIssues = computed<GenericRecord[]>(() => opsStackDiagnosisDiagnosis.value.issues || []);
|
||
const opsStackDiagnosisRecommendedActions = computed<GenericRecord[]>(() => opsStackDiagnosisDiagnosis.value.recommended_actions || []);
|
||
const opsStackDiagnosisOperatorDecision = computed<GenericRecord>(() => opsStackDiagnosisDiagnosis.value.operator_decision || {});
|
||
const opsStackDiagnosisNextActions = computed<GenericRecord>(() => opsStackDiagnosisDiagnosis.value.next_actions || {});
|
||
const opsStackDiagnosisRecommendedCommands = computed<GenericRecord>(() => opsStackDiagnosisDiagnosis.value.recommended_commands || {});
|
||
const opsStackDiagnosisOperatorHints = computed<string[]>(() =>
|
||
(opsStackDiagnosisDiagnosis.value.operator_hints || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const opsStackDiagnosisQuickCommands = computed<string[]>(() =>
|
||
(opsStackDiagnosisDiagnosis.value.quick_commands || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const opsStackDiagnosisRuntimeBuildInfo = computed<GenericRecord>(() => opsStackDiagnosis.value.runtime_build_info || {});
|
||
const opsStackDiagnosisRuntimeRouteSurface = computed<GenericRecord>(() => opsStackDiagnosisRuntimeBuildInfo.value.route_surface || {});
|
||
const opsStackDiagnosisRuntimeRouteSurfaceMissingKeys = computed<string[]>(() =>
|
||
(opsStackDiagnosisRuntimeBuildInfo.value.route_surface_missing_keys ||
|
||
opsStackDiagnosisRuntimeRouteSurface.value.missing_keys ||
|
||
[])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const opsStackDiagnosisSummaryContracts = computed<GenericRecord[]>(() => contractNavigationContracts(opsStackDiagnosisDiagnosis.value));
|
||
const opsStackDiagnosisQuickCommandBlock = computed(() => opsStackDiagnosisQuickCommands.value.join("\n"));
|
||
const opsStackDiagnosisNextStep = computed<GenericRecord>(() => opsStackDiagnosisDiagnosis.value.next_step || {});
|
||
const opsStackDiagnosisNextStepContracts = computed<GenericRecord[]>(() => contractNavigationContracts(opsStackDiagnosisNextStep.value));
|
||
const opsStackDiagnosisOperatorDecisionHasFocus = computed(
|
||
() => Object.keys((opsStackDiagnosisOperatorDecision.value.next_focus || {}) as GenericRecord).length > 0
|
||
);
|
||
const opsStackDiagnosisOperatorDecisionNavigable = computed(() => {
|
||
if (opsStackDiagnosisOperatorDecisionHasFocus.value) {
|
||
return true;
|
||
}
|
||
if (String(opsStackDiagnosisNextActions.value.primary_command || "").trim()) {
|
||
return true;
|
||
}
|
||
const lane = String(opsStackDiagnosisOperatorDecision.value.lane || "").trim();
|
||
return ["observability", "node_handover", "release", "ops_jobs", "managed_nodes"].includes(lane);
|
||
});
|
||
const opsStackDiagnosisOperatorDecisionLaneLabel = computed(() => {
|
||
const lane = String(opsStackDiagnosisOperatorDecision.value.lane || "").trim();
|
||
if (lane === "observability") return "现场观察";
|
||
if (lane === "node_handover") return "节点接管";
|
||
if (lane === "release") return "发布";
|
||
if (lane === "ops_jobs") return "运维任务";
|
||
if (lane === "steady") return "稳定观察";
|
||
return lane || "未分类";
|
||
});
|
||
const opsStackDiagnosisOperatorDecisionPriorityLabel = computed(() => {
|
||
const priority = String(opsStackDiagnosisOperatorDecision.value.priority || "").trim();
|
||
if (priority === "high") return "高优先级";
|
||
if (priority === "medium") return "中优先级";
|
||
if (priority === "low") return "低优先级";
|
||
return priority || "未定级";
|
||
});
|
||
const opsStackDiagnosisOperatorDecisionTagType = computed(() => {
|
||
const lane = String(opsStackDiagnosisOperatorDecision.value.lane || "").trim();
|
||
if (lane === "observability" || lane === "node_handover") return "danger";
|
||
if (lane === "release" || lane === "ops_jobs") return "warning";
|
||
if (lane === "steady") return "success";
|
||
return "info";
|
||
});
|
||
const opsStackDiagnosisRecommendedCommandEntries = computed(() =>
|
||
Object.entries(opsStackDiagnosisRecommendedCommands.value || {})
|
||
.map(([key, command]) => ({
|
||
key,
|
||
label: String(key || "").trim(),
|
||
command: String(command || "").trim(),
|
||
}))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const opsStackDiagnosisNextAction = computed<GenericRecord>(() => {
|
||
const actionCode = String(opsStackDiagnosisNextStep.value.action_code || "").trim();
|
||
if (!actionCode) {
|
||
return {};
|
||
}
|
||
return (
|
||
opsStackDiagnosisRecommendedActions.value.find(
|
||
(item) => String(item?.action_code || "").trim() === actionCode
|
||
) || {}
|
||
);
|
||
});
|
||
const opsStackDiagnosisStatusLabel = computed(() => {
|
||
const status = String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim();
|
||
if (status === "blocked") return "阻断";
|
||
if (status === "attention") return "待处理";
|
||
if (status === "ready") return "就绪";
|
||
return status || "未知";
|
||
});
|
||
const opsStackDiagnosisStatusType = computed(() => {
|
||
const status = String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim();
|
||
if (status === "blocked") return "danger";
|
||
if (status === "attention") return "warning";
|
||
if (status === "ready") return "success";
|
||
return "info";
|
||
});
|
||
const opsStackDiagnosisSurfaceStatusLabel = computed(() => {
|
||
const status = String(opsStackDiagnosisDiagnosis.value.surface_status || "").trim();
|
||
if (status === "healthy") return "观察层完整";
|
||
if (status === "partial") return "观察层部分缺失";
|
||
if (status === "broken") return "观察层异常";
|
||
return status || "观察层未知";
|
||
});
|
||
const opsStackDiagnosisAutomationStatusLabel = computed(() => {
|
||
const status = String(opsStackDiagnosisDiagnosis.value.automation_status || "").trim();
|
||
if (status === "ready") return "自动化就绪";
|
||
if (status === "attention") return "自动化待处理";
|
||
if (status === "blocked") return "自动化阻断";
|
||
return status || "自动化未知";
|
||
});
|
||
const opsStackDiagnosisHeadline = computed(() => {
|
||
const topHint = String(opsStackDiagnosisOperatorHints.value[0] || "").trim();
|
||
if (topHint) {
|
||
return topHint;
|
||
}
|
||
const issueTotal = Number(opsStackDiagnosisDiagnosis.value.issue_total || 0);
|
||
if (opsStackDiagnosisStatusLabel.value === "阻断") {
|
||
return `总检发现 ${issueTotal} 个需要先处理的问题,建议先执行默认下一步动作。`;
|
||
}
|
||
if (opsStackDiagnosisStatusLabel.value === "待处理") {
|
||
return `总检已可用,但仍有 ${issueTotal} 个缺口待收口。`;
|
||
}
|
||
return "当前海外单脑控制面总检已基本就绪。";
|
||
});
|
||
const opsStackDiagnosisMissingSurfacesText = computed(() => {
|
||
const values = (opsStackDiagnosisDiagnosis.value.missing_surfaces || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return values.length ? values.join("、") : "无";
|
||
});
|
||
const opsStackDiagnosisRuntimeBuildIdentityText = computed(() => {
|
||
const commitRef = String(opsStackDiagnosisRuntimeBuildInfo.value.commit_ref || "").trim();
|
||
const commitSha = String(opsStackDiagnosisRuntimeBuildInfo.value.commit_sha || "").trim();
|
||
if (commitRef && commitSha) {
|
||
return `${commitRef} @ ${commitSha}`;
|
||
}
|
||
return commitRef || commitSha || "-";
|
||
});
|
||
const opsStackDiagnosisRuntimeBuildStatusLabel = computed(() => {
|
||
if (opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length) return "待重启";
|
||
if (Object.keys(opsStackDiagnosisRuntimeBuildInfo.value || {}).length) return "已对齐";
|
||
return "未知";
|
||
});
|
||
const opsStackDiagnosisRuntimeBuildTagType = computed(() => {
|
||
if (opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length) return "warning";
|
||
if (Object.keys(opsStackDiagnosisRuntimeBuildInfo.value || {}).length) return "success";
|
||
return "info";
|
||
});
|
||
const opsStackDiagnosisRuntimeRouteSurfaceText = computed(() => {
|
||
if (opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length) {
|
||
return `不完整(缺 ${opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length} 项)`;
|
||
}
|
||
if (Object.keys(opsStackDiagnosisRuntimeRouteSurface.value || {}).length) {
|
||
return "完整";
|
||
}
|
||
return "-";
|
||
});
|
||
const opsStackDiagnosisRuntimeBuildHeadline = computed(() => {
|
||
if (opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length) {
|
||
return "当前运行中的 API 还没吃到完整运维路由面";
|
||
}
|
||
if (Object.keys(opsStackDiagnosisRuntimeBuildInfo.value || {}).length) {
|
||
return "当前运行中的 API 版本面已对齐";
|
||
}
|
||
return "当前还没有拿到运行中 API 的版本面信息";
|
||
});
|
||
const opsStackDiagnosisRuntimeBuildSummary = computed(() => {
|
||
if (opsStackDiagnosisRuntimeRouteSurfaceMissingKeys.value.length) {
|
||
return `这通常表示服务还没重启到最新代码,或当前部署内容缺少关键运维入口。建议先重启 API,再重新看总检。`;
|
||
}
|
||
if (Object.keys(opsStackDiagnosisRuntimeBuildInfo.value || {}).length) {
|
||
return "当前总检已经可以确认运行中 API 的 build-info 与路由面,没有发现版本面缺口。";
|
||
}
|
||
return "当前总检还无法判断运行中的 API 是否已经对齐到最新版本。";
|
||
});
|
||
const opsStackDiagnosisRuntimeApiRestartCommand = computed(() => {
|
||
return String(opsStackDiagnosisRecommendedCommands.value["api-restart"] || "").trim();
|
||
});
|
||
const opsStackDiagnosisNextActionLabel = computed(() => {
|
||
return (
|
||
String(opsStackDiagnosisNextAction.value.title || "").trim() ||
|
||
String(opsStackDiagnosisNextStep.value.action_code || "").trim() ||
|
||
"当前还没有默认下一步"
|
||
);
|
||
});
|
||
const opsStackDiagnosisNextCommand = computed(
|
||
() => String(opsStackDiagnosisNextStep.value.command || opsStackDiagnosisNextAction.value.command || "").trim()
|
||
);
|
||
const opsStackDiagnosisActionHasFocus = (payload: GenericRecord = {}) => {
|
||
const focusRef = payload?.focus_ref || {};
|
||
if (Object.keys(focusRef || {}).length) {
|
||
return true;
|
||
}
|
||
return String(payload?.action_code || "").trim().startsWith("focus_");
|
||
};
|
||
const opsStackDiagnosisIssueTagType = (severity: string) => {
|
||
const normalizedSeverity = String(severity || "").trim();
|
||
if (normalizedSeverity === "blocked") return "danger";
|
||
if (normalizedSeverity === "warning") return "warning";
|
||
if (normalizedSeverity === "info") return "info";
|
||
return "info";
|
||
};
|
||
const executionNodeStateMap = computed<Record<string, GenericRecord>>(() => {
|
||
const rows = [...executionParticipatingNodes.value, ...executionStandbyNodes.value];
|
||
return rows.reduce<Record<string, GenericRecord>>((acc, item) => {
|
||
const nodeCode = String(item?.node_code || "").trim();
|
||
if (nodeCode) {
|
||
acc[nodeCode] = item;
|
||
}
|
||
return acc;
|
||
}, {});
|
||
});
|
||
const executionStateGroupKeyForRow = (row: GenericRecord) => {
|
||
if (Boolean(row?.is_dispatch_active)) return "dispatch_active";
|
||
const state = String(row?.participation_state || "").trim();
|
||
if (state === "recent_throughput") return "recent_throughput";
|
||
if (state === "load_syncing") return "load_syncing";
|
||
return "standby";
|
||
};
|
||
const executionStateGroupLabel = (value: string) => {
|
||
const normalizedValue = String(value || "").trim();
|
||
if (normalizedValue === "dispatch_active") return "执行中 / 已领";
|
||
if (normalizedValue === "recent_throughput") return "近窗有吞吐";
|
||
if (normalizedValue === "load_syncing") return "负载待确认";
|
||
if (normalizedValue === "participating") return "全部参与节点";
|
||
if (normalizedValue === "non_participating") return "全部未参与节点";
|
||
if (normalizedValue === "standby") return "在线待命";
|
||
return normalizedValue || "未分类";
|
||
};
|
||
const executionStateGroupLabelForRow = (row: GenericRecord) =>
|
||
executionStateGroupLabel(executionStateGroupKeyForRow(row));
|
||
const executionStateGroupTagType = (row: GenericRecord) => {
|
||
const stateGroup = executionStateGroupKeyForRow(row);
|
||
if (stateGroup === "dispatch_active") return "warning";
|
||
if (stateGroup === "recent_throughput") return "success";
|
||
if (stateGroup === "load_syncing") return "danger";
|
||
return "info";
|
||
};
|
||
const executionAllNodes = computed<GenericRecord[]>(() => {
|
||
const groupWeight: Record<string, number> = {
|
||
dispatch_active: 0,
|
||
recent_throughput: 1,
|
||
load_syncing: 2,
|
||
standby: 3,
|
||
};
|
||
return [...executionParticipatingNodes.value, ...executionStandbyNodes.value].sort((left, right) => {
|
||
const leftGroup = executionStateGroupKeyForRow(left);
|
||
const rightGroup = executionStateGroupKeyForRow(right);
|
||
const groupDiff = (groupWeight[leftGroup] ?? 99) - (groupWeight[rightGroup] ?? 99);
|
||
if (groupDiff !== 0) {
|
||
return groupDiff;
|
||
}
|
||
const runningDiff = Number(right?.items_running || 0) - Number(left?.items_running || 0);
|
||
if (runningDiff !== 0) {
|
||
return runningDiff;
|
||
}
|
||
const claimedDiff = Number(right?.items_claimed || 0) - Number(left?.items_claimed || 0);
|
||
if (claimedDiff !== 0) {
|
||
return claimedDiff;
|
||
}
|
||
const recentDiff = Number(right?.processed_recent || 0) - Number(left?.processed_recent || 0);
|
||
if (recentDiff !== 0) {
|
||
return recentDiff;
|
||
}
|
||
const loadDiff = Number(right?.current_load || 0) - Number(left?.current_load || 0);
|
||
if (loadDiff !== 0) {
|
||
return loadDiff;
|
||
}
|
||
return String(left?.node_code || "").localeCompare(String(right?.node_code || ""));
|
||
});
|
||
});
|
||
const executionSceneStateCounts = computed<Record<string, number>>(() => ({
|
||
participating: executionParticipatingNodes.value.length,
|
||
dispatch_active: executionDispatchActiveNodes.value.length,
|
||
recent_throughput: executionRecentOnlyNodes.value.length,
|
||
non_participating: executionStandbyNodes.value.length,
|
||
load_syncing: executionLoadSyncingNodes.value.length,
|
||
standby: executionPureStandbyNodes.value.length,
|
||
}));
|
||
const executionSceneStateOptions = computed(() => {
|
||
const order = [
|
||
"participating",
|
||
"dispatch_active",
|
||
"recent_throughput",
|
||
"non_participating",
|
||
"load_syncing",
|
||
"standby",
|
||
];
|
||
return order
|
||
.map((value) => ({
|
||
value,
|
||
count: Number(executionSceneStateCounts.value[value] || 0),
|
||
label: `${executionStateGroupLabel(value)} (${Number(executionSceneStateCounts.value[value] || 0)})`,
|
||
}))
|
||
.filter((item) => item.count > 0);
|
||
});
|
||
const executionSceneRoleOptions = computed(() => {
|
||
const roleCounts = executionAllNodes.value.reduce<Record<string, number>>((acc, item) => {
|
||
const role = String(item?.role || "").trim();
|
||
if (role) {
|
||
acc[role] = Number(acc[role] || 0) + 1;
|
||
}
|
||
return acc;
|
||
}, {});
|
||
return Object.entries(roleCounts)
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${value} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label));
|
||
});
|
||
const executionRowMatchesStateGroup = (row: GenericRecord, stateGroup: string) => {
|
||
const normalizedStateGroup = String(stateGroup || "").trim();
|
||
if (!normalizedStateGroup) {
|
||
return true;
|
||
}
|
||
if (normalizedStateGroup === "participating") {
|
||
return Boolean(row?.detect_participating);
|
||
}
|
||
if (normalizedStateGroup === "non_participating") {
|
||
return !Boolean(row?.detect_participating);
|
||
}
|
||
return executionStateGroupKeyForRow(row) === normalizedStateGroup;
|
||
};
|
||
const executionFilteredNodes = computed<GenericRecord[]>(() =>
|
||
executionAllNodes.value.filter((row) => {
|
||
const role = String(executionSceneFilters.value.role || "").trim();
|
||
return executionRowMatchesStateGroup(row, String(executionSceneFilters.value.state_group || "").trim())
|
||
&& (!role || String(row?.role || "").trim() === role);
|
||
})
|
||
);
|
||
const hasExecutionSceneFilters = computed(
|
||
() =>
|
||
Boolean(String(executionSceneFilters.value.state_group || "").trim()) ||
|
||
Boolean(String(executionSceneFilters.value.role || "").trim())
|
||
);
|
||
const opsWorkerLogSyncEnabled = computed(() => Boolean(executionScene.value.log_sync?.enabled));
|
||
const opsWorkerLogSyncMode = computed<"key" | "full">(() =>
|
||
String(executionScene.value.log_sync?.mode || "").trim().toLowerCase() === "full" ? "full" : "key"
|
||
);
|
||
const opsWorkerLogSyncStatusType = computed(() => {
|
||
const statusType = String(executionScene.value.log_sync?.status_type || "").trim();
|
||
return statusType || (opsWorkerLogSyncEnabled.value ? "success" : "info");
|
||
});
|
||
const opsWorkerLogSyncStatusLabel = computed(() => {
|
||
const statusLabel = String(executionScene.value.log_sync?.status_label || "").trim();
|
||
return statusLabel || (opsWorkerLogSyncEnabled.value ? "已开启" : "已关闭");
|
||
});
|
||
const opsWorkerLogSyncModeLabel = computed(() => {
|
||
const modeLabel = String(executionScene.value.log_sync?.mode_label || "").trim();
|
||
if (modeLabel) {
|
||
return modeLabel;
|
||
}
|
||
return opsWorkerLogSyncEnabled.value ? (opsWorkerLogSyncMode.value === "full" ? "全量回传" : "关键回传") : "已关闭";
|
||
});
|
||
const opsWorkerLogSyncDescription = computed(() => {
|
||
const backendDescription = String(executionScene.value.log_sync?.description || "").trim();
|
||
if (backendDescription) {
|
||
return backendDescription;
|
||
}
|
||
if (!opsWorkerLogSyncEnabled.value) {
|
||
return "当前已关闭远端日志回传;海外控制面不会持续镜像大陆节点的 Worker 过程日志。";
|
||
}
|
||
const logSync = executionScene.value.log_sync || {};
|
||
const lineCount = Number(logSync.line_count || 0);
|
||
const sourceNodeCount = Number(logSync.source_node_count || 0);
|
||
const lastAt = String(logSync.last_at || "").trim();
|
||
const observation = lineCount > 0
|
||
? ` 当前已收到 ${lineCount} 条回传样本,来源 ${sourceNodeCount} 台${lastAt ? `,最近一条 ${lastAt}` : ""}。`
|
||
: " 当前还没有收到远端日志样本。";
|
||
if (opsWorkerLogSyncMode.value === "full") {
|
||
return `当前为全量回传;适合短时深度排障,但日志量会明显增大。${observation}`;
|
||
}
|
||
return `当前为关键回传;更适合日常联调观察,只保留阶段、异常、代理和关键执行事件。${observation}`;
|
||
});
|
||
const opsWorkerLogSyncActionHint = computed(() => {
|
||
const backendHint = String(executionScene.value.log_sync?.recommended_action_label || "").trim();
|
||
if (backendHint) {
|
||
return backendHint;
|
||
}
|
||
if (!opsWorkerLogSyncEnabled.value) {
|
||
return "建议先开启关键回传";
|
||
}
|
||
return "当前无需调整";
|
||
});
|
||
const opsWorkerLogPreviewLines = computed<string[]>(() => {
|
||
const sceneLines = (executionScene.value.log_sync?.preview_lines || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return sceneLines.length ? sceneLines : opsLinkSnapshotPreviewLines.value;
|
||
});
|
||
const opsWorkerLogSourceNodeSummaries = computed<GenericRecord[]>(() =>
|
||
(executionScene.value.log_sync?.source_node_summaries || []).map((item: unknown) => ({ ...(item as GenericRecord) }))
|
||
);
|
||
const opsWorkerLogMissingNodeSummaries = computed<GenericRecord[]>(() =>
|
||
(executionScene.value.log_sync?.missing_participating_node_summaries || []).map((item: unknown) => ({
|
||
...(item as GenericRecord),
|
||
}))
|
||
);
|
||
const opsWorkerLogSceneNodeOptions = computed<GenericRecord[]>(() => {
|
||
const optionMap = new Map<string, GenericRecord>();
|
||
const appendOption = (rawItem: GenericRecord = {}) => {
|
||
const nodeCode = String(rawItem?.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
return;
|
||
}
|
||
const existing = optionMap.get(nodeCode) || {};
|
||
optionMap.set(nodeCode, {
|
||
...rawItem,
|
||
...existing,
|
||
node_code: nodeCode,
|
||
region: String(existing.region || rawItem.region || "").trim(),
|
||
role: String(existing.role || rawItem.role || "").trim(),
|
||
participation_label: String(existing.participation_label || rawItem.participation_label || "").trim(),
|
||
missing_reason: String(existing.missing_reason || rawItem.missing_reason || "").trim(),
|
||
line_count: Math.max(Number(existing.line_count || 0), Number(rawItem.line_count || 0)),
|
||
last_at: String(existing.last_at || rawItem.last_at || "").trim(),
|
||
});
|
||
};
|
||
executionAllNodes.value.forEach((item) => appendOption(item as GenericRecord));
|
||
opsWorkerLogSourceNodeSummaries.value.forEach((item) => appendOption(item as GenericRecord));
|
||
opsWorkerLogMissingNodeSummaries.value.forEach((item) => appendOption(item as GenericRecord));
|
||
return Array.from(optionMap.values()).sort((left, right) => {
|
||
const leftWeight = Number(left.line_count || 0) > 0 ? 0 : String(left.missing_reason || "").trim() ? 1 : 2;
|
||
const rightWeight = Number(right.line_count || 0) > 0 ? 0 : String(right.missing_reason || "").trim() ? 1 : 2;
|
||
if (leftWeight !== rightWeight) {
|
||
return leftWeight - rightWeight;
|
||
}
|
||
const leftAt = String(left.last_at || "").trim();
|
||
const rightAt = String(right.last_at || "").trim();
|
||
if (leftAt !== rightAt) {
|
||
return rightAt.localeCompare(leftAt);
|
||
}
|
||
return String(left.node_code || "").localeCompare(String(right.node_code || ""));
|
||
});
|
||
});
|
||
const remoteLogSelectedNodeDetailData = computed<GenericRecord>(() => remoteLogNodeDetail.value || {});
|
||
const remoteLogSelectedNodeRecords = computed<GenericRecord[]>(() => remoteLogSelectedNodeDetailData.value.records || []);
|
||
const remoteLogSelectedNodeSummary = computed<GenericRecord>(() => remoteLogSelectedNodeDetailData.value.source_summary || {});
|
||
const remoteLogSelectedNodeMeta = computed<GenericRecord>(() => remoteLogSelectedNodeDetailData.value.node || {});
|
||
const remoteLogSelectedNodeParticipation = computed<GenericRecord>(
|
||
() => remoteLogSelectedNodeDetailData.value.participation || {}
|
||
);
|
||
const remoteLogSelectedNodeStatusLabel = computed(
|
||
() => String(remoteLogSelectedNodeDetailData.value.status_label || "").trim() || "未发现"
|
||
);
|
||
const remoteLogSelectedNodeStatusType = computed(() => {
|
||
const statusType = String(remoteLogSelectedNodeDetailData.value.status_type || "").trim();
|
||
return statusType || "info";
|
||
});
|
||
const remoteLogSelectedNodeSummaryText = computed(
|
||
() => String(remoteLogSelectedNodeDetailData.value.summary || "").trim() || "当前还没有节点级现场日志补充说明。"
|
||
);
|
||
const remoteLogCurrentLines = computed<string[]>(() => {
|
||
if (remoteLogSelectedNodeCode.value) {
|
||
return remoteLogSelectedNodeRecords.value
|
||
.map((item) => String(item?.line || "").trim())
|
||
.filter(Boolean);
|
||
}
|
||
return opsWorkerLogPreviewLines.value;
|
||
});
|
||
const remoteLogDrawerTitleText = computed(() =>
|
||
remoteLogSelectedNodeCode.value ? `节点现场日志:${remoteLogSelectedNodeCode.value}` : "远端日志样本"
|
||
);
|
||
const executionSceneSummaryText = computed(
|
||
() =>
|
||
String(executionParticipationSummary.value.summary || "").trim() ||
|
||
"当前还没有形成统一的执行现场摘要。"
|
||
);
|
||
const executionScenePhaseText = computed(() => {
|
||
const parts = [
|
||
String(executionScene.value.phase_label || "").trim(),
|
||
String(executionScene.value.phase_detail || "").trim(),
|
||
].filter(Boolean);
|
||
return parts.length ? parts.join(" / ") : "当前暂无活跃检测阶段。";
|
||
});
|
||
const executionParticipatingSummaryText = computed(() => {
|
||
const total = executionParticipatingNodes.value.length;
|
||
if (!total) {
|
||
return "当前没有节点处于执行中、已领待跑或近窗刚有吞吐状态。";
|
||
}
|
||
return `当前参与检测节点 ${total} 台,其中执行/已领 ${executionDispatchActiveNodes.value.length} 台,近窗刚有吞吐 ${executionRecentOnlyNodes.value.length} 台。`;
|
||
});
|
||
const executionStandbySummaryText = computed(() => {
|
||
const total = executionStandbyNodes.value.length;
|
||
if (!total) {
|
||
return "当前没有在线但未参与的节点。";
|
||
}
|
||
return `在线未参与节点 ${total} 台,其中纯待命 ${executionPureStandbyNodes.value.length} 台,负载待确认 ${executionLoadSyncingNodes.value.length} 台。`;
|
||
});
|
||
const executionFilteredSummaryText = computed(() => {
|
||
const total = executionFilteredNodes.value.length;
|
||
if (!total) {
|
||
return "当前筛选条件下没有匹配的执行节点。";
|
||
}
|
||
const dispatchActive = executionFilteredNodes.value.filter((row) => executionStateGroupKeyForRow(row) === "dispatch_active").length;
|
||
const recentThroughput = executionFilteredNodes.value.filter((row) => executionStateGroupKeyForRow(row) === "recent_throughput").length;
|
||
const loadSyncing = executionFilteredNodes.value.filter((row) => executionStateGroupKeyForRow(row) === "load_syncing").length;
|
||
const standby = executionFilteredNodes.value.filter((row) => executionStateGroupKeyForRow(row) === "standby").length;
|
||
return `当前视图共 ${total} 台节点,其中执行中/已领 ${dispatchActive} 台,近窗有吞吐 ${recentThroughput} 台,负载待确认 ${loadSyncing} 台,在线待命 ${standby} 台。`;
|
||
});
|
||
const capabilities = computed<GenericRecord[]>(() => capabilitiesState.value.capabilities || []);
|
||
const opsControlPlaneSummary = computed<GenericRecord>(() => capabilitiesState.value.control_plane || {});
|
||
const opsActionCatalog = computed<GenericRecord[]>(() => capabilitiesState.value.action_catalog || []);
|
||
const opsContractRegistrySummary = computed<GenericRecord>(() => capabilitiesState.value.contract_registry || {});
|
||
const opsContractRegistry = computed<GenericRecord>(() =>
|
||
Object.keys(contractsPayload.value || {}).length ? contractsPayload.value : {}
|
||
);
|
||
const opsContractRegistryVersion = computed(
|
||
() =>
|
||
String(opsContractRegistry.value.registry_version || opsContractRegistrySummary.value.registry_version || "").trim() || "-"
|
||
);
|
||
const opsContractRegistrySchemaVersion = computed(
|
||
() =>
|
||
String(opsContractRegistry.value.schema_version || opsContractRegistrySummary.value.schema_version || "").trim() || "-"
|
||
);
|
||
const opsContractRegistryTotal = computed(
|
||
() =>
|
||
Number(
|
||
opsContractRegistry.value.contracts_total ||
|
||
opsContractRegistrySummary.value.contracts_total ||
|
||
(Array.isArray(opsContractRegistry.value.contracts) ? opsContractRegistry.value.contracts.length : 0) ||
|
||
0
|
||
)
|
||
);
|
||
const opsContractDocsRoot = computed(
|
||
() => String(opsContractRegistry.value.docs_root || "").trim() || "docs/schemas"
|
||
);
|
||
const opsContracts = computed<GenericRecord[]>(() => opsContractRegistry.value.contracts || []);
|
||
const opsContractsByKey = computed<Record<string, GenericRecord>>(() => {
|
||
const mapping: Record<string, GenericRecord> = {};
|
||
opsContracts.value.forEach((item) => {
|
||
const key = String(item.key || "").trim();
|
||
if (key) {
|
||
mapping[key] = item;
|
||
}
|
||
});
|
||
return mapping;
|
||
});
|
||
const opsAgentContract = computed<GenericRecord>(() => opsContractsByKey.value.ops_agent_protocol || {});
|
||
const opsReleaseHubContract = computed<GenericRecord>(() => opsContractsByKey.value.release_hub_contract || {});
|
||
const opsDriverContract = computed<GenericRecord>(() => opsContractsByKey.value.ops_driver_contract || {});
|
||
const opsStackDiagnosisContract = computed<GenericRecord>(() => opsContractsByKey.value.ops_stack_diagnosis_contract || {});
|
||
const opsContractDiscoveryEntrypoints = computed<string[]>(() =>
|
||
(opsContractRegistry.value.discovery_entrypoints || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
);
|
||
const opsContractDiscoveryUrls = computed<string[]>(() => {
|
||
const host = opsRunbookEntrypointHost.value;
|
||
return opsContractDiscoveryEntrypoints.value.map((path) => {
|
||
if (!host) {
|
||
return path;
|
||
}
|
||
return `${host}${path.startsWith("/") ? path : `/${path}`}`;
|
||
});
|
||
});
|
||
const opsContractDetailEndpointPattern = computed(
|
||
() =>
|
||
String(opsContractRegistrySummary.value.detail_endpoint_pattern || "/api/v1/ops/contracts/{contract_key}").trim() ||
|
||
"/api/v1/ops/contracts/{contract_key}"
|
||
);
|
||
const buildOpsContractDetailPath = (contractKey: string) =>
|
||
opsContractDetailEndpointPattern.value.replace("{contract_key}", encodeURIComponent(String(contractKey || "").trim()));
|
||
const buildOpsContractDetailUrl = (contractKey: string) => {
|
||
const path = buildOpsContractDetailPath(contractKey);
|
||
const host = opsRunbookEntrypointHost.value;
|
||
if (!host) {
|
||
return path;
|
||
}
|
||
return `${host}${path.startsWith("/") ? path : `/${path}`}`;
|
||
};
|
||
const normalizeContractNavigationPayload = (payload: GenericRecord = {}) =>
|
||
((payload?.contract_navigation || {}) as GenericRecord);
|
||
const contractNavigationKeys = (payload: GenericRecord = {}) => {
|
||
const navigation = normalizeContractNavigationPayload(payload);
|
||
const navigationKeys = (navigation.contract_keys || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
if (navigationKeys.length) {
|
||
return navigationKeys;
|
||
}
|
||
return (payload?.contract_keys || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
};
|
||
const contractNavigationPrimaryKey = (payload: GenericRecord = {}) =>
|
||
String(normalizeContractNavigationPayload(payload).primary_contract_key || "").trim() || contractNavigationKeys(payload)[0] || "";
|
||
const contractNavigationContracts = (payload: GenericRecord = {}) => {
|
||
const navigation = normalizeContractNavigationPayload(payload);
|
||
const navigationContracts = (navigation.contracts || [])
|
||
.map((item: unknown) => ({ ...(item as GenericRecord) }))
|
||
.filter((item) => Boolean(String(item?.key || "").trim()));
|
||
if (navigationContracts.length) {
|
||
return navigationContracts;
|
||
}
|
||
return contractNavigationKeys(payload)
|
||
.map((key) => {
|
||
const registryItem = opsContractsByKey.value[key] || {};
|
||
return {
|
||
key,
|
||
title: String(registryItem.title || key).trim(),
|
||
status: String(registryItem.status || "").trim(),
|
||
version: String(registryItem.version || opsContractRegistrySchemaVersion.value || "").trim(),
|
||
summary: String(registryItem.summary || "").trim(),
|
||
primary_endpoint: String(registryItem.primary_endpoint || "").trim(),
|
||
schema_doc_path: String(registryItem.schema_doc_path || "").trim(),
|
||
detail_endpoint: buildOpsContractDetailPath(key),
|
||
};
|
||
})
|
||
.filter((item) => Boolean(String(item.key || "").trim()));
|
||
};
|
||
const contractVersionLabel = (contract: GenericRecord, fallback = opsContractRegistrySchemaVersion.value) =>
|
||
String(contract.version || fallback || "-").trim() || "-";
|
||
const contractPrimaryEndpoint = (contract: GenericRecord, fallback = "-") =>
|
||
String(contract.primary_endpoint || fallback || "-").trim() || "-";
|
||
const contractSchemaDocPath = (contract: GenericRecord, fallback = "-") =>
|
||
String(contract.schema_doc_path || fallback || "-").trim() || "-";
|
||
const contractSummaryText = (contract: GenericRecord, fallback = "当前未提供契约摘要。") =>
|
||
String(contract.summary || fallback || "").trim() || fallback;
|
||
const contractConsumersText = (contract: GenericRecord) => {
|
||
const values = (contract.consumers || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return values.length ? values.join(" / ") : "-";
|
||
};
|
||
const contractServiceKeysText = (contract: GenericRecord) => {
|
||
const values = (contract.service_keys || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return values.length ? values.join(" / ") : "-";
|
||
};
|
||
const contractDiscoveryPreviewText = (contract: GenericRecord) => {
|
||
const values = (contract.discovery_endpoints || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
if (!values.length) {
|
||
return opsContractDiscoveryUrls.value.join(" / ") || "-";
|
||
}
|
||
const preview = values.slice(0, 3).join(" / ");
|
||
return values.length > 3 ? `${preview} / +${values.length - 3}` : preview;
|
||
};
|
||
const contractRelatedKeys = (contract: GenericRecord) =>
|
||
(contract.related_contract_keys || []).map((item: unknown) => String(item || "").trim()).filter(Boolean);
|
||
const contractRelatedKeysText = (contract: GenericRecord) => {
|
||
const values = contractRelatedKeys(contract);
|
||
return values.length ? values.join(" / ") : "-";
|
||
};
|
||
const selectedOpsContractRegistryItem = computed<GenericRecord>(() => {
|
||
const key = String(selectedOpsContractKey.value || "").trim();
|
||
if (!key) {
|
||
return {};
|
||
}
|
||
return opsContractsByKey.value[key] || {};
|
||
});
|
||
const selectedOpsContractDetailData = computed<GenericRecord>(() => opsContractDetailPayload.value || {});
|
||
const selectedOpsContract = computed<GenericRecord>(() => {
|
||
const detailContract = selectedOpsContractDetailData.value.contract || {};
|
||
if (Object.keys(detailContract).length) {
|
||
return detailContract;
|
||
}
|
||
return selectedOpsContractRegistryItem.value;
|
||
});
|
||
const selectedOpsContractDetailEndpoint = computed(() => {
|
||
const detailEndpoint = String(selectedOpsContractDetailData.value.detail_endpoint || "").trim();
|
||
if (detailEndpoint) {
|
||
return detailEndpoint;
|
||
}
|
||
if (!selectedOpsContractKey.value) {
|
||
return "-";
|
||
}
|
||
return buildOpsContractDetailUrl(selectedOpsContractKey.value);
|
||
});
|
||
const selectedOpsContractRelatedKeys = computed<string[]>(() => {
|
||
const detailKeys = (selectedOpsContractDetailData.value.related_contract_keys || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
if (detailKeys.length) {
|
||
return detailKeys;
|
||
}
|
||
return contractRelatedKeys(selectedOpsContract.value);
|
||
});
|
||
const selectedOpsContractRelatedContracts = computed<GenericRecord[]>(() => {
|
||
const detailContracts = (selectedOpsContractDetailData.value.related_contracts || []).filter((item: unknown) => Boolean(item));
|
||
if (detailContracts.length) {
|
||
return detailContracts as GenericRecord[];
|
||
}
|
||
return selectedOpsContractRelatedKeys.value
|
||
.map((key) => opsContractsByKey.value[key] || { key, title: key })
|
||
.filter((item) => Boolean(item) && Boolean(String((item as GenericRecord).key || "").trim()));
|
||
});
|
||
const selectedOpsContractNotes = computed<string[]>(() =>
|
||
(selectedOpsContract.value.notes || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const opsAgentContractExecutionChain = computed(
|
||
() => "ops-center -> bootstrap-plan -> node-agent -> heartbeat/register"
|
||
);
|
||
const opsReleaseHubLaunchpadEndpoint = computed(
|
||
() => "/api/v1/ops/releases/launchpad"
|
||
);
|
||
const opsReleaseContractExecutionChain = computed(
|
||
() => "ops-center -> release-hub -> release-record -> rollout-gate"
|
||
);
|
||
const rolloutExecutionModeLabel = computed(() =>
|
||
rolloutForm.execution_mode === "ssh" ? "ssh" : "remote-agent"
|
||
);
|
||
const opsRolloutContractExecutionChain = computed(
|
||
() => `ops-center -> rollout-preview -> ${rolloutExecutionModeLabel.value} -> node-agent/service-executor`
|
||
);
|
||
const driverActionExecutorKindLabel = (value: string) => {
|
||
const normalized = String(value || "").trim();
|
||
if (normalized === "runbook_sequence") {
|
||
return "runbook sequence";
|
||
}
|
||
return normalized || "driver action";
|
||
};
|
||
const hasDriverActionContractPreview = (entry: GenericRecord) => {
|
||
const executorKind = String(entry?.executorKind || entry?.executor_kind || "driver_action").trim();
|
||
const sequenceKey = String(entry?.sequenceKey || entry?.sequence_key || "").trim();
|
||
const primaryActionCode = String(entry?.primaryActionCode || entry?.primary_action_code || "").trim();
|
||
const secondaryActionCode = String(entry?.secondaryActionCode || entry?.secondary_action_code || "").trim();
|
||
return Boolean(primaryActionCode || secondaryActionCode || (executorKind === "runbook_sequence" && sequenceKey));
|
||
};
|
||
const driverActionTargetApi = (entry: GenericRecord, secondary = false) => {
|
||
const targetApi = String(
|
||
secondary ? entry?.secondaryTargetApi || "" : entry?.targetApi || entry?.primaryTargetApi || ""
|
||
).trim();
|
||
if (targetApi) {
|
||
return targetApi;
|
||
}
|
||
const executorKind = String(entry?.executorKind || entry?.executor_kind || "driver_action").trim();
|
||
const sequenceKey = String(entry?.sequenceKey || entry?.sequence_key || "").trim();
|
||
if (executorKind === "runbook_sequence" && sequenceKey) {
|
||
return `/api/v1/ops/runbook/sequences/${sequenceKey}/execute`;
|
||
}
|
||
return "/api/v1/ops/driver-actions/execute";
|
||
};
|
||
const driverActionTargetMethod = (entry: GenericRecord, secondary = false) =>
|
||
String(secondary ? entry?.secondaryTargetMethod || "" : entry?.targetMethod || entry?.primaryTargetMethod || "").trim() || "POST";
|
||
const buildDriverActionRequestPayloadPreview = (entry: GenericRecord, secondary = false) => {
|
||
const executorKind = String(entry?.executorKind || entry?.executor_kind || "driver_action").trim() || "driver_action";
|
||
const sequenceKey = String(entry?.sequenceKey || entry?.sequence_key || "").trim();
|
||
const actionCode = String(
|
||
secondary ? entry?.secondaryActionCode || entry?.secondary_action_code || "" : entry?.primaryActionCode || entry?.primary_action_code || ""
|
||
).trim();
|
||
const nodeCodes = normalizeDriverNodeCodes(
|
||
secondary
|
||
? entry?.secondaryNodeCodes || entry?.secondary_node_codes || []
|
||
: entry?.primaryNodeCodes || entry?.primary_node_codes || []
|
||
);
|
||
const actionPayload = {
|
||
...(secondary ? entry?.secondaryActionPayload || entry?.secondary_action_payload || {} : entry?.primaryActionPayload || entry?.primary_action_payload || {}),
|
||
};
|
||
if (executorKind === "runbook_sequence" && sequenceKey) {
|
||
return {
|
||
secondary,
|
||
requested_by: "web-ui/ops-center",
|
||
...(Object.keys(actionPayload).length ? { action_payload: actionPayload } : {}),
|
||
};
|
||
}
|
||
return {
|
||
action_code: actionCode,
|
||
node_codes: nodeCodes,
|
||
action_payload: actionPayload,
|
||
requested_by: "web-ui/driver",
|
||
};
|
||
};
|
||
const buildDriverActionPreviewSection = (entry: GenericRecord, secondary = false) => {
|
||
const actionCode = String(
|
||
secondary ? entry?.secondaryActionCode || entry?.secondary_action_code || "" : entry?.primaryActionCode || entry?.primary_action_code || ""
|
||
).trim();
|
||
const label = String(
|
||
secondary ? entry?.secondaryLabel || entry?.secondary_label || "" : entry?.primaryLabel || entry?.primary_label || ""
|
||
).trim();
|
||
const nodeCodes = normalizeDriverNodeCodes(
|
||
secondary
|
||
? entry?.secondaryNodeCodes || entry?.secondary_node_codes || []
|
||
: entry?.primaryNodeCodes || entry?.primary_node_codes || []
|
||
);
|
||
const reason = String(
|
||
secondary
|
||
? entry?.secondaryExecutionReason || entry?.secondaryExecution?.reason || entry?.reason || ""
|
||
: entry?.primaryExecutionReason || entry?.primaryExecution?.reason || entry?.reason || ""
|
||
).trim();
|
||
const recommendationLabel = String(
|
||
secondary
|
||
? entry?.secondaryRecommendationLabel || entry?.secondaryExecution?.recommendation || ""
|
||
: entry?.recommendationLabel || entry?.primaryRecommendationLabel || entry?.primaryExecution?.recommendation || ""
|
||
).trim();
|
||
const automationLevelLabel = String(
|
||
secondary
|
||
? entry?.secondaryAutomationLevelLabel || entry?.secondaryExecution?.automation_level || ""
|
||
: entry?.automationLevelLabel || entry?.primaryAutomationLevelLabel || entry?.primaryExecution?.automation_level || ""
|
||
).trim();
|
||
const riskLevelLabel = String(
|
||
secondary
|
||
? entry?.secondaryRiskLevelLabel || entry?.secondaryExecution?.risk_level || ""
|
||
: entry?.riskLevelLabel || entry?.primaryRiskLevelLabel || entry?.primaryExecution?.risk_level || ""
|
||
).trim();
|
||
return {
|
||
label: label || (secondary ? "次动作" : "主动作"),
|
||
action_code: actionCode,
|
||
node_codes: nodeCodes,
|
||
target_api: driverActionTargetApi(entry, secondary),
|
||
target_method: driverActionTargetMethod(entry, secondary),
|
||
reason,
|
||
recommendation_label: recommendationLabel,
|
||
automation_level_label: automationLevelLabel,
|
||
risk_level_label: riskLevelLabel,
|
||
request_payload: buildDriverActionRequestPayloadPreview(entry, secondary),
|
||
};
|
||
};
|
||
const buildDriverActionContractPreview = (entry: GenericRecord, sourceLabel: string) => {
|
||
const executorKind = String(entry?.executorKind || entry?.executor_kind || "driver_action").trim() || "driver_action";
|
||
return {
|
||
source_label: sourceLabel,
|
||
title: String(entry?.title || "").trim() || "驾驶动作",
|
||
summary: String(entry?.summary || "").trim(),
|
||
reason: String(entry?.reason || "").trim(),
|
||
meta_text: String(entry?.nodeCodesText || entry?.metaText || entry?.meta_text || "").trim(),
|
||
executor_kind: executorKind,
|
||
executor_kind_label: driverActionExecutorKindLabel(executorKind),
|
||
execution_chain:
|
||
executorKind === "runbook_sequence"
|
||
? "ops-driver -> runbook-sequence -> resolved driver action / ui-intent"
|
||
: "ops-driver -> driver-action execute -> playbook / ui-intent / settings",
|
||
primary: buildDriverActionPreviewSection(entry, false),
|
||
secondary: buildDriverActionPreviewSection(entry, true),
|
||
};
|
||
};
|
||
const buildDriverActionContractPreviewRequest = (entry: GenericRecord, sourceLabel: string) => ({
|
||
source_label: sourceLabel,
|
||
title: String(entry?.title || "").trim() || "驾驶动作",
|
||
summary: String(entry?.summary || "").trim(),
|
||
reason: String(entry?.reason || "").trim(),
|
||
meta_text: String(entry?.nodeCodesText || entry?.metaText || entry?.meta_text || "").trim(),
|
||
executor_kind: String(entry?.executorKind || entry?.executor_kind || "driver_action").trim() || "driver_action",
|
||
sequence_key: String(entry?.sequenceKey || entry?.sequence_key || "").trim(),
|
||
primary_label: String(entry?.primaryLabel || entry?.primary_label || "").trim(),
|
||
secondary_label: String(entry?.secondaryLabel || entry?.secondary_label || "").trim(),
|
||
primary_action_code: String(entry?.primaryActionCode || entry?.primary_action_code || "").trim(),
|
||
secondary_action_code: String(entry?.secondaryActionCode || entry?.secondary_action_code || "").trim(),
|
||
primary_node_codes: normalizeDriverNodeCodes(entry?.primaryNodeCodes || entry?.primary_node_codes || entry?.nodeCodes || entry?.node_codes || []),
|
||
secondary_node_codes: normalizeDriverNodeCodes(entry?.secondaryNodeCodes || entry?.secondary_node_codes || entry?.nodeCodes || entry?.node_codes || []),
|
||
primary_action_payload: { ...(entry?.primaryActionPayload || entry?.primary_action_payload || {}) },
|
||
secondary_action_payload: { ...(entry?.secondaryActionPayload || entry?.secondary_action_payload || {}) },
|
||
requested_by: "web-ui/ops-center"
|
||
});
|
||
const buildResolvedDriverActionRequest = (
|
||
entry: GenericRecord,
|
||
options: {
|
||
secondary?: boolean;
|
||
confirm?: boolean;
|
||
sourceLabel?: string;
|
||
requestedBy?: string;
|
||
} = {}
|
||
) => ({
|
||
...buildDriverActionContractPreviewRequest(
|
||
entry,
|
||
String(options.sourceLabel || "ops-center").trim() || "ops-center"
|
||
),
|
||
secondary: Boolean(options.secondary),
|
||
confirm: Boolean(options.confirm),
|
||
include_secondary: true,
|
||
requested_by: String(options.requestedBy || "web-ui/ops-center").trim() || "web-ui/ops-center",
|
||
});
|
||
const buildCodexActionRequest = (
|
||
entry: GenericRecord,
|
||
options: {
|
||
secondary?: boolean;
|
||
confirm?: boolean;
|
||
sourceLabel?: string;
|
||
requestedBy?: string;
|
||
} = {}
|
||
) => ({
|
||
entry_key: String(entry?.key || "").trim(),
|
||
secondary: Boolean(options.secondary),
|
||
confirm: Boolean(options.confirm),
|
||
include_secondary: true,
|
||
source_label: String(options.sourceLabel || "web-ui/codex-brief").trim() || "web-ui/codex-brief",
|
||
requested_by: String(options.requestedBy || "web-ui/codex-brief").trim() || "web-ui/codex-brief",
|
||
});
|
||
const normalizeResolvedDriverActionContractPreview = (payload: GenericRecord, fallbackSourceLabel = "ops-center") => {
|
||
const preview = (payload?.preview || {}) as GenericRecord;
|
||
const gate = (payload?.gate || {}) as GenericRecord;
|
||
const previewRequest = (payload?.preview_request || {}) as GenericRecord;
|
||
return {
|
||
...preview,
|
||
preview_request: previewRequest,
|
||
selected_section:
|
||
String(payload?.selected_section || "").trim() ||
|
||
(gate?.secondary ? "secondary" : "primary"),
|
||
source_label:
|
||
String(preview?.source_label || previewRequest?.source_label || fallbackSourceLabel).trim() || fallbackSourceLabel,
|
||
title: String(preview?.title || previewRequest?.title || "").trim() || "驾驶动作",
|
||
summary: String(preview?.summary || previewRequest?.summary || "").trim(),
|
||
reason: String(preview?.reason || previewRequest?.reason || "").trim(),
|
||
meta_text: String(preview?.meta_text || previewRequest?.meta_text || "").trim(),
|
||
gate,
|
||
};
|
||
};
|
||
const normalizeCodexActionContractPreview = (payload: GenericRecord, fallbackSourceLabel = "codex-brief") => {
|
||
const selectedEntry = (payload?.selected_entry || {}) as GenericRecord;
|
||
const preview = (payload?.preview || {}) as GenericRecord;
|
||
const gate = (payload?.gate || {}) as GenericRecord;
|
||
return {
|
||
...preview,
|
||
entry_key: String(payload?.entry_key || selectedEntry?.key || "").trim(),
|
||
selected_entry: selectedEntry,
|
||
selected_section:
|
||
String(payload?.selected_section || "").trim() ||
|
||
(gate?.secondary ? "secondary" : "primary"),
|
||
source_label: String(preview?.source_label || fallbackSourceLabel).trim() || fallbackSourceLabel,
|
||
title: String(preview?.title || selectedEntry?.title || "").trim() || "驾驶动作",
|
||
summary: String(preview?.summary || selectedEntry?.summary || "").trim(),
|
||
reason: String(preview?.reason || selectedEntry?.reason || "").trim(),
|
||
meta_text: String(preview?.meta_text || selectedEntry?.meta_text || "").trim(),
|
||
gate,
|
||
};
|
||
};
|
||
const driverActionGatePromptText = (gate: GenericRecord) =>
|
||
[
|
||
`门禁结论:${String(gate?.decision_label || gate?.decision || "-").trim() || "-"}`,
|
||
String(gate?.decision_reason || "").trim(),
|
||
String(gate?.target_api || "").trim()
|
||
? `目标入口:${String(gate?.target_method || "POST").trim() || "POST"} ${String(gate?.target_api || "").trim()}`
|
||
: "",
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
const buildOverviewRecommendationDriverEntry = (): GenericRecord => ({
|
||
title: String(recommendation.value.title || recommendation.value.priority || "当前建议").trim(),
|
||
summary: String(recommendation.value.summary || "").trim(),
|
||
reason: String(recommendation.value.reason || "").trim(),
|
||
metaText: [
|
||
normalizeDriverNodeCodes(recommendation.value.primary_node_codes || recommendation.value.node_codes || []).join(" / "),
|
||
normalizeDriverNodeCodes(recommendation.value.secondary_node_codes || []).join(" / "),
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ; "),
|
||
executorKind: "driver_action",
|
||
primaryLabel: String(recommendation.value.primary_label || "").trim(),
|
||
secondaryLabel: String(recommendation.value.secondary_label || "").trim(),
|
||
primaryActionCode: String(recommendation.value.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(recommendation.value.secondary_action_code || "").trim(),
|
||
primaryNodeCodes: normalizeDriverNodeCodes(recommendation.value.primary_node_codes || recommendation.value.node_codes || []),
|
||
secondaryNodeCodes: normalizeDriverNodeCodes(recommendation.value.secondary_node_codes || recommendation.value.node_codes || []),
|
||
primaryActionPayload: { ...(recommendation.value.primary_action_payload || {}) },
|
||
secondaryActionPayload: { ...(recommendation.value.secondary_action_payload || {}) },
|
||
recommendationLabel: recommendationLevelLabel.value,
|
||
});
|
||
const recommendationPreviewable = computed(() => hasDriverActionContractPreview(buildOverviewRecommendationDriverEntry()));
|
||
const opsCodexDriverReady = computed(() => Boolean(opsControlPlaneSummary.value.codex_driver_ready));
|
||
const opsUiButtonReady = computed(() => Boolean(opsControlPlaneSummary.value.ui_button_ready));
|
||
const opsBlueprint = computed<GenericRecord>(() => blueprintPayload.value || {});
|
||
const opsBlueprintArchitecture = computed<GenericRecord>(() => opsBlueprint.value.architecture || {});
|
||
const opsBlueprintExecutionModel = computed<GenericRecord>(() => opsBlueprint.value.execution_model || {});
|
||
const opsBlueprintControlResponsibilities = computed<string[]>(() => opsBlueprintArchitecture.value.control_plane?.responsibility || []);
|
||
const opsBlueprintManagedResponsibilities = computed<string[]>(() => opsBlueprintArchitecture.value.managed_nodes?.responsibility || []);
|
||
const opsBlueprintJobResultFields = computed<string[]>(
|
||
() => opsBlueprintExecutionModel.value.job_result_contract?.required_fields || []
|
||
);
|
||
const opsBlueprintOperatorWithCodex = computed(
|
||
() => String(opsBlueprint.value.operator_model?.with_codex || "").trim() || "海外 Codex 作为智能驾驶员统一处理运维动作。"
|
||
);
|
||
const opsBlueprintOperatorWithoutCodex = computed(
|
||
() => String(opsBlueprint.value.operator_model?.without_codex || "").trim() || "后台按钮直接创建 ops job 交给控制面与 agent 执行。"
|
||
);
|
||
const opsBlueprintPhases = computed(() => ({
|
||
phase1: (opsBlueprint.value.mvp_scope?.phase_1 || []).map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||
phase2: (opsBlueprint.value.mvp_scope?.phase_2 || []).map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||
phase3: (opsBlueprint.value.mvp_scope?.phase_3 || []).map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||
}));
|
||
const opsRunbook = computed<GenericRecord>(() => runbookPayload.value || {});
|
||
const opsRunbookEntrypointRaw = computed(() => String(opsRunbook.value.entrypoint?.preferred_host || "").trim());
|
||
const opsRunbookApiPrefix = computed(() => String(opsRunbook.value.entrypoint?.api_prefix || "/api/v1").trim() || "/api/v1");
|
||
const opsRunbookEntrypointHost = computed(() => opsRunbookEntrypointRaw.value.replace(/\/api\/v1\/?$/, ""));
|
||
const opsRunbookApiBaseUrl = computed(() => {
|
||
const host = opsRunbookEntrypointHost.value;
|
||
if (!host) {
|
||
return "";
|
||
}
|
||
return `${host}${opsRunbookApiPrefix.value.startsWith("/") ? opsRunbookApiPrefix.value : `/${opsRunbookApiPrefix.value}`}`;
|
||
});
|
||
const opsRunbookReadinessStatus = computed(() => String(opsRunbook.value.current_readiness?.status || "").trim());
|
||
const opsRunbookReadinessLabel = computed(() => {
|
||
const status = opsRunbookReadinessStatus.value;
|
||
if (status === "ready") return "就绪";
|
||
if (status === "attention") return "待处理";
|
||
if (status === "blocking") return "阻断";
|
||
return status || "未知";
|
||
});
|
||
const opsRunbookReadinessTagType = computed(() => {
|
||
const status = opsRunbookReadinessStatus.value;
|
||
if (status === "ready") return "success";
|
||
if (status === "attention") return "warning";
|
||
if (status === "blocking") return "danger";
|
||
return "info";
|
||
});
|
||
const opsRunbookReadinessSummary = computed(
|
||
() => String(opsRunbook.value.current_readiness?.summary || "").trim() || "当前还没有运行手册摘要。"
|
||
);
|
||
const opsRunbookServices = computed(() =>
|
||
Object.entries(opsRunbook.value.services || {}).map(([key, value]) => {
|
||
const row = (value || {}) as GenericRecord;
|
||
const status = String(row.status || "").trim();
|
||
return {
|
||
key,
|
||
title: key === "sync_agent" ? "Sync Agent" : key.toUpperCase(),
|
||
service_name: String(row.service_name || "").trim(),
|
||
status,
|
||
status_label: status === "enabled" ? "在线" : status === "disabled" ? "离线" : status || "-",
|
||
};
|
||
})
|
||
);
|
||
const opsRunbookFastChecks = computed<string[]>(() =>
|
||
(opsRunbook.value.fast_checks || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const opsRunbookFastCheckCommands = computed<string[]>(() => {
|
||
const host = opsRunbookEntrypointHost.value;
|
||
return opsRunbookFastChecks.value.map((path) => {
|
||
if (!host) {
|
||
return `curl -fsS "${path}"`;
|
||
}
|
||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||
return `curl -fsS "${host}${normalizedPath}"`;
|
||
});
|
||
});
|
||
const opsRunbookControlSequences = computed<GenericRecord[]>(() =>
|
||
(opsRunbook.value.control_sequences || []).filter((item: unknown) => Boolean(item))
|
||
);
|
||
const opsRunbookControlSequenceCards = computed(() =>
|
||
opsRunbookControlSequences.value.map((raw) => {
|
||
const targetNodeCodes = normalizeDriverNodeCodes(raw.target_node_codes || []);
|
||
const primaryActionPayload = { ...(raw.primary_action_payload || {}) };
|
||
const secondaryActionPayload = { ...(raw.secondary_action_payload || {}) };
|
||
const targetScopeLabel = String(raw.target_scope_label || "").trim();
|
||
const stepTitles = (raw.step_titles || []).map((item: unknown) => String(item || "").trim()).filter(Boolean);
|
||
const primaryResolution = { ...(raw.primary_resolution || {}) };
|
||
const secondaryResolution = { ...(raw.secondary_resolution || {}) };
|
||
const primaryResolvedLabel = String(primaryResolution.driver_action_label || primaryResolution.driver_action_code || "").trim();
|
||
const primaryResolvedCode = String(primaryResolution.driver_action_code || "").trim();
|
||
const primaryResolvedNodes = normalizeDriverNodeCodes(primaryResolution.driver_node_codes || []);
|
||
const secondaryResolvedLabel = String(secondaryResolution.driver_action_label || secondaryResolution.driver_action_code || "").trim();
|
||
const secondaryResolvedCode = String(secondaryResolution.driver_action_code || "").trim();
|
||
const secondaryResolvedNodes = normalizeDriverNodeCodes(secondaryResolution.driver_node_codes || []);
|
||
const resolutionLines: string[] = [];
|
||
if (primaryResolution.ok === false) {
|
||
resolutionLines.push(`当前解析失败:${String(primaryResolution.message || "解析失败").trim()}`);
|
||
} else if (primaryResolvedLabel || primaryResolvedCode) {
|
||
resolutionLines.push(
|
||
`当前解析:${primaryResolvedLabel || primaryResolvedCode}${
|
||
primaryResolvedCode && primaryResolvedLabel && primaryResolvedCode !== primaryResolvedLabel ? ` (${primaryResolvedCode})` : ""
|
||
}`
|
||
);
|
||
resolutionLines.push(`当前目标:${primaryResolvedNodes.length ? primaryResolvedNodes.join(" / ") : "按后端实时判断"}`);
|
||
}
|
||
if (secondaryResolution.ok === false) {
|
||
resolutionLines.push(`备用解析失败:${String(secondaryResolution.message || "解析失败").trim()}`);
|
||
} else if (String(raw.secondary_label || "").trim() && (secondaryResolvedLabel || secondaryResolvedCode)) {
|
||
resolutionLines.push(
|
||
`备用解析:${secondaryResolvedLabel || secondaryResolvedCode}${
|
||
secondaryResolvedCode && secondaryResolvedLabel && secondaryResolvedCode !== secondaryResolvedLabel ? ` (${secondaryResolvedCode})` : ""
|
||
}`
|
||
);
|
||
if (secondaryResolvedNodes.length) {
|
||
resolutionLines.push(`备用目标:${secondaryResolvedNodes.join(" / ")}`);
|
||
}
|
||
}
|
||
if (primaryResolution.resolved_at) {
|
||
resolutionLines.push(`解析时间:${String(primaryResolution.resolved_at || "").trim()}`);
|
||
}
|
||
const nodeScopeText = [targetScopeLabel, targetNodeCodes.length ? targetNodeCodes.join(" / ") : "稍后选择目标节点"]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return {
|
||
key: String(raw.key || "").trim() || "runbook-sequence",
|
||
title: String(raw.title || "").trim() || "标准作业路径",
|
||
levelLabel: String(raw.status_label || raw.status || "").trim() || "ready",
|
||
tagType: String(raw.tag_type || "").trim() || "info",
|
||
summary: String(raw.summary || "").trim(),
|
||
reason: String(raw.reason || "").trim(),
|
||
nodeCodesText: nodeScopeText ? `目标:${nodeScopeText}` : "",
|
||
stepTitlesText: stepTitles.length ? `步骤:${stepTitles.join(" -> ")}` : "",
|
||
resolutionLines,
|
||
primaryLabel: String(raw.primary_label || "").trim() || "执行",
|
||
secondaryLabel: String(raw.secondary_label || "").trim(),
|
||
disabled: !String(raw.primary_action_code || "").trim(),
|
||
disabledSecondary: !String(raw.secondary_action_code || "").trim(),
|
||
primaryAction: () =>
|
||
runDriverActionCode(String(raw.primary_action_code || ""), targetNodeCodes, primaryActionPayload),
|
||
secondaryAction: () =>
|
||
runDriverActionCode(String(raw.secondary_action_code || ""), targetNodeCodes, secondaryActionPayload),
|
||
};
|
||
})
|
||
);
|
||
const actionTemplates = computed<GenericRecord[]>(() => actionTemplatesPayload.value.items || []);
|
||
const actionTemplateDefaults = computed(() => actionTemplatesPayload.value.defaults || {});
|
||
const playbooks = computed<GenericRecord[]>(() => playbooksPayload.value.items || []);
|
||
const playbookRuns = computed<GenericRecord[]>(() => playbookRunsPayload.value.runs || []);
|
||
const playbookRunsSummary = computed<GenericRecord>(() => playbookRunsPayload.value.summary || {});
|
||
const playbookRunEvents = computed<GenericRecord[]>(() => playbookRunEventsPayload.value.events || []);
|
||
const playbookRunEventsSummary = computed<GenericRecord>(() => playbookRunEventsPayload.value.summary || {});
|
||
const activityStreamItems = computed<GenericRecord[]>(() => activityStreamPayload.value.items || []);
|
||
const activityStreamSummary = computed<GenericRecord>(() => activityStreamPayload.value.summary || {});
|
||
const activityStreamKindCounts = computed<GenericRecord>(() => activityStreamSummary.value.kind_counts || {});
|
||
const activityStreamRunbookCount = computed(() => Number(activityStreamKindCounts.value.runbook_sequence || 0));
|
||
const activityStreamSummaryContracts = computed<GenericRecord[]>(() => contractNavigationContracts(activityStreamPayload.value));
|
||
const codexBrief = computed<GenericRecord>(() => codexBriefPayload.value || {});
|
||
const codexBriefSummary = computed<GenericRecord>(() => codexBrief.value.summary || {});
|
||
const codexBriefFocus = computed<GenericRecord>(() => codexBrief.value.focus || {});
|
||
const codexAutomationCoverage = computed<GenericRecord>(() => {
|
||
const coverage = (codexBrief.value.automation_coverage || {}) as GenericRecord;
|
||
const summary = codexBriefSummary.value || {};
|
||
return {
|
||
total: Number(coverage.total ?? summary.total ?? 0),
|
||
safe_auto_total: Number(coverage.safe_auto_total ?? summary.automation_level_counts?.safe_auto ?? 0),
|
||
guarded_auto_total: Number(coverage.guarded_auto_total ?? summary.automation_level_counts?.guarded_auto ?? 0),
|
||
mixed_total: Number(coverage.mixed_total ?? summary.automation_level_counts?.mixed ?? 0),
|
||
ui_only_total: Number(coverage.ui_only_total ?? summary.automation_level_counts?.ui_only ?? 0),
|
||
blocked_total: Number(coverage.blocked_total ?? summary.automation_level_counts?.blocked ?? 0),
|
||
preview_only_total: Number(coverage.preview_only_total ?? summary.preview_only_total ?? 0),
|
||
backend_handled_total: Number(coverage.backend_handled_total ?? summary.backend_handled_total ?? 0),
|
||
execution_ready_total: Number(
|
||
coverage.execution_ready_total
|
||
?? (Number(summary.auto_execute_total || 0) + Number(summary.confirm_then_execute_total || 0))
|
||
),
|
||
human_dependency_total: Number(
|
||
coverage.human_dependency_total
|
||
?? (Number(summary.resolve_first_total || 0) + Number(summary.open_ui_total || 0) + Number(summary.blocked_total || 0))
|
||
),
|
||
launch_status: String(coverage.launch_status ?? summary.launch_status ?? "").trim(),
|
||
launch_ready: Boolean(coverage.launch_ready ?? summary.launch_ready ?? false),
|
||
};
|
||
});
|
||
const codexBriefSummaryContracts = computed<GenericRecord[]>(() => contractNavigationContracts(codexBrief.value));
|
||
const codexBriefFocusContracts = computed<GenericRecord[]>(() => contractNavigationContracts(codexBriefFocus.value));
|
||
const codexBriefHeadline = computed(() => String(codexBrief.value.headline || "").trim());
|
||
const driverFeed = computed<GenericRecord>(() => driverFeedPayload.value || {});
|
||
const driverFeedSummary = computed<GenericRecord>(() => driverFeed.value.summary || {});
|
||
const driverFeedHeadline = computed(() => String(driverFeed.value.headline || "").trim());
|
||
const driverFeedSummaryContracts = computed<GenericRecord[]>(() => contractNavigationContracts(driverFeed.value));
|
||
const sceneLogObservationTagType = (status: string) => {
|
||
const normalized = String(status || "").trim();
|
||
if (normalized === "disabled") {
|
||
return "danger";
|
||
}
|
||
if (normalized === "waiting_sample") {
|
||
return "warning";
|
||
}
|
||
if (normalized === "ready") {
|
||
return "success";
|
||
}
|
||
return "info";
|
||
};
|
||
const normalizeSceneLogObservation = (raw: GenericRecord = {}) => {
|
||
const status = String(raw.status || "").trim() || "standby";
|
||
return {
|
||
title: "现场日志观察",
|
||
status,
|
||
statusLabel: String(raw.status_label || "").trim() || "待命",
|
||
summary: String(raw.summary || "").trim() || "当前还没有现场日志观察结果。",
|
||
tagType: sceneLogObservationTagType(status),
|
||
targetNodeCode: String(raw.target_node_code || "").trim(),
|
||
focusRef: { ...(raw.focus_ref || {}) },
|
||
participatingCount: Number(raw.participating_node_count || 0),
|
||
coveredCount: Number(raw.covered_participating_node_count || 0),
|
||
lineCount: Number(raw.line_count || 0),
|
||
contractNavigation: { ...(raw.contract_navigation || {}) },
|
||
};
|
||
};
|
||
const driverFeedSceneLogObservation = computed(() =>
|
||
normalizeSceneLogObservation((driverFeed.value.scene_log_observation || {}) as GenericRecord)
|
||
);
|
||
const codexBriefSceneLogObservation = computed(() =>
|
||
normalizeSceneLogObservation((codexBrief.value.scene_log_observation || {}) as GenericRecord)
|
||
);
|
||
const driverFeedSceneLogContracts = computed<GenericRecord[]>(() => contractNavigationContracts(driverFeedSceneLogObservation.value));
|
||
const codexBriefSceneLogContracts = computed<GenericRecord[]>(() => contractNavigationContracts(codexBriefSceneLogObservation.value));
|
||
const codexAutomationCoverageLaunchStatusLabel = computed(() => {
|
||
const status = String(codexAutomationCoverage.value.launch_status || "").trim();
|
||
if (status === "ready") return "自动化就绪";
|
||
if (status === "attention") return "自动化待处理";
|
||
if (status === "blocked") return "自动化阻断";
|
||
return status || "自动化未知";
|
||
});
|
||
const codexAutomationCoverageLaunchTagType = computed(() => {
|
||
const status = String(codexAutomationCoverage.value.launch_status || "").trim();
|
||
if (status === "ready") return "success";
|
||
if (status === "attention") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const codexAutomationCoverageLaunchSummary = computed(() => {
|
||
const coverage = codexAutomationCoverage.value;
|
||
const ready = Number(coverage.execution_ready_total || 0);
|
||
const human = Number(coverage.human_dependency_total || 0);
|
||
const previewOnly = Number(coverage.preview_only_total || 0);
|
||
const backendHandled = Number(coverage.backend_handled_total || 0);
|
||
const total = Number(coverage.total || codexBriefSummary.value.total || 0);
|
||
const statusLabel = codexAutomationCoverageLaunchStatusLabel.value;
|
||
if (coverage.launch_status === "ready") {
|
||
return `${statusLabel},当前 ${ready} 条动作已进入可执行车道,后端已接管 ${backendHandled}/${total} 条。`;
|
||
}
|
||
if (coverage.launch_status === "blocked") {
|
||
return `${statusLabel},当前仍有 ${human} 条动作依赖人工复核或排阻断;只读预览 ${previewOnly} 条。`;
|
||
}
|
||
return `${statusLabel},当前 ${ready} 条动作可继续推进,另有 ${human} 条动作仍需人工介入;只读预览 ${previewOnly} 条。`;
|
||
});
|
||
const formatOpsSurfaceLabel = (surface: string) => {
|
||
const normalizedSurface = String(surface || "").trim();
|
||
if (normalizedSurface === "release-launchpad" || normalizedSurface === "release-hub") return "Release Hub";
|
||
if (normalizedSurface === "codex-brief") return "自动驾驶判断";
|
||
if (normalizedSurface === "go-live-summary") return "上线收口详情";
|
||
if (normalizedSurface === "go-live-review") return "正式复核详情";
|
||
if (normalizedSurface === "execution-scene") return "执行现场";
|
||
if (normalizedSurface === "managed-nodes") return "托管节点";
|
||
if (normalizedSurface === "driver-feed") return "驾驶摘要";
|
||
if (normalizedSurface === "contracts") return "契约注册表";
|
||
if (normalizedSurface === "stack-summary") return "总检摘要";
|
||
return normalizedSurface || "待判定";
|
||
};
|
||
const labelDoctorDecisionCommandKey = (key: string) => {
|
||
const normalizedKey = String(key || "").trim();
|
||
if (normalizedKey === "doctor_decision") return "doctor 复核";
|
||
if (normalizedKey === "primary_recovery") return "主建议";
|
||
if (normalizedKey === "go_live_review") return "证据复核";
|
||
if (normalizedKey === "go_live_signoff") return "最终签收";
|
||
if (normalizedKey === "stack_check" || normalizedKey === "stack_diagnosis") return "总检摘要";
|
||
if (normalizedKey === "doctor") return "总检快检";
|
||
if (normalizedKey.startsWith("follow_up_")) {
|
||
return `跟进 ${normalizedKey.replace("follow_up_", "")}`;
|
||
}
|
||
return normalizedKey || "推荐命令";
|
||
};
|
||
const buildFallbackDoctorDecisionData = (): GenericRecord => {
|
||
const stackStatus = String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim();
|
||
const goLiveStatus = String(goLiveSummary.value.go_live_status || "").trim();
|
||
const publishStatus = String(goLiveSummary.value.publish_status || "").trim();
|
||
const status = [stackStatus, goLiveStatus, publishStatus].includes("blocked")
|
||
? "blocked"
|
||
: [stackStatus, goLiveStatus, publishStatus].includes("attention")
|
||
? "attention"
|
||
: "ready";
|
||
const lane = String(opsStackDiagnosisOperatorDecision.value.lane || "").trim();
|
||
const focusRef = (opsStackDiagnosisOperatorDecision.value.next_focus || {}) as GenericRecord;
|
||
const preferredSurface = String(focusRef.kind || "").trim() === "node_scene_log"
|
||
? "execution-scene"
|
||
: lane === "node_handover"
|
||
? "managed-nodes"
|
||
: lane === "release"
|
||
? "release-hub"
|
||
: lane === "observability"
|
||
? "execution-scene"
|
||
: ["ops_jobs", "steady"].includes(lane)
|
||
? "driver-feed"
|
||
: "stack-summary";
|
||
const primaryCommand = String(opsStackDiagnosisNextActions.value.primary_command || "").trim();
|
||
const secondaryCommand = String(opsStackDiagnosisNextActions.value.secondary_command || "").trim();
|
||
const baseUrl = String(controlPlaneBaseUrl.value || "").trim() || "http://127.0.0.1:8100";
|
||
|
||
return {
|
||
generated_at: String(goLiveSummary.value.generated_at || opsStackDiagnosis.value.generated_at || "").trim(),
|
||
status,
|
||
status_label: status === "ready" ? "主决策已就绪" : status === "attention" ? "主决策待复核" : "主决策阻断",
|
||
headline:
|
||
String(opsStackDiagnosisOperatorDecision.value.title || "").trim()
|
||
|| String(opsStackDiagnosisDiagnosis.value.headline || "").trim()
|
||
|| "当前还没有生成总检主决策。",
|
||
detail:
|
||
String(opsStackDiagnosisOperatorDecision.value.summary || "").trim()
|
||
|| String(opsStackDiagnosisNextStep.value.reason || "").trim()
|
||
|| String(goLiveSummary.value.next_step_reason || "").trim()
|
||
|| "建议先沿着 stack next 收口当前问题。",
|
||
source: "live_fallback",
|
||
report_dir: "",
|
||
manifest_path: "",
|
||
decision_available: true,
|
||
decision: {
|
||
status,
|
||
reason_code:
|
||
String(opsStackDiagnosisOperatorDecision.value.reason_code || "").trim()
|
||
|| String(opsStackDiagnosisNextStep.value.source || "").trim()
|
||
|| "live_fallback",
|
||
headline:
|
||
String(opsStackDiagnosisOperatorDecision.value.title || "").trim()
|
||
|| String(opsStackDiagnosisDiagnosis.value.headline || "").trim(),
|
||
detail:
|
||
String(opsStackDiagnosisOperatorDecision.value.summary || "").trim()
|
||
|| String(opsStackDiagnosisNextStep.value.reason || "").trim(),
|
||
preferred_surface: preferredSurface,
|
||
next_action_code:
|
||
String(opsStackDiagnosisOperatorDecision.value.primary_command_key || "").trim()
|
||
|| String(opsStackDiagnosisNextStep.value.action_code || "").trim()
|
||
|| String(goLiveSummary.value.next_step_action_code || "").trim(),
|
||
recommended_commands: [primaryCommand, secondaryCommand].filter(Boolean),
|
||
evidence: {}
|
||
},
|
||
summary: {
|
||
ok: status === "ready",
|
||
required_failures: [],
|
||
optional_unavailable: [],
|
||
scene_log_reports_total: 0,
|
||
scene_log_reports_ok: 0,
|
||
scene_log_status_counts: {},
|
||
scene_log_reports: [],
|
||
scene_log_problem_node_codes: [],
|
||
contract_surface_gaps: [],
|
||
launchpad_recommended_target_node_code: String(goLiveSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
launchpad_recommended_recovery_label: String(goLiveSummary.value.launchpad_recommended_recovery_label || "").trim(),
|
||
launchpad_recommended_recovery_summary: String(goLiveSummary.value.launchpad_recommended_recovery_summary || "").trim(),
|
||
launchpad_onboarding_bootstrap_pending_nodes: Number(goLiveSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0),
|
||
launchpad_onboarding_acceptance_ready_nodes: Number(goLiveSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0)
|
||
},
|
||
recommended_commands: {
|
||
doctor_decision: buildOpsScriptCommand("drive_ops_center.sh", "doctor-decision", baseUrl),
|
||
primary_recovery: primaryCommand,
|
||
follow_up_1: secondaryCommand,
|
||
stack_check: buildOpsScriptCommand("drive_ops_center.sh", "stack-diagnosis", baseUrl, "summary"),
|
||
stack_diagnosis: buildOpsScriptCommand("drive_ops_center.sh", "stack-diagnosis", baseUrl, "summary")
|
||
}
|
||
};
|
||
};
|
||
const doctorDecision = computed<GenericRecord>(() => {
|
||
if (Object.keys(doctorDecisionPayload.value || {}).length) {
|
||
return doctorDecisionPayload.value || {};
|
||
}
|
||
return buildFallbackDoctorDecisionData();
|
||
});
|
||
const doctorDecisionStatus = computed(() => String(doctorDecision.value.status || "").trim());
|
||
const doctorDecisionStatusLabel = computed(() => {
|
||
const status = doctorDecisionStatus.value;
|
||
if (status === "ready") return "主决策已就绪";
|
||
if (status === "attention") return "主决策待复核";
|
||
if (status === "blocked") return "主决策阻断";
|
||
if (status === "missing") return "待生成";
|
||
return status || "主决策未知";
|
||
});
|
||
const doctorDecisionStatusType = computed(() => {
|
||
const status = doctorDecisionStatus.value;
|
||
if (status === "ready") return "success";
|
||
if (status === "attention" || status === "missing") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const doctorDecisionHeadline = computed(
|
||
() => String(doctorDecision.value.headline || "").trim() || "当前还没有生成总检主决策。"
|
||
);
|
||
const doctorDecisionDetail = computed(
|
||
() => String(doctorDecision.value.detail || (doctorDecision.value.decision || {}).detail || "").trim()
|
||
|| "当前没有额外的总检主决策补充说明。"
|
||
);
|
||
const doctorDecisionSummary = computed<GenericRecord>(() => (doctorDecision.value.summary || {}) as GenericRecord);
|
||
const doctorDecisionDecision = computed<GenericRecord>(() => (doctorDecision.value.decision || {}) as GenericRecord);
|
||
const doctorDecisionPreferredSurfaceKey = computed(() => String(doctorDecisionDecision.value.preferred_surface || "").trim());
|
||
const doctorDecisionPreferredSurfaceLabel = computed(() => formatOpsSurfaceLabel(doctorDecisionPreferredSurfaceKey.value));
|
||
const doctorDecisionNextActionCode = computed(() =>
|
||
String(doctorDecisionDecision.value.next_action_code || "").trim() || "待生成"
|
||
);
|
||
const doctorDecisionReasonCode = computed(() =>
|
||
String(doctorDecisionDecision.value.reason_code || "").trim() || "未标记"
|
||
);
|
||
const doctorDecisionSceneLogSummary = computed(() => {
|
||
const total = Number(doctorDecisionSummary.value.scene_log_reports_total || 0);
|
||
const ok = Number(doctorDecisionSummary.value.scene_log_reports_ok || 0);
|
||
const problemNodes = (doctorDecisionSummary.value.scene_log_problem_node_codes || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
if (!total) {
|
||
return "当前没有现场附件摘要";
|
||
}
|
||
return problemNodes.length
|
||
? `就绪 ${ok}/${total},问题节点 ${problemNodes.join(" / ")}`
|
||
: `就绪 ${ok}/${total},当前附件摘要已齐`;
|
||
});
|
||
const doctorDecisionLaunchpadSummary = computed(() => {
|
||
const targetNodeCode = String(doctorDecisionSummary.value.launchpad_recommended_target_node_code || "").trim();
|
||
const recoveryLabel = String(doctorDecisionSummary.value.launchpad_recommended_recovery_label || "").trim();
|
||
const bootstrapPendingNodes = Number(doctorDecisionSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0);
|
||
const acceptanceReadyNodes = Number(doctorDecisionSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0);
|
||
const tail = bootstrapPendingNodes > 0 || acceptanceReadyNodes > 0
|
||
? `待补安装 ${bootstrapPendingNodes} / 待验收 ${acceptanceReadyNodes}`
|
||
: "当前没有额外接入缺口";
|
||
return [recoveryLabel, targetNodeCode, tail].filter(Boolean).join(" / ") || "当前没有额外的 launchpad 摘要";
|
||
});
|
||
const doctorDecisionRequiredFailures = computed<string[]>(() =>
|
||
(doctorDecisionSummary.value.required_failures || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const doctorDecisionOptionalUnavailable = computed<string[]>(() =>
|
||
(doctorDecisionSummary.value.optional_unavailable || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const doctorDecisionContractSurfaceGaps = computed<string[]>(() =>
|
||
(doctorDecisionSummary.value.contract_surface_gaps || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const doctorDecisionSceneLogProblemNodeCodes = computed<string[]>(() =>
|
||
(doctorDecisionSummary.value.scene_log_problem_node_codes || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const doctorDecisionReportDirLabel = computed(() => String(doctorDecision.value.report_dir || "").trim() || "当前没有绑定报告目录");
|
||
const doctorDecisionManifestPathLabel = computed(() => String(doctorDecision.value.manifest_path || "").trim() || "当前没有 manifest");
|
||
const doctorDecisionRecommendedCommandEntries = computed(() =>
|
||
Object.entries((doctorDecision.value.recommended_commands || {}) as Record<string, unknown>)
|
||
.map(([key, command]) => ({
|
||
key,
|
||
label: labelDoctorDecisionCommandKey(key),
|
||
command: String(command || "").trim()
|
||
}))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const doctorDecisionPrimaryCommand = computed(() => {
|
||
const primaryEntry = doctorDecisionRecommendedCommandEntries.value.find((item) => item.key === "primary_recovery");
|
||
return String(primaryEntry?.command || doctorDecisionRecommendedCommandEntries.value[0]?.command || "").trim();
|
||
});
|
||
const doctorDecisionContracts = computed<GenericRecord[]>(() => contractNavigationContracts(doctorDecision.value));
|
||
const doctorDecisionPrimaryContract = computed<GenericRecord>(() => {
|
||
const primaryKey = contractNavigationPrimaryKey(doctorDecision.value);
|
||
return (
|
||
doctorDecisionContracts.value.find((item) => String(item.key || "").trim() === primaryKey)
|
||
|| doctorDecisionContracts.value[0]
|
||
|| {}
|
||
);
|
||
});
|
||
const buildFallbackGoLiveSignoffData = (): GenericRecord => {
|
||
const launchpadTargetNodeCodes = [
|
||
String(goLiveSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
String(driverFeedSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
String(codexBriefSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
String(releaseLaunchpadStatus.value.recommended_target_node_code || "").trim(),
|
||
].filter(Boolean);
|
||
const launchpadRecoveryLabels = [
|
||
String(goLiveSummary.value.launchpad_recommended_recovery_label || "").trim(),
|
||
String(driverFeedSummary.value.launchpad_recommended_recovery_label || "").trim(),
|
||
String(codexBriefSummary.value.launchpad_recommended_recovery_label || "").trim(),
|
||
String(releaseLaunchpadStatus.value.recommended_recovery_label || "").trim(),
|
||
].filter(Boolean);
|
||
const launchpadRecoverySummaries = [
|
||
String(goLiveSummary.value.launchpad_recommended_recovery_summary || "").trim(),
|
||
String(driverFeedSummary.value.launchpad_recommended_recovery_summary || "").trim(),
|
||
String(codexBriefSummary.value.launchpad_recommended_recovery_summary || "").trim(),
|
||
String(releaseLaunchpadStatus.value.recommended_recovery_summary || "").trim(),
|
||
].filter(Boolean);
|
||
const launchpadBootstrapPendingNodes = [
|
||
Number(goLiveSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0),
|
||
Number(driverFeedSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0),
|
||
Number(codexBriefSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0),
|
||
Number(releaseLaunchpadStatus.value.onboarding_bootstrap_pending_nodes || 0),
|
||
];
|
||
const launchpadAcceptanceReadyNodes = [
|
||
Number(goLiveSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0),
|
||
Number(driverFeedSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0),
|
||
Number(codexBriefSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0),
|
||
Number(releaseLaunchpadStatus.value.onboarding_acceptance_ready_nodes || 0),
|
||
];
|
||
const launchpadAlignment = {
|
||
consistent: true,
|
||
target_node_code_consistent: new Set(launchpadTargetNodeCodes).size <= 1,
|
||
recovery_label_consistent: new Set(launchpadRecoveryLabels).size <= 1,
|
||
recovery_summary_consistent: new Set(launchpadRecoverySummaries).size <= 1,
|
||
bootstrap_pending_consistent: new Set(launchpadBootstrapPendingNodes).size <= 1,
|
||
acceptance_ready_consistent: new Set(launchpadAcceptanceReadyNodes).size <= 1,
|
||
target_node_codes: {
|
||
go_live_summary: String(goLiveSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
driver_feed: String(driverFeedSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
codex_brief: String(codexBriefSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
release_launchpad: String(releaseLaunchpadStatus.value.recommended_target_node_code || "").trim(),
|
||
},
|
||
};
|
||
launchpadAlignment.consistent =
|
||
launchpadAlignment.target_node_code_consistent &&
|
||
launchpadAlignment.recovery_label_consistent &&
|
||
launchpadAlignment.recovery_summary_consistent &&
|
||
launchpadAlignment.bootstrap_pending_consistent &&
|
||
launchpadAlignment.acceptance_ready_consistent;
|
||
|
||
const blockedReasons: string[] = [];
|
||
const attentionReasons: string[] = [];
|
||
if (goLiveSummaryStatus.value === "blocked") blockedReasons.push("go_live_summary=blocked");
|
||
else if (goLiveSummaryStatus.value === "attention") attentionReasons.push("go_live_summary=attention");
|
||
if (goLivePublishStatus.value === "blocked") blockedReasons.push("publish_status=blocked");
|
||
else if (goLivePublishStatus.value === "attention") attentionReasons.push("publish_status=attention");
|
||
if (String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim() === "blocked") blockedReasons.push("stack_diagnosis=blocked");
|
||
else if (String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim() === "attention") attentionReasons.push("stack_diagnosis=attention");
|
||
if (String(codexAutomationCoverage.value.launch_status || "").trim() === "blocked") blockedReasons.push("automation=blocked");
|
||
else if (String(codexAutomationCoverage.value.launch_status || "").trim() === "attention") attentionReasons.push("automation=attention");
|
||
if (String(releaseLaunchpadStatus.value.status || "").trim() === "blocked") blockedReasons.push("release_launchpad=blocked");
|
||
else if (String(releaseLaunchpadStatus.value.status || "").trim() === "attention") attentionReasons.push("release_launchpad=attention");
|
||
if (!launchpadAlignment.consistent) {
|
||
attentionReasons.push("launchpad_alignment=inconsistent");
|
||
}
|
||
|
||
const signoffStatus = blockedReasons.length ? "blocked" : attentionReasons.length ? "attention" : "ready";
|
||
return {
|
||
generated_at: String(goLiveSummary.value.generated_at || opsStackDiagnosis.value.generated_at || "").trim(),
|
||
signoff_status: signoffStatus,
|
||
status_label:
|
||
signoffStatus === "ready" ? "可签字上线" : signoffStatus === "attention" ? "待签字复核" : "暂不可签字",
|
||
headline:
|
||
signoffStatus === "ready"
|
||
? "当前收口、发布、自动化与 launchpad 摘要一致,可以进入最终人工签字或正式发布。"
|
||
: signoffStatus === "attention"
|
||
? `当前已进入最终收口阶段,但仍建议先完成 ${String(goLiveSummary.value.operator_title || opsStackDiagnosisOperatorDecision.value.title || "attention 项").trim()} 再签字上线。`
|
||
: `当前仍存在发布阻断,建议优先处理 ${String(goLiveSummary.value.operator_title || opsStackDiagnosisOperatorDecision.value.title || "阻断项").trim()}。`,
|
||
release_gate: {
|
||
go_live_status: goLiveSummaryStatus.value,
|
||
publish_status: goLivePublishStatus.value,
|
||
stack_status: String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim(),
|
||
driver_launch_status: String(driverFeedSummary.value.launch_status || "").trim(),
|
||
codex_launch_status: String(codexBriefSummary.value.launch_status || "").trim(),
|
||
release_launchpad_status: String(releaseLaunchpadStatus.value.status || "").trim(),
|
||
},
|
||
blocked_reasons: blockedReasons,
|
||
attention_reasons: attentionReasons,
|
||
decision: {
|
||
operator_title: String(goLiveSummary.value.operator_title || "").trim(),
|
||
next_step_action_code: String(goLiveSummary.value.next_step_action_code || "").trim(),
|
||
next_step_reason: String(goLiveSummary.value.next_step_reason || "").trim(),
|
||
preferred_surface:
|
||
String(releaseLaunchpadStatus.value.status || "").trim() === "blocked"
|
||
|| String(releaseLaunchpadStatus.value.status || "").trim() === "attention"
|
||
? "release-launchpad"
|
||
: String(codexBriefSummary.value.launch_status || "").trim() === "blocked"
|
||
|| String(codexBriefSummary.value.launch_status || "").trim() === "attention"
|
||
? "codex-brief"
|
||
: "go-live-summary",
|
||
launchpad_recommended_target_node_code: String(goLiveSummary.value.launchpad_recommended_target_node_code || "").trim(),
|
||
launchpad_recommended_recovery_label: String(goLiveSummary.value.launchpad_recommended_recovery_label || "").trim(),
|
||
launchpad_recommended_recovery_summary: String(goLiveSummary.value.launchpad_recommended_recovery_summary || "").trim(),
|
||
},
|
||
launchpad_alignment: launchpadAlignment,
|
||
recommended_commands: {
|
||
...((goLiveSummary.value.recommended_commands || {}) as GenericRecord),
|
||
go_live_signoff: buildOpsScriptCommand(
|
||
"drive_ops_center.sh",
|
||
"go-live-signoff",
|
||
String(controlPlaneBaseUrl.value || "").trim() || "http://127.0.0.1:8100"
|
||
),
|
||
},
|
||
};
|
||
};
|
||
const goLiveSignoff = computed<GenericRecord>(() => {
|
||
if (Object.keys(goLiveSignoffPayload.value || {}).length) {
|
||
return goLiveSignoffPayload.value || {};
|
||
}
|
||
return buildFallbackGoLiveSignoffData();
|
||
});
|
||
const goLiveSignoffStatus = computed(() => String(goLiveSignoff.value.signoff_status || "").trim());
|
||
const goLiveSignoffStatusLabel = computed(() => {
|
||
const status = goLiveSignoffStatus.value;
|
||
if (status === "ready") return "可签字上线";
|
||
if (status === "attention") return "待签字复核";
|
||
if (status === "blocked") return "暂不可签字";
|
||
return status || "签收未知";
|
||
});
|
||
const goLiveSignoffStatusType = computed(() => {
|
||
const status = goLiveSignoffStatus.value;
|
||
if (status === "ready") return "success";
|
||
if (status === "attention") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const goLiveSignoffHeadline = computed(
|
||
() => String(goLiveSignoff.value.headline || "").trim() || "当前还没有生成最终签收结论。"
|
||
);
|
||
const goLiveSignoffBlockedReasons = computed<string[]>(() =>
|
||
(goLiveSignoff.value.blocked_reasons || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveSignoffAttentionReasons = computed<string[]>(() =>
|
||
(goLiveSignoff.value.attention_reasons || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveSignoffDecisionActionCode = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.next_step_action_code || decision.stack_next_action_code || "").trim() || "-";
|
||
});
|
||
const goLiveSignoffDecisionReason = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.next_step_reason || "").trim() || "当前签收结论还没有默认动作说明。";
|
||
});
|
||
const goLiveSignoffDecisionOperatorTitle = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.operator_title || "").trim() || "待生成";
|
||
});
|
||
const goLiveSignoffStackOperatorTitle = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.stack_operator_title || "").trim() || "待生成";
|
||
});
|
||
const goLiveSignoffPreferredSurfaceKey = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.preferred_surface || "").trim();
|
||
});
|
||
const goLiveSignoffPreferredSurfaceLabel = computed(() => {
|
||
return formatOpsSurfaceLabel(goLiveSignoffPreferredSurfaceKey.value);
|
||
});
|
||
const goLiveSignoffReviewStatus = computed(() =>
|
||
String(((goLiveSignoff.value.release_gate || {}) as GenericRecord).go_live_review_status || "").trim()
|
||
);
|
||
const goLiveSignoffReviewStatusLabel = computed(() => {
|
||
const status = goLiveSignoffReviewStatus.value;
|
||
if (status === "ready") return "已通过";
|
||
if (status === "attention") return "待复核";
|
||
if (status === "blocked") return "阻断";
|
||
if (status === "missing") return "待导出";
|
||
return status || "未接入";
|
||
});
|
||
const goLiveSignoffDoctorStatus = computed(() =>
|
||
String(((goLiveSignoff.value.release_gate || {}) as GenericRecord).doctor_decision_status || "").trim()
|
||
);
|
||
const goLiveSignoffDoctorStatusLabel = computed(() => {
|
||
const status = goLiveSignoffDoctorStatus.value;
|
||
if (status === "ready") return "已收口";
|
||
if (status === "attention") return "待处理";
|
||
if (status === "blocked") return "阻断";
|
||
if (status === "missing") return "待生成";
|
||
return status || "未接入";
|
||
});
|
||
const goLiveSignoffReportDirLabel = computed(() => String(goLiveSignoff.value.report_dir || "").trim() || "当前没有绑定报告目录");
|
||
const goLiveSignoffManifestPathLabel = computed(() => String(goLiveSignoff.value.manifest_path || "").trim() || "当前没有 manifest");
|
||
const goLiveSignoffLaunchpadTargetNodeCode = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return String(decision.launchpad_recommended_target_node_code || "").trim() || "-";
|
||
});
|
||
const goLiveSignoffLaunchpadRecoveryLabel = computed(() => {
|
||
const decision = (goLiveSignoff.value.decision || {}) as GenericRecord;
|
||
return (
|
||
String(decision.launchpad_recommended_recovery_label || decision.launchpad_recommended_recovery_summary || "").trim()
|
||
|| "当前没有额外的 launchpad 补口建议"
|
||
);
|
||
});
|
||
const goLiveSignoffAlignment = computed<GenericRecord>(() => (goLiveSignoff.value.launchpad_alignment || {}) as GenericRecord);
|
||
const goLiveSignoffAlignmentConsistent = computed(() => Boolean(goLiveSignoffAlignment.value.consistent));
|
||
const goLiveSignoffAlignmentStatusLabel = computed(() => (goLiveSignoffAlignmentConsistent.value ? "一致" : "存在漂移"));
|
||
const goLiveSignoffAlignmentSummary = computed(() => {
|
||
if (goLiveSignoffAlignmentConsistent.value) {
|
||
return "go-live-summary / driver-feed / codex-brief / release-launchpad 已对齐";
|
||
}
|
||
const availableSources = (goLiveSignoffAlignment.value.available_sources || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return availableSources.length
|
||
? `当前比较源:${availableSources.join(" / ")}`
|
||
: "签收层检测到 launchpad 摘要存在不一致";
|
||
});
|
||
const goLiveSignoffAlignmentLines = computed<string[]>(() => {
|
||
const alignment = goLiveSignoffAlignment.value;
|
||
if (Boolean(alignment.consistent)) {
|
||
return ["launchpad 的目标节点、恢复标签、摘要和待补计数已经在各 contract 之间保持一致。"];
|
||
}
|
||
const lines: string[] = [];
|
||
if (alignment.target_node_code_consistent === false) {
|
||
lines.push("目标节点摘要在 go-live-summary / driver-feed / codex-brief / release-launchpad 之间不一致。");
|
||
}
|
||
if (alignment.recovery_label_consistent === false) {
|
||
lines.push("恢复标签摘要不一致,建议统一采用同一条 launchpad 补口口径。");
|
||
}
|
||
if (alignment.recovery_summary_consistent === false) {
|
||
lines.push("恢复说明摘要不一致,建议重新生成或复核签收输入。");
|
||
}
|
||
if (alignment.bootstrap_pending_consistent === false) {
|
||
lines.push("待补安装节点数在不同 contract 中存在漂移。");
|
||
}
|
||
if (alignment.acceptance_ready_consistent === false) {
|
||
lines.push("待验收节点数在不同 contract 中存在漂移。");
|
||
}
|
||
return lines;
|
||
});
|
||
const goLiveSignoffRecommendedCommandEntries = computed(() =>
|
||
Object.entries((goLiveSignoff.value.recommended_commands || {}) as Record<string, unknown>)
|
||
.map(([key, command]) => ({ key, label: String(key || "").trim(), command: String(command || "").trim() }))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const goLiveSignoffPrimaryCommand = computed(() => {
|
||
const preferredEntry = goLiveSignoffRecommendedCommandEntries.value.find((item) => item.key === "go_live_signoff");
|
||
return String(preferredEntry?.command || goLiveSignoffRecommendedCommandEntries.value[0]?.command || "").trim();
|
||
});
|
||
const goLiveSignoffContracts = computed<GenericRecord[]>(() => contractNavigationContracts(goLiveSignoff.value));
|
||
const goLiveSignoffPrimaryContract = computed<GenericRecord>(() => {
|
||
const primaryKey = contractNavigationPrimaryKey(goLiveSignoff.value);
|
||
return (
|
||
goLiveSignoffContracts.value.find((item) => String(item.key || "").trim() === primaryKey)
|
||
|| goLiveSignoffContracts.value[0]
|
||
|| {}
|
||
);
|
||
});
|
||
const buildFallbackGoLiveReviewData = (): GenericRecord => ({
|
||
generated_at: String(goLiveSignoff.value.generated_at || goLiveSummary.value.generated_at || "").trim(),
|
||
status: "missing",
|
||
status_label: "待导出",
|
||
headline: "当前还没有可复核的 go-live bundle,建议先导出证据包,再进入正式复核。",
|
||
report_dir: "",
|
||
manifest_path: "",
|
||
artifact_total: 0,
|
||
failure_total: 0,
|
||
critical_failures: [],
|
||
noncritical_failures: [],
|
||
env_audit: {},
|
||
launchpad_alignment: {
|
||
consistent: false,
|
||
available_sources: [],
|
||
},
|
||
recommended_next_steps: [
|
||
"先执行 go-live-export,导出完整上线证据包。",
|
||
"导出完成后再执行 go-live-review,确认正式复核结论。",
|
||
],
|
||
recommended_commands: {
|
||
go_live_export: buildOpsScriptCommand("drive_ops_center.sh", "go-live-export"),
|
||
go_live_review: buildOpsScriptCommand("drive_ops_center.sh", "go-live-review", "/path/to/go-live-bundle"),
|
||
go_live_signoff: buildOpsScriptCommand("drive_ops_center.sh", "go-live-signoff", "/path/to/go-live-bundle"),
|
||
doctor_decision: buildOpsScriptCommand("drive_ops_center.sh", "doctor-decision", "http://127.0.0.1:8100"),
|
||
},
|
||
});
|
||
const goLiveReview = computed<GenericRecord>(() => {
|
||
if (Object.keys(goLiveReviewPayload.value || {}).length) {
|
||
return goLiveReviewPayload.value || {};
|
||
}
|
||
return buildFallbackGoLiveReviewData();
|
||
});
|
||
const goLiveReviewStatus = computed(() => String(goLiveReview.value.status || "").trim());
|
||
const goLiveReviewStatusLabel = computed(() => {
|
||
const status = goLiveReviewStatus.value;
|
||
if (status === "ready") return "可复核发布";
|
||
if (status === "attention") return "待复核补口";
|
||
if (status === "blocked") return "暂不可发布";
|
||
if (status === "missing") return "待导出";
|
||
return status || "复核未知";
|
||
});
|
||
const goLiveReviewStatusType = computed(() => {
|
||
const status = goLiveReviewStatus.value;
|
||
if (status === "ready") return "success";
|
||
if (status === "attention" || status === "missing") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const goLiveReviewHeadline = computed(
|
||
() => String(goLiveReview.value.headline || "").trim() || "当前还没有生成正式复核结论。"
|
||
);
|
||
const goLiveReviewManifestReadyLabel = computed(() =>
|
||
String(goLiveReview.value.manifest_path || "").trim() ? "manifest 已就绪" : "等待导出"
|
||
);
|
||
const goLiveReviewReportDirLabel = computed(() => String(goLiveReview.value.report_dir || "").trim() || "当前还没有导出目录");
|
||
const goLiveReviewManifestPathLabel = computed(() => String(goLiveReview.value.manifest_path || "").trim() || "当前还没有 manifest");
|
||
const goLiveReviewCriticalFailures = computed<string[]>(() =>
|
||
(goLiveReview.value.critical_failures || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveReviewNoncriticalFailures = computed<string[]>(() =>
|
||
(goLiveReview.value.noncritical_failures || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveReviewEnvAudit = computed<GenericRecord>(() => (goLiveReview.value.env_audit || {}) as GenericRecord);
|
||
const goLiveReviewEnvAuditStatusLabel = computed(() => {
|
||
const status = String(goLiveReviewEnvAudit.value.status || "").trim();
|
||
if (status === "ready") return "环境就绪";
|
||
if (status === "attention") return "环境待补";
|
||
if (status === "blocked") return "环境阻断";
|
||
return status || "未审计";
|
||
});
|
||
const goLiveReviewEnvAuditHeadline = computed(
|
||
() => String(goLiveReviewEnvAudit.value.headline || "").trim() || "当前没有额外的环境审计摘要。"
|
||
);
|
||
const goLiveReviewEnvAuditMissingItems = computed<string[]>(() =>
|
||
(goLiveReviewEnvAudit.value.missing_items || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveReviewAlignment = computed<GenericRecord>(() => (goLiveReview.value.launchpad_alignment || {}) as GenericRecord);
|
||
const goLiveReviewAlignmentConsistent = computed(() => Boolean(goLiveReviewAlignment.value.consistent));
|
||
const goLiveReviewAlignmentStatusLabel = computed(() => (goLiveReviewAlignmentConsistent.value ? "摘要一致" : "摘要漂移"));
|
||
const goLiveReviewAlignmentSummary = computed(() => {
|
||
if (goLiveReviewAlignmentConsistent.value) {
|
||
return "go-live-summary / stack-diagnosis / driver-feed / codex-brief 已对齐";
|
||
}
|
||
const availableSources = (goLiveReviewAlignment.value.available_sources || [])
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return availableSources.length
|
||
? `当前比较源:${availableSources.join(" / ")}`
|
||
: "当前正式复核检测到 launchpad 摘要存在不一致";
|
||
});
|
||
const goLiveReviewAlignmentLines = computed<string[]>(() => {
|
||
const alignment = goLiveReviewAlignment.value;
|
||
if (Boolean(alignment.consistent)) {
|
||
return ["launchpad 的目标节点、恢复标签、摘要和待补计数已经在正式复核层完成对齐。"];
|
||
}
|
||
const lines: string[] = [];
|
||
if (alignment.target_node_code_consistent === false) {
|
||
lines.push("目标节点摘要在 go-live-summary / stack-diagnosis / driver-feed / codex-brief 之间不一致。");
|
||
}
|
||
if (alignment.recovery_label_consistent === false) {
|
||
lines.push("恢复标签摘要不一致,建议统一采用同一条 launchpad 补口口径。");
|
||
}
|
||
if (alignment.recovery_summary_consistent === false) {
|
||
lines.push("恢复说明摘要不一致,建议重新导出 bundle 后再复核。");
|
||
}
|
||
if (alignment.bootstrap_pending_consistent === false) {
|
||
lines.push("待补安装节点数在不同复核来源中存在漂移。");
|
||
}
|
||
if (alignment.acceptance_ready_consistent === false) {
|
||
lines.push("待验收节点数在不同复核来源中存在漂移。");
|
||
}
|
||
return lines;
|
||
});
|
||
const goLiveReviewRecommendedNextSteps = computed<string[]>(() =>
|
||
(goLiveReview.value.recommended_next_steps || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveReviewRecommendedCommandEntries = computed(() =>
|
||
Object.entries((goLiveReview.value.recommended_commands || {}) as Record<string, unknown>)
|
||
.map(([key, command]) => ({ key, label: String(key || "").trim(), command: String(command || "").trim() }))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const goLiveReviewPrimaryCommand = computed(() => {
|
||
const preferredEntry = goLiveReviewRecommendedCommandEntries.value.find((item) => item.key === "go_live_review");
|
||
return String(preferredEntry?.command || goLiveReviewRecommendedCommandEntries.value[0]?.command || "").trim();
|
||
});
|
||
const goLiveReviewContracts = computed<GenericRecord[]>(() => contractNavigationContracts(goLiveReview.value));
|
||
const goLiveReviewPrimaryContract = computed<GenericRecord>(() => {
|
||
const primaryKey = contractNavigationPrimaryKey(goLiveReview.value);
|
||
return (
|
||
goLiveReviewContracts.value.find((item) => String(item.key || "").trim() === primaryKey)
|
||
|| goLiveReviewContracts.value[0]
|
||
|| {}
|
||
);
|
||
});
|
||
const buildFallbackGoLiveBundleData = (): GenericRecord => ({
|
||
generated_at: String(goLiveSignoff.value.generated_at || goLiveSummary.value.generated_at || "").trim(),
|
||
status: "missing",
|
||
status_label: "待导出",
|
||
headline: "当前还没有读取到 go-live bundle,建议先导出证据包,再进入 manifest 与最终签收复核。",
|
||
bundle_available: false,
|
||
report_dir: "",
|
||
manifest_path: "",
|
||
artifact_total: 0,
|
||
failure_total: 0,
|
||
critical_failures: [],
|
||
noncritical_failures: [],
|
||
env_audit: {},
|
||
summary: {
|
||
ok: false,
|
||
review_status_hint: "missing",
|
||
review_headline: "当前还没有读取到 go-live bundle,建议先导出证据包,再进入 manifest 与最终签收复核。",
|
||
launchpad_alignment: { consistent: false },
|
||
},
|
||
recommended_commands: {
|
||
go_live_export: buildOpsScriptCommand("drive_ops_center.sh", "go-live-export"),
|
||
go_live_review: buildOpsScriptCommand("drive_ops_center.sh", "go-live-review", "/path/to/go-live-bundle"),
|
||
go_live_signoff: buildOpsScriptCommand("drive_ops_center.sh", "go-live-signoff", "/path/to/go-live-bundle"),
|
||
doctor_export: buildOpsScriptCommand("drive_ops_center.sh", "doctor-export", "/tmp/domaincheck-go-live", "http://127.0.0.1:8100"),
|
||
},
|
||
});
|
||
const goLiveBundle = computed<GenericRecord>(() => {
|
||
if (Object.keys(goLiveBundlePayload.value || {}).length) {
|
||
return goLiveBundlePayload.value || {};
|
||
}
|
||
return buildFallbackGoLiveBundleData();
|
||
});
|
||
const goLiveBundleStatus = computed(() => String(goLiveBundle.value.status || "").trim());
|
||
const goLiveBundleStatusLabel = computed(() => {
|
||
const status = goLiveBundleStatus.value;
|
||
if (status === "ready") return "证据可交付";
|
||
if (status === "attention") return "证据待复核";
|
||
if (status === "blocked") return "证据阻断";
|
||
if (status === "missing") return "待导出";
|
||
return status || "证据未知";
|
||
});
|
||
const goLiveBundleStatusType = computed(() => {
|
||
const status = goLiveBundleStatus.value;
|
||
if (status === "ready") return "success";
|
||
if (status === "attention" || status === "missing") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const goLiveBundleHeadline = computed(
|
||
() => String(goLiveBundle.value.headline || "").trim() || "当前还没有生成交付证据结论。"
|
||
);
|
||
const goLiveBundleSummary = computed<GenericRecord>(() => (goLiveBundle.value.summary || {}) as GenericRecord);
|
||
const goLiveBundleSummaryHeadline = computed(
|
||
() => String(goLiveBundleSummary.value.review_headline || "").trim() || goLiveBundleHeadline.value
|
||
);
|
||
const goLiveBundleAlignment = computed<GenericRecord>(() => (goLiveBundleSummary.value.launchpad_alignment || {}) as GenericRecord);
|
||
const goLiveBundleAlignmentStatusLabel = computed(() =>
|
||
Boolean(goLiveBundleAlignment.value.consistent) ? "摘要一致" : "摘要漂移"
|
||
);
|
||
const goLiveBundleLaunchpadSummary = computed(() => {
|
||
const targetNodeCode = String(goLiveBundleSummary.value.launchpad_recommended_target_node_code || "").trim();
|
||
const recoveryLabel = String(goLiveBundleSummary.value.launchpad_recommended_recovery_label || "").trim();
|
||
const bootstrapPendingNodes = Number(goLiveBundleSummary.value.launchpad_onboarding_bootstrap_pending_nodes || 0);
|
||
const acceptanceReadyNodes = Number(goLiveBundleSummary.value.launchpad_onboarding_acceptance_ready_nodes || 0);
|
||
const tail = bootstrapPendingNodes > 0 || acceptanceReadyNodes > 0
|
||
? `待补安装 ${bootstrapPendingNodes} / 待验收 ${acceptanceReadyNodes}`
|
||
: "当前没有额外接入缺口";
|
||
if (targetNodeCode || recoveryLabel) {
|
||
return [recoveryLabel, targetNodeCode, tail].filter(Boolean).join(" / ");
|
||
}
|
||
return tail;
|
||
});
|
||
const goLiveBundleEnvAudit = computed<GenericRecord>(() => (goLiveBundle.value.env_audit || {}) as GenericRecord);
|
||
const goLiveBundleEnvAuditStatusLabel = computed(() => {
|
||
const status = String(goLiveBundleEnvAudit.value.status || "").trim();
|
||
if (status === "ready") return "环境就绪";
|
||
if (status === "attention") return "环境待补";
|
||
if (status === "blocked") return "环境阻断";
|
||
return status || "未审计";
|
||
});
|
||
const goLiveBundleEnvAuditHeadline = computed(
|
||
() => String(goLiveBundleEnvAudit.value.headline || "").trim() || "当前没有额外的环境审计摘要。"
|
||
);
|
||
const goLiveBundleManifestPathLabel = computed(() => String(goLiveBundle.value.manifest_path || "").trim() || "当前还没有 manifest");
|
||
const goLiveBundleReportDirLabel = computed(() => String(goLiveBundle.value.report_dir || "").trim() || "当前还没有导出目录");
|
||
const goLiveBundleCriticalFailures = computed<string[]>(() =>
|
||
(goLiveBundle.value.critical_failures || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveBundleNoncriticalFailures = computed<string[]>(() =>
|
||
(goLiveBundle.value.noncritical_failures || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveBundleEnvAuditMissingItems = computed<string[]>(() =>
|
||
(goLiveBundleEnvAudit.value.missing_items || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveBundleContractSurfaceGaps = computed<string[]>(() =>
|
||
(goLiveBundleSummary.value.contract_surface_gaps || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const goLiveBundleRecommendedCommandEntries = computed(() =>
|
||
Object.entries((goLiveBundle.value.recommended_commands || {}) as Record<string, unknown>)
|
||
.map(([key, command]) => ({ key, label: String(key || "").trim(), command: String(command || "").trim() }))
|
||
.filter((item) => Boolean(item.command))
|
||
);
|
||
const goLiveBundleExportCommand = computed(() =>
|
||
String(
|
||
goLiveBundleRecommendedCommandEntries.value.find((item) => item.key === "go_live_export")?.command || ""
|
||
).trim()
|
||
);
|
||
const goLiveBundleReviewCommand = computed(() =>
|
||
String(
|
||
goLiveBundleRecommendedCommandEntries.value.find((item) => item.key === "go_live_review")?.command || ""
|
||
).trim()
|
||
);
|
||
const goLiveBundleSignoffCommand = computed(() =>
|
||
String(
|
||
goLiveBundleRecommendedCommandEntries.value.find((item) => item.key === "go_live_signoff")?.command || ""
|
||
).trim()
|
||
);
|
||
const goLiveBundleContracts = computed<GenericRecord[]>(() => contractNavigationContracts(goLiveBundle.value));
|
||
const goLiveBundlePrimaryContract = computed<GenericRecord>(() => {
|
||
const primaryKey = contractNavigationPrimaryKey(goLiveBundle.value);
|
||
return (
|
||
goLiveBundleContracts.value.find((item) => String(item.key || "").trim() === primaryKey)
|
||
|| goLiveBundleContracts.value[0]
|
||
|| {}
|
||
);
|
||
});
|
||
const opsCockpitStatus = computed(() => {
|
||
const doctorStatus = String(doctorDecision.value.status || "").trim();
|
||
const reviewStatus = String(goLiveReview.value.status || "").trim();
|
||
const signoffStatus = String(goLiveSignoff.value.signoff_status || "").trim();
|
||
const bundleStatus = String(goLiveBundle.value.status || "").trim();
|
||
if (doctorStatus === "blocked" || reviewStatus === "blocked" || signoffStatus === "blocked" || bundleStatus === "blocked") {
|
||
return "blocked";
|
||
}
|
||
if (
|
||
doctorStatus === "attention"
|
||
|| reviewStatus === "attention"
|
||
|| reviewStatus === "missing"
|
||
|| signoffStatus === "attention"
|
||
|| bundleStatus === "attention"
|
||
|| bundleStatus === "missing"
|
||
) {
|
||
return "attention";
|
||
}
|
||
if (doctorStatus) {
|
||
return doctorStatus;
|
||
}
|
||
if (reviewStatus) {
|
||
return reviewStatus;
|
||
}
|
||
if (signoffStatus) {
|
||
return signoffStatus;
|
||
}
|
||
const goLiveStatus = String(goLiveSummary.value.go_live_status || "").trim();
|
||
const automationStatus = String(codexAutomationCoverage.value.launch_status || "").trim();
|
||
const stackStatus = String(opsStackDiagnosisDiagnosis.value.stack_status || "").trim();
|
||
if ([goLiveStatus, automationStatus, stackStatus].includes("blocked")) {
|
||
return "blocked";
|
||
}
|
||
if ([goLiveStatus, automationStatus, stackStatus].includes("attention")) {
|
||
return "attention";
|
||
}
|
||
if ([goLiveStatus, automationStatus, stackStatus].includes("ready")) {
|
||
return "ready";
|
||
}
|
||
return goLiveStatus || automationStatus || stackStatus || "unknown";
|
||
});
|
||
const opsCockpitStatusLabel = computed(() => {
|
||
const status = String(opsCockpitStatus.value || "").trim();
|
||
if (status === "ready") return "可上线";
|
||
if (status === "attention") return "待收口";
|
||
if (status === "blocked") return "阻断";
|
||
return status || "未知";
|
||
});
|
||
const opsCockpitStatusType = computed(() => {
|
||
const status = String(opsCockpitStatus.value || "").trim();
|
||
if (status === "ready") return "success";
|
||
if (status === "attention") return "warning";
|
||
if (status === "blocked") return "danger";
|
||
return "info";
|
||
});
|
||
const opsCockpitNextActionCode = computed(() =>
|
||
String(
|
||
doctorDecisionDecision.value.next_action_code
|
||
|| goLiveSummary.value.next_step_action_code
|
||
|| opsStackDiagnosisNextStep.value.action_code
|
||
|| ""
|
||
).trim()
|
||
);
|
||
const opsCockpitNextStepReason = computed(() =>
|
||
String(
|
||
doctorDecisionDetail.value
|
||
|| goLiveSummary.value.next_step_reason
|
||
|| opsStackDiagnosisNextStep.value.reason
|
||
|| "当前还没有默认动作原因"
|
||
).trim()
|
||
);
|
||
const opsCockpitHeadline = computed(() => {
|
||
if (doctorDecisionStatus.value === "blocked" || doctorDecisionStatus.value === "attention") {
|
||
return doctorDecisionHeadline.value;
|
||
}
|
||
if (goLiveReviewStatus.value === "blocked" || goLiveReviewStatus.value === "attention") {
|
||
return goLiveReviewHeadline.value;
|
||
}
|
||
if (String(goLiveSignoff.value.headline || "").trim()) {
|
||
if (goLiveBundleStatus.value === "ready" && goLiveReviewStatus.value === "ready") {
|
||
return String(goLiveSignoff.value.headline || "").trim();
|
||
}
|
||
return `${String(goLiveSignoff.value.headline || "").trim()} ${goLiveReviewHeadline.value}`;
|
||
}
|
||
if (String(goLiveReview.value.headline || "").trim()) {
|
||
return String(goLiveReview.value.headline || "").trim();
|
||
}
|
||
if (String(goLiveBundle.value.headline || "").trim()) {
|
||
return String(goLiveBundle.value.headline || "").trim();
|
||
}
|
||
const statusLabel = opsCockpitStatusLabel.value;
|
||
const nextActionCode = opsCockpitNextActionCode.value;
|
||
const operatorTitle = String(opsStackDiagnosisOperatorDecision.value.title || goLiveSummary.value.operator_title || "").trim();
|
||
const reason = opsCockpitNextStepReason.value;
|
||
if (opsCockpitStatus.value === "blocked") {
|
||
return `${statusLabel},优先处理 ${operatorTitle || nextActionCode || "阻断项"}。${reason ? ` 当前建议:${reason}` : ""}`;
|
||
}
|
||
if (opsCockpitStatus.value === "attention") {
|
||
return `${statusLabel},当前可以继续收口,但建议先完成 ${operatorTitle || nextActionCode || "默认下一步"}。${reason ? ` ${reason}` : ""}`;
|
||
}
|
||
return `${statusLabel},当前总检、自动化和发布门禁都已接近收口。${nextActionCode ? ` 默认下一步:${nextActionCode}。` : ""}`;
|
||
});
|
||
const opsCockpitHighlights = computed(() =>
|
||
[
|
||
...(goLiveSummaryLaunchpadTargetNodeCode.value
|
||
? [
|
||
{
|
||
type:
|
||
goLiveSummaryStatus.value === "blocked" || goLiveSummaryStatus.value === "attention" ? "warning" : "info",
|
||
text: `接入缺口:${goLiveSummaryLaunchpadRecoveryLabel.value || "处理 launchpad"} ${goLiveSummaryLaunchpadTargetNodeCode.value}。${goLiveSummaryLaunchpadGapSummary.value}`,
|
||
},
|
||
]
|
||
: []),
|
||
...goLiveSummaryBlockingReasons.value.map((text) => ({ type: "danger", text: `上线阻断:${text}` })),
|
||
...goLivePublishBlockingReasons.value.map((text) => ({ type: "danger", text: `发布阻断:${text}` })),
|
||
...doctorDecisionRequiredFailures.value.slice(0, 2).map((text) => ({ type: "danger", text: `总检缺失:${text}` })),
|
||
...goLiveReviewCriticalFailures.value.slice(0, 2).map((text) => ({ type: "danger", text: `正式复核关键失败:${text}` })),
|
||
...goLiveSignoffBlockedReasons.value.map((text) => ({ type: "danger", text: `签收阻断:${text}` })),
|
||
...goLiveBundleCriticalFailures.value.slice(0, 2).map((text) => ({ type: "danger", text: `证据关键失败:${text}` })),
|
||
...goLiveSummaryWarnings.value.slice(0, 2).map((text) => ({ type: "warning", text: `上线关注:${text}` })),
|
||
...goLivePublishWarnings.value.slice(0, 2).map((text) => ({ type: "warning", text: `发布关注:${text}` })),
|
||
...doctorDecisionOptionalUnavailable.value.slice(0, 1).map((text) => ({ type: "warning", text: `总检预览待补:${text}` })),
|
||
...doctorDecisionContractSurfaceGaps.value.slice(0, 1).map((text) => ({ type: "warning", text: `总检 contract 缺口:${text}` })),
|
||
...goLiveReviewNoncriticalFailures.value.slice(0, 2).map((text) => ({ type: "warning", text: `正式复核补充失败:${text}` })),
|
||
...goLiveReviewEnvAuditMissingItems.value.slice(0, 1).map((text) => ({ type: "warning", text: `正式复核环境缺口:${text}` })),
|
||
...(!goLiveReviewAlignmentConsistent.value ? [{ type: "warning", text: `正式复核摘要漂移:${goLiveReviewAlignmentSummary.value}` }] : []),
|
||
...goLiveSignoffAttentionReasons.value.slice(0, 2).map((text) => ({ type: "warning", text: `签收关注:${text}` })),
|
||
...goLiveBundleNoncriticalFailures.value.slice(0, 2).map((text) => ({ type: "warning", text: `证据补充失败:${text}` })),
|
||
...goLiveBundleContractSurfaceGaps.value.slice(0, 1).map((text) => ({ type: "warning", text: `证据 contract 缺口:${text}` })),
|
||
...opsStackDiagnosisOperatorHints.value.slice(0, 2).map((text) => ({ type: "info", text })),
|
||
].slice(0, 6)
|
||
);
|
||
const buildOpsCockpitNextStepEntry = (): GenericRecord => {
|
||
const actionCode = String(opsCockpitNextActionCode.value || "").trim();
|
||
const focusRef = { ...((opsStackDiagnosisNextStep.value.focus_ref || {}) as GenericRecord) };
|
||
const targetNodeCode = String(
|
||
focusRef.target_node_code || focusRef.node_code || focusRef.targetNodeCode || ""
|
||
).trim();
|
||
return {
|
||
key: "ops-cockpit-next-step",
|
||
executorKind: "driver_action",
|
||
title: "单脑驾驶舱默认下一步",
|
||
summary: opsCockpitNextStepReason.value,
|
||
reason: opsCockpitHeadline.value,
|
||
focusRef,
|
||
primaryFocusRef: focusRef,
|
||
secondaryFocusRef: {},
|
||
primaryLabel: actionCode || "默认下一步",
|
||
secondaryLabel: "",
|
||
primaryActionCode: actionCode,
|
||
secondaryActionCode: "",
|
||
primaryNodeCodes: targetNodeCode ? [targetNodeCode] : [],
|
||
secondaryNodeCodes: [],
|
||
primaryActionPayload: focusRef && Object.keys(focusRef).length ? { focus_ref: focusRef } : {},
|
||
secondaryActionPayload: {},
|
||
};
|
||
};
|
||
const opsCockpitNextStepPreviewable = computed(() =>
|
||
hasDriverActionContractPreview(buildOpsCockpitNextStepEntry())
|
||
);
|
||
const opsCockpitPrimaryActionContracts = computed<GenericRecord[]>(() => {
|
||
const items = [
|
||
...contractNavigationContracts(opsStackDiagnosisNextStep.value),
|
||
...contractNavigationContracts(codexBriefFocus.value),
|
||
];
|
||
const seen = new Set<string>();
|
||
return items.filter((item) => {
|
||
const key = String(item?.key || "").trim();
|
||
if (!key || seen.has(key)) {
|
||
return false;
|
||
}
|
||
seen.add(key);
|
||
return true;
|
||
}).slice(0, 4);
|
||
});
|
||
const opsCockpitSummaryContracts = computed<GenericRecord[]>(() => {
|
||
const items = [
|
||
...driverFeedSummaryContracts.value,
|
||
...codexBriefSummaryContracts.value,
|
||
...opsStackDiagnosisSummaryContracts.value,
|
||
];
|
||
const seen = new Set<string>();
|
||
return items.filter((item) => {
|
||
const key = String(item?.key || "").trim();
|
||
if (!key || seen.has(key)) {
|
||
return false;
|
||
}
|
||
seen.add(key);
|
||
return true;
|
||
}).slice(0, 4);
|
||
});
|
||
const hasActivityStreamFilters = computed(
|
||
() =>
|
||
Boolean(String(activityStreamFilters.value.kind || "").trim()) ||
|
||
Boolean(String(activityStreamFilters.value.status || "").trim()) ||
|
||
Boolean(String(activityStreamFilters.value.execution_mode || "").trim()) ||
|
||
Boolean(String(activityStreamFilters.value.query || "").trim())
|
||
);
|
||
const activityStreamKindOptions = computed(() =>
|
||
Object.entries(activityStreamSummary.value.available_kind_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${activityKindLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const activityStreamStatusOptions = computed(() =>
|
||
Object.entries(activityStreamSummary.value.available_status_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${genericStatusLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const activityStreamExecutionModeOptions = computed(() =>
|
||
Object.entries(activityStreamSummary.value.available_execution_mode_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${executionModeLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const activityStreamAttentionCount = computed(
|
||
() =>
|
||
Number(activityStreamSummary.value.status_counts?.attention || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.running || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.failed || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.blocked || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.cancelled || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.ready_for_next_batch || 0) +
|
||
Number(activityStreamSummary.value.status_counts?.awaiting_approval || 0)
|
||
);
|
||
const hasPlaybookRunFilters = computed(
|
||
() =>
|
||
Boolean(String(playbookRunFilters.value.status || "").trim()) ||
|
||
Boolean(String(playbookRunFilters.value.group_key || "").trim()) ||
|
||
Boolean(String(playbookRunFilters.value.query || "").trim())
|
||
);
|
||
const playbookRunStatusOptions = computed(() =>
|
||
Object.entries(playbookRunsSummary.value.available_status_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${playbookRunStatusLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const playbookRunGroupOptions = computed(() =>
|
||
Object.entries(playbookRunsSummary.value.available_group_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${playbookRunGroupTitle(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const playbookRunEventStepOptions = computed(() =>
|
||
{
|
||
const stepTitleMap = new Map<string, string>(
|
||
(activePlaybookRun.value?.steps || [])
|
||
.map((item: GenericRecord) => [String(item.step_key || "").trim(), String(item.title || item.step_key || "").trim()] as const)
|
||
.filter(([key]) => Boolean(key))
|
||
);
|
||
return Object.entries(playbookRunEventsSummary.value.available_step_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${stepTitleMap.get(String(value || "").trim()) || value} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label));
|
||
}
|
||
);
|
||
const playbookRunEventNodeOptions = computed(() =>
|
||
Object.entries(playbookRunEventsSummary.value.available_node_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
label: `${value} (${Number(count || 0)})`,
|
||
}))
|
||
.sort((left, right) => left.label.localeCompare(right.label))
|
||
);
|
||
const selectedActionTemplate = computed<GenericRecord>(
|
||
() => actionTemplates.value.find((item) => String(item.key || "") === String(actionTemplateForm.value.template_key || "")) || {}
|
||
);
|
||
const selectedPlaybook = computed<GenericRecord>(
|
||
() => playbooks.value.find((item) => String(item.key || "") === String(playbookForm.value.playbook_key || "")) || {}
|
||
);
|
||
const selectedActionTemplateFields = computed<GenericRecord[]>(() => selectedActionTemplate.value.fields || []);
|
||
const selectedActionTemplateModeOptions = computed(() =>
|
||
resolveAvailableExecutionModeOptions(
|
||
selectedActionTemplate.value.execution_mode_options || [],
|
||
selectedActionTemplate.value.execution_modes || [],
|
||
String(selectedActionTemplate.value.default_execution_mode || actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
actionTemplateForm.value.target_node_codes || []
|
||
)
|
||
);
|
||
const selectedActionTemplateModesText = computed(() =>
|
||
executionModesText(
|
||
selectedActionTemplate.value.execution_modes || [],
|
||
String(selectedActionTemplate.value.default_execution_mode || actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
selectedActionTemplate.value.execution_mode_options || []
|
||
)
|
||
);
|
||
const selectedActionTemplateDefaultExecutionModeLabel = computed(() =>
|
||
defaultExecutionModeLabel(
|
||
selectedActionTemplate.value,
|
||
String(actionTemplateDefaults.value.execution_mode || "remote-agent")
|
||
)
|
||
);
|
||
const actionTemplateExecutionModeRecommendation = computed(() =>
|
||
recommendActionTemplateExecutionMode(
|
||
String(actionTemplateForm.value.template_key || ""),
|
||
actionTemplateForm.value.target_node_codes || []
|
||
)
|
||
);
|
||
const actionTemplateExecutionModeHelpText = computed(() => {
|
||
const recommendation = actionTemplateExecutionModeRecommendation.value;
|
||
if (!String(actionTemplateForm.value.template_key || "").trim()) {
|
||
return "选择模板后,会按目标节点自动推荐执行方式。";
|
||
}
|
||
const currentMode = String(actionTemplateForm.value.execution_mode || "").trim();
|
||
const currentOption = selectedActionTemplateModeOptions.value.find((item) => String(item.value || "").trim() === currentMode);
|
||
if (currentOption?.disabled) {
|
||
return `推荐路径:${recommendation.recommendedModeLabel}。${String(recommendation.reason || "").trim()} 当前所选模式暂不可用:${String(currentOption.reason || "").trim()}`;
|
||
}
|
||
return `推荐路径:${recommendation.recommendedModeLabel}。${String(recommendation.reason || "").trim()}`;
|
||
});
|
||
const selectedActionTemplateAction = computed(() => String(selectedActionTemplate.value.action || "").trim());
|
||
const releaseDeployTemplateNodeSource = computed<GenericRecord[]>(() => {
|
||
if (nodes.value.length) {
|
||
return [...nodes.value];
|
||
}
|
||
return clusterNodes.value.map((item) => ({
|
||
node_code: item.node_code,
|
||
region: item.region,
|
||
role: item.role,
|
||
is_enabled: false,
|
||
current_load: item.current_load,
|
||
status: item.status,
|
||
cluster_status: item.status,
|
||
cluster_is_effective_worker: item.is_effective_worker,
|
||
metadata: {
|
||
imported_from_cluster_fallback: true,
|
||
last_heartbeat_at: item.last_heartbeat_at,
|
||
},
|
||
}));
|
||
});
|
||
const normalizeReleaseDeployNodeStatus = (row: GenericRecord) =>
|
||
String(row.cluster_status || row.status || "").trim().toLowerCase();
|
||
const normalizeReleaseDeployNodeRole = (row: GenericRecord) =>
|
||
String(row.role || row.cluster_role || "").trim().toLowerCase();
|
||
const isReleaseDeployEffectiveWorker = (row: GenericRecord) => {
|
||
if (typeof row.cluster_is_effective_worker === "boolean") {
|
||
return row.cluster_is_effective_worker;
|
||
}
|
||
if (typeof row.is_effective_worker === "boolean") {
|
||
return row.is_effective_worker;
|
||
}
|
||
return normalizeReleaseDeployNodeRole(row) === "worker";
|
||
};
|
||
const collectReleaseDeployTemplateCandidateNodeCodes = (templateKey: string) => {
|
||
const normalizedTemplateKey = String(templateKey || "").trim();
|
||
return releaseDeployTemplateNodeSource.value
|
||
.filter((item) => {
|
||
const status = normalizeReleaseDeployNodeStatus(item);
|
||
if (!["online", "busy"].includes(status)) {
|
||
return false;
|
||
}
|
||
const role = normalizeReleaseDeployNodeRole(item);
|
||
if (normalizedTemplateKey === "deploy.release.control") {
|
||
return role === "control";
|
||
}
|
||
if (normalizedTemplateKey === "deploy.release.worker") {
|
||
return role === "worker" && isReleaseDeployEffectiveWorker(item);
|
||
}
|
||
return false;
|
||
})
|
||
.map((item) => String(item.node_code || "").trim())
|
||
.filter(Boolean);
|
||
};
|
||
const buildReleaseDeployTemplatePrefillTargetNodeCodes = (templateKey: string) => {
|
||
const candidateNodeCodes = collectReleaseDeployTemplateCandidateNodeCodes(templateKey);
|
||
return candidateNodeCodes.length === 1 ? candidateNodeCodes : [];
|
||
};
|
||
const releaseDeployTemplateMenuItems = computed(() => {
|
||
const controlNodeCount = collectReleaseDeployTemplateCandidateNodeCodes("deploy.release.control").length;
|
||
const workerNodeCount = collectReleaseDeployTemplateCandidateNodeCodes("deploy.release.worker").length;
|
||
return [
|
||
{
|
||
key: "deploy.release.control",
|
||
label: `发布控制面节点${controlNodeCount ? ` (${controlNodeCount})` : ""}`,
|
||
},
|
||
{
|
||
key: "deploy.release.worker",
|
||
label: `发布 Worker 节点${workerNodeCount ? ` (${workerNodeCount})` : ""}`,
|
||
},
|
||
{
|
||
key: "deploy.release.custom",
|
||
label: "发布自定义节点",
|
||
},
|
||
];
|
||
});
|
||
const releaseSmartRolloutMenuItems = computed(() => [
|
||
{
|
||
key: "worker",
|
||
label: "Worker 灰度",
|
||
},
|
||
{
|
||
key: "control",
|
||
label: "Control 发布",
|
||
},
|
||
]);
|
||
const actionTargetNodes = computed<GenericRecord[]>(() => {
|
||
const targetRoles = new Set((selectedActionTemplate.value.target_roles || []).map((item: unknown) => String(item || "").trim()).filter(Boolean));
|
||
const sourceNodes = nodes.value.length
|
||
? [...nodes.value]
|
||
: clusterNodes.value.map((item) => ({
|
||
node_code: item.node_code,
|
||
region: item.region,
|
||
role: item.role,
|
||
is_enabled: false,
|
||
current_load: item.current_load,
|
||
status: item.status,
|
||
metadata: {
|
||
imported_from_cluster_fallback: true,
|
||
last_heartbeat_at: item.last_heartbeat_at,
|
||
},
|
||
}));
|
||
return sourceNodes
|
||
.filter((item) => {
|
||
if (!targetRoles.size) {
|
||
return true;
|
||
}
|
||
return targetRoles.has(String(item.role || "").trim());
|
||
})
|
||
.sort((left, right) => {
|
||
const leftEnabled = left.is_enabled ? 0 : 1;
|
||
const rightEnabled = right.is_enabled ? 0 : 1;
|
||
if (leftEnabled !== rightEnabled) {
|
||
return leftEnabled - rightEnabled;
|
||
}
|
||
return String(left.node_code || "").localeCompare(String(right.node_code || ""));
|
||
});
|
||
});
|
||
const actionTargetNodeSourceLabel = computed(() => (nodes.value.length ? "托管节点" : "集群在线节点(未纳管 fallback)"));
|
||
const actionTemplatePreviewHasData = computed(
|
||
() =>
|
||
Boolean((actionTemplatePreview.value.target_nodes || []).length) ||
|
||
Boolean((actionTemplatePreview.value.blocking_reasons || []).length) ||
|
||
Boolean((actionTemplatePreview.value.approval_reasons || []).length) ||
|
||
Boolean((actionTemplatePreview.value.warnings || []).length)
|
||
);
|
||
const actionTemplatePreviewSummary = computed<GenericRecord>(() => actionTemplatePreview.value.target_summary || {});
|
||
const actionTemplatePreviewRows = computed<GenericRecord[]>(() => actionTemplatePreview.value.target_nodes || []);
|
||
const actionTemplatePreviewBlockingReasons = computed<string[]>(() => actionTemplatePreview.value.blocking_reasons || []);
|
||
const actionTemplatePreviewApprovalReasons = computed<string[]>(() => actionTemplatePreview.value.approval_reasons || []);
|
||
const actionTemplatePreviewWarnings = computed<string[]>(() => actionTemplatePreview.value.warnings || []);
|
||
const actionTemplatePreviewRecommendations = computed<string[]>(() => actionTemplatePreview.value.recommendations || []);
|
||
const actionTemplatePreviewStatusType = computed(() => {
|
||
if (actionTemplatePreview.value.blocked) return "danger";
|
||
if (actionTemplatePreview.value.approval_required || actionTemplatePreviewWarnings.value.length) return "warning";
|
||
return "success";
|
||
});
|
||
const actionTemplatePreviewStatusLabel = computed(() => {
|
||
if (actionTemplatePreview.value.blocked) return "存在阻断";
|
||
if (actionTemplatePreview.value.approval_required) return "建议审批";
|
||
if (actionTemplatePreviewWarnings.value.length) return "需关注";
|
||
return "可执行";
|
||
});
|
||
const actionTemplatePreviewSummaryText = computed(() => {
|
||
const summary = actionTemplatePreviewSummary.value || {};
|
||
const nodesTotal = Number(summary.nodes_total || 0);
|
||
if (actionTemplatePreview.value.blocked) {
|
||
return `预检完成:目标节点 ${nodesTotal} 台,其中阻断 ${Number(summary.blocked_nodes || 0)} 台,请先处理阻断项。`;
|
||
}
|
||
if (actionTemplatePreview.value.approval_required) {
|
||
return `预检完成:目标节点 ${nodesTotal} 台,其中建议审批 ${Number(summary.approval_nodes || 0)} 台。`;
|
||
}
|
||
if (actionTemplatePreviewWarnings.value.length) {
|
||
return `预检完成:目标节点 ${nodesTotal} 台,其中需关注 ${Number(summary.warning_nodes || 0)} 台。`;
|
||
}
|
||
return `预检通过:目标节点 ${nodesTotal} 台,当前没有发现阻断项。`;
|
||
});
|
||
const actionTemplatePreviewSignature = computed(() =>
|
||
JSON.stringify({
|
||
template_key: actionTemplateForm.value.template_key,
|
||
target_node_codes: actionTemplateForm.value.target_node_codes || [],
|
||
execution_mode: actionTemplateForm.value.execution_mode,
|
||
payload: actionTemplateForm.value.payload || {},
|
||
})
|
||
);
|
||
|
||
const playbookTargetNodes = computed<GenericRecord[]>(() => {
|
||
const sourceNodes = nodes.value.length
|
||
? [...nodes.value]
|
||
: clusterNodes.value.map((item) => ({
|
||
node_code: item.node_code,
|
||
region: item.region,
|
||
role: item.role,
|
||
is_enabled: false,
|
||
current_load: item.current_load,
|
||
status: item.status,
|
||
metadata: {
|
||
imported_from_cluster_fallback: true,
|
||
last_heartbeat_at: item.last_heartbeat_at,
|
||
},
|
||
}));
|
||
return sourceNodes.sort((left, right) => String(left.node_code || "").localeCompare(String(right.node_code || "")));
|
||
});
|
||
const playbookTargetNodeSourceLabel = computed(() => (nodes.value.length ? "托管节点" : "集群在线节点(未纳管 fallback)"));
|
||
const selectedPlaybookModeOptions = computed(() =>
|
||
resolveAvailableExecutionModeOptions(
|
||
selectedPlaybook.value.execution_mode_options || [],
|
||
selectedPlaybook.value.execution_modes || [],
|
||
String(selectedPlaybook.value.default_execution_mode || "remote-agent"),
|
||
playbookForm.value.target_node_codes || []
|
||
)
|
||
);
|
||
const selectedPlaybookModesText = computed(() =>
|
||
executionModesText(
|
||
selectedPlaybook.value.execution_modes || [],
|
||
String(selectedPlaybook.value.default_execution_mode || "remote-agent"),
|
||
selectedPlaybook.value.execution_mode_options || []
|
||
)
|
||
);
|
||
const selectedPlaybookDefaultExecutionModeLabel = computed(() =>
|
||
defaultExecutionModeLabel(selectedPlaybook.value, "remote-agent")
|
||
);
|
||
const playbookExecutionModeRecommendation = computed(() =>
|
||
recommendPlaybookExecutionMode(
|
||
String(playbookForm.value.playbook_key || ""),
|
||
playbookForm.value.target_node_codes || []
|
||
)
|
||
);
|
||
const playbookExecutionModeHelpText = computed(() => {
|
||
const recommendation = playbookExecutionModeRecommendation.value;
|
||
if (!String(playbookForm.value.playbook_key || "").trim()) {
|
||
return "选择 playbook 后,会按目标节点自动推荐执行方式。";
|
||
}
|
||
const currentMode = String(playbookForm.value.execution_mode || "").trim();
|
||
const currentOption = selectedPlaybookModeOptions.value.find((item) => String(item.value || "").trim() === currentMode);
|
||
if (currentOption?.disabled) {
|
||
return `推荐路径:${recommendation.recommendedModeLabel}。${String(recommendation.reason || "").trim()} 当前所选模式暂不可用:${String(currentOption.reason || "").trim()}`;
|
||
}
|
||
return `推荐路径:${recommendation.recommendedModeLabel}。${String(recommendation.reason || "").trim()}`;
|
||
});
|
||
const selectedPlaybookStepTitles = computed<string[]>(() =>
|
||
(selectedPlaybook.value.steps || []).map((item: GenericRecord) => String(item.title || item.template_key || "").trim()).filter(Boolean)
|
||
);
|
||
const playbookPreviewHasData = computed(
|
||
() => Boolean(playbookPreview.value.playbook) || Boolean((playbookPreview.value.steps || []).length)
|
||
);
|
||
|
||
const isClusterOnlineNode = (row: GenericRecord) => {
|
||
const clusterStatus = String(row.cluster_status || "").trim();
|
||
return clusterStatus === "online" || clusterStatus === "busy";
|
||
};
|
||
|
||
const isManagedAgentReadyNode = (row: GenericRecord) =>
|
||
Boolean(row.is_managed) && row.is_enabled !== false && Boolean(row.is_agent_online);
|
||
|
||
const isEffectiveWorkerNode = (row: GenericRecord) =>
|
||
Boolean(row.cluster_is_effective_worker) && isClusterOnlineNode(row);
|
||
|
||
const isInspectionVisibleRow = (row: GenericRecord) =>
|
||
Boolean(row?.is_inspection_visible) || isEffectiveWorkerNode(row);
|
||
|
||
const isInspectionEligibleRow = (row: GenericRecord) =>
|
||
Boolean(row?.is_inspection_eligible) || (isManagedAgentReadyNode(row) && isEffectiveWorkerNode(row));
|
||
|
||
const isInspectionParticipatingRow = (row: GenericRecord) => Boolean(row?.cluster_detect_participating);
|
||
|
||
const bulkDiagnosticsEligibleNodes = computed<GenericRecord[]>(() =>
|
||
nodes.value.filter((item) => isManagedAgentReadyNode(item) && isEffectiveWorkerNode(item))
|
||
);
|
||
|
||
const inspectionVisibleFallbackNodes = computed<GenericRecord[]>(() =>
|
||
nodes.value.filter((item) => isEffectiveWorkerNode(item))
|
||
);
|
||
|
||
const bulkDiagnosticsParticipatingNodes = computed<GenericRecord[]>(() =>
|
||
bulkDiagnosticsEligibleNodes.value.filter((item) => Boolean(item.cluster_detect_participating))
|
||
);
|
||
|
||
const bulkDiagnosticsStandbyNodes = computed<GenericRecord[]>(() =>
|
||
bulkDiagnosticsEligibleNodes.value.filter((item) => !Boolean(item.cluster_detect_participating))
|
||
);
|
||
|
||
const bulkDiagnosticsExcludedNodes = computed<GenericRecord[]>(() =>
|
||
nodes.value.filter((item) => isEffectiveWorkerNode(item) && !isManagedAgentReadyNode(item))
|
||
);
|
||
|
||
const inspectionActionBucket = (job: GenericRecord | null | undefined) => {
|
||
const action = String(job?.action || "").trim();
|
||
if (action === "health.snapshot" || action === "diagnostics.collect") {
|
||
return action;
|
||
}
|
||
if (action !== "logs.collect") {
|
||
return "";
|
||
}
|
||
const serviceName = String(job?.payload?.service_name || "").trim().toLowerCase();
|
||
if (!serviceName) {
|
||
return "";
|
||
}
|
||
return serviceName.includes("worker") ? "logs.collect" : "";
|
||
};
|
||
|
||
const buildInspectionIssueSummary = (row: GenericRecord) => {
|
||
const jobMap = (row?.job_map || {}) as Record<string, GenericRecord>;
|
||
const actionLabels: Record<string, string> = {
|
||
"health.snapshot": "健康快照",
|
||
"logs.collect": "Worker 日志",
|
||
"diagnostics.collect": "诊断包",
|
||
};
|
||
const presentStatuses = INSPECTION_ACTION_KEYS.map((key) => String(jobMap[key]?.status || "").trim()).filter(Boolean);
|
||
const nodeVisible = isInspectionVisibleRow(row);
|
||
const nodeEligible = isInspectionEligibleRow(row);
|
||
const nodeParticipating = isInspectionParticipatingRow(row);
|
||
const agentStateLabel = String(row?.agent_state_label || "").trim();
|
||
const agentStateReason = String(row?.agent_state_reason || "").trim();
|
||
const hasSshAccess = Boolean(row?.has_ssh_access);
|
||
|
||
if (nodeVisible && !nodeEligible) {
|
||
if (!Boolean(row?.is_managed)) {
|
||
return {
|
||
problemKind: "handover_gap",
|
||
problemLabel: "未接管",
|
||
problemTitle: "执行节点未接管",
|
||
problemLevel: "error",
|
||
problemKeys: [],
|
||
summary: "节点在线且属于有效执行面,但还没纳入 Node Agent 接管,当前不能稳定下发标准巡检、发布或诊断动作。",
|
||
recommendedAction: "纳管节点",
|
||
recommendedActionCode: "handover_first_gap",
|
||
};
|
||
}
|
||
if (row?.is_enabled === false) {
|
||
return {
|
||
problemKind: "handover_gap",
|
||
problemLabel: "已停用",
|
||
problemTitle: "节点已停用",
|
||
problemLevel: "warning",
|
||
problemKeys: [],
|
||
summary: "节点虽然仍在有效执行面中,但当前被标记为停用,先确认是否需要重新启用后再下发巡检或发布动作。",
|
||
recommendedAction: "查看节点",
|
||
recommendedActionCode: "view_first_gap",
|
||
};
|
||
}
|
||
return {
|
||
problemKind: "handover_gap",
|
||
problemLabel: hasSshAccess ? "SSH 已备好" : (agentStateLabel || "待接入"),
|
||
problemTitle: "Agent 未就绪",
|
||
problemLevel: "error",
|
||
problemKeys: [],
|
||
summary: hasSshAccess
|
||
? (agentStateReason
|
||
? `节点已纳管,SSH 入口已备好,虽然仍未进入 Agent 在线状态,但已可通过 SSH 执行日志、诊断与部分服务控制。${agentStateReason}`
|
||
: "节点已纳管,SSH 入口已备好,当前已可通过 SSH 执行日志、诊断与部分服务控制;标准巡检编排、正式发布与 Rollout 仍优先依赖 Node Agent 在线。")
|
||
: (agentStateReason
|
||
? `节点已纳管,但仍未进入 Agent 在线状态。${agentStateReason}`
|
||
: "节点已纳管,但仍未进入 Agent 在线状态,当前不能稳定执行标准巡检。"),
|
||
recommendedAction: "补接入",
|
||
recommendedActionCode: "handover_first_gap",
|
||
};
|
||
}
|
||
|
||
if (nodeVisible && nodeParticipating && !presentStatuses.length) {
|
||
return {
|
||
problemKind: "participating_no_inspection",
|
||
problemLabel: "现场未巡检",
|
||
problemTitle: "参与检测但未形成巡检",
|
||
problemLevel: "warning",
|
||
problemKeys: [],
|
||
summary: "节点当前正在真实参与检测,但还没有形成标准巡检记录,建议先看现场日志或补一轮标准巡检。",
|
||
recommendedAction: "看现场日志",
|
||
recommendedActionCode: "open_worker_logs_participating",
|
||
};
|
||
}
|
||
|
||
if (nodeVisible && nodeEligible && !nodeParticipating && !presentStatuses.length) {
|
||
return {
|
||
problemKind: "standby_no_inspection",
|
||
problemLabel: "待命未巡检",
|
||
problemTitle: "在线待命但未巡检",
|
||
problemLevel: "info",
|
||
problemKeys: [],
|
||
summary: "节点当前在线可执行,但尚未参与检测,也还没有形成标准巡检记录,适合先做一轮巡检确认是否正常待命。",
|
||
recommendedAction: "补齐标准巡检",
|
||
recommendedActionCode: "run_standard_inspection",
|
||
};
|
||
}
|
||
|
||
const terminalProblemActions = INSPECTION_ACTION_KEYS.filter((key) => {
|
||
const status = String(jobMap[key]?.status || "").trim();
|
||
return ["failed", "blocked", "cancelled"].includes(status);
|
||
});
|
||
if (terminalProblemActions.length) {
|
||
const key = terminalProblemActions[0];
|
||
return {
|
||
problemKind: "terminal_failure",
|
||
problemLabel: "失败/阻断",
|
||
problemTitle: `${actionLabels[key]}失败`,
|
||
problemLevel: "error",
|
||
problemKeys: terminalProblemActions,
|
||
summary: `${actionLabels[key]}当前状态为 ${inspectionJobLabel(jobMap[key])},优先查看事件并重跑标准巡检。`,
|
||
recommendedAction: "先看事件,再重跑巡检",
|
||
recommendedActionCode: "focus_latest_job_events",
|
||
};
|
||
}
|
||
const runningActions = INSPECTION_ACTION_KEYS.filter((key) => {
|
||
const status = String(jobMap[key]?.status || "").trim();
|
||
return ["queued", "dispatching", "running", "awaiting_approval"].includes(status);
|
||
});
|
||
if (runningActions.length) {
|
||
const key = runningActions[0];
|
||
return {
|
||
problemKind: "inflight_execution",
|
||
problemLabel: "执行中",
|
||
problemTitle: `${actionLabels[key]}执行中`,
|
||
problemLevel: "warning",
|
||
problemKeys: runningActions,
|
||
summary: `${actionLabels[key]}仍在${inspectionJobLabel(jobMap[key])},先等待回执或检查对应节点事件流。`,
|
||
recommendedAction: "先看事件 / 等待回执",
|
||
recommendedActionCode: "focus_latest_job_events",
|
||
};
|
||
}
|
||
const missingActions = INSPECTION_ACTION_KEYS.filter((key) => !String(jobMap[key]?.status || "").trim());
|
||
if (missingActions.length) {
|
||
const missingProblemMap: Record<string, [string, string, string]> = {
|
||
"health.snapshot": ["missing_health_snapshot", "缺健康快照", "健康快照缺口"],
|
||
"logs.collect": ["missing_worker_logs", "缺 Worker 日志", "Worker 日志缺口"],
|
||
"diagnostics.collect": ["missing_diagnostics", "缺诊断包", "诊断包缺口"],
|
||
};
|
||
const [problemKind, problemLabel, problemTitle] =
|
||
missingActions.length === 1
|
||
? missingProblemMap[missingActions[0]] || ["missing_inspection_steps", "巡检缺口", "巡检缺口"]
|
||
: ["missing_inspection_steps", "巡检缺口", "巡检缺口"];
|
||
return {
|
||
problemKind,
|
||
problemLabel,
|
||
problemTitle,
|
||
problemLevel: "info",
|
||
problemKeys: missingActions,
|
||
summary: `当前仍缺少 ${missingActions.map((key) => actionLabels[key]).join("、")} 的巡检记录。`,
|
||
recommendedAction: "补齐标准巡检",
|
||
recommendedActionCode: "run_standard_inspection",
|
||
};
|
||
}
|
||
return {
|
||
problemKind: "healthy",
|
||
problemLabel: "已收口",
|
||
problemTitle: "巡检健康",
|
||
problemLevel: "success",
|
||
problemKeys: [],
|
||
summary: "当前三类巡检均已成功回执。",
|
||
recommendedAction: "当前无需处理",
|
||
recommendedActionCode: "no_action",
|
||
};
|
||
};
|
||
|
||
const inspectionHasFilters = computed(
|
||
() =>
|
||
Boolean(String(inspectionFilters.value.status || "").trim()) ||
|
||
Boolean(String(inspectionFilters.value.problem_kind || "").trim()) ||
|
||
Boolean(String(inspectionFilters.value.query || "").trim()) ||
|
||
Boolean(inspectionFilters.value.only_problem) ||
|
||
Boolean(inspectionFilters.value.only_participating)
|
||
);
|
||
|
||
const inspectionPayload = computed<GenericRecord>(() => {
|
||
if (Object.keys(inspectionPayloadState.value || {}).length) {
|
||
return inspectionPayloadState.value;
|
||
}
|
||
return (overview.value.inspection as GenericRecord) || {};
|
||
});
|
||
|
||
const hasBackendInspection = computed(() => Object.keys(inspectionPayload.value || {}).length > 0);
|
||
|
||
const inspectionRowsTotal = computed(() => {
|
||
const total = Number(inspectionPayload.value.rows_total);
|
||
if (Number.isFinite(total) && total >= 0) {
|
||
return total;
|
||
}
|
||
return inspectionRows.value.length;
|
||
});
|
||
|
||
const inspectionVisibleNodesTotal = computed(() => {
|
||
const total = Number(inspectionPayload.value.visible_nodes_total);
|
||
if (Number.isFinite(total) && total >= 0) {
|
||
return total;
|
||
}
|
||
return inspectionRows.value.filter((row) => isInspectionVisibleRow(row)).length;
|
||
});
|
||
|
||
const inspectionEligibleNodesTotal = computed(() => {
|
||
const total = Number(inspectionPayload.value.eligible_nodes_total);
|
||
if (Number.isFinite(total) && total >= 0) {
|
||
return total;
|
||
}
|
||
return inspectionRows.value.filter((row) => isInspectionEligibleRow(row)).length;
|
||
});
|
||
|
||
const inspectionHandoverGapNodesTotal = computed(() => {
|
||
const total = Number(inspectionPayload.value.handover_gap_nodes_total);
|
||
if (Number.isFinite(total) && total >= 0) {
|
||
return total;
|
||
}
|
||
return inspectionRows.value.filter((row) => isInspectionVisibleRow(row) && !isInspectionEligibleRow(row)).length;
|
||
});
|
||
|
||
const inspectionParticipatingNodesTotal = computed(() => {
|
||
const total = Number(inspectionPayload.value.participating_nodes_total);
|
||
if (Number.isFinite(total) && total >= 0) {
|
||
return total;
|
||
}
|
||
return inspectionRows.value.filter((row) => isInspectionParticipatingRow(row)).length;
|
||
});
|
||
|
||
const inspectionStatusOptions = computed(() =>
|
||
Object.entries(inspectionPayload.value.available_status_counts || {
|
||
attention: 0,
|
||
running: 0,
|
||
missing: 0,
|
||
healthy: 0,
|
||
})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
count: Number(count || 0),
|
||
label: `${inspectionOverallLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.filter((item) => item.count > 0)
|
||
);
|
||
|
||
const inspectionProblemKindLabel = (value: unknown) => {
|
||
const normalized = String(value || "").trim();
|
||
const mapping: Record<string, string> = {
|
||
handover_gap: "接管缺口",
|
||
participating_no_inspection: "现场未巡检",
|
||
standby_no_inspection: "待命未巡检",
|
||
terminal_failure: "失败/阻断",
|
||
inflight_execution: "执行中",
|
||
missing_health_snapshot: "缺健康快照",
|
||
missing_worker_logs: "缺 Worker 日志",
|
||
missing_diagnostics: "缺诊断包",
|
||
missing_inspection_steps: "巡检缺口",
|
||
healthy: "已收口",
|
||
};
|
||
return mapping[normalized] || normalized || "未分类";
|
||
};
|
||
|
||
const inspectionProblemKindOptions = computed(() =>
|
||
Object.entries(inspectionPayload.value.available_problem_kind_counts || {})
|
||
.map(([value, count]) => ({
|
||
value,
|
||
count: Number(count || 0),
|
||
label: `${inspectionProblemKindLabel(value)} (${Number(count || 0)})`,
|
||
}))
|
||
.filter((item) => item.count > 0)
|
||
);
|
||
|
||
const localInspectionJobs = computed<GenericRecord[]>(() =>
|
||
jobs.value.filter((item) => Boolean(inspectionActionBucket(item)) && String(item.target_node_code || "").trim())
|
||
);
|
||
|
||
const inspectionJobsTotal = computed(() => {
|
||
const backendTotal = Number(inspectionPayload.value.jobs_total);
|
||
if (Number.isFinite(backendTotal) && backendTotal >= 0) {
|
||
return backendTotal;
|
||
}
|
||
return localInspectionJobs.value.length;
|
||
});
|
||
|
||
const inspectionRows = computed<GenericRecord[]>(() => {
|
||
if (hasBackendInspection.value && Array.isArray(inspectionPayload.value.rows)) {
|
||
return inspectionPayload.value.rows as GenericRecord[];
|
||
}
|
||
|
||
const nodeMap = new Map<string, GenericRecord>(
|
||
nodes.value
|
||
.map((item) => [String(item.node_code || "").trim(), item] as const)
|
||
.filter(([nodeCode]) => Boolean(nodeCode))
|
||
);
|
||
const rowMap = new Map<string, GenericRecord>();
|
||
|
||
const ensureRow = (nodeCode: string) => {
|
||
if (!rowMap.has(nodeCode)) {
|
||
const node = nodeMap.get(nodeCode) || {};
|
||
rowMap.set(nodeCode, {
|
||
node_code: nodeCode,
|
||
region: String(node.region || "").trim(),
|
||
role: String(node.role || "").trim(),
|
||
is_managed: Boolean(node.is_managed),
|
||
is_enabled: node.is_enabled !== false,
|
||
is_agent_online: Boolean(node.is_agent_online),
|
||
agent_state_label: String(node.agent_state_label || "").trim(),
|
||
agent_state_reason: String(node.agent_state_reason || "").trim(),
|
||
cluster_status: String(node.cluster_status || "").trim(),
|
||
cluster_is_effective_worker: Boolean(node.cluster_is_effective_worker),
|
||
cluster_detect_participating: Boolean(node.cluster_detect_participating),
|
||
is_inspection_visible: isInspectionVisibleRow(node),
|
||
is_inspection_eligible: isInspectionEligibleRow(node),
|
||
job_map: {} as Record<string, GenericRecord>,
|
||
latest_job: {},
|
||
last_updated_at: "",
|
||
success_count: 0,
|
||
overall_status: "missing",
|
||
});
|
||
}
|
||
return rowMap.get(nodeCode) as GenericRecord;
|
||
};
|
||
|
||
for (const job of localInspectionJobs.value) {
|
||
const nodeCode = String(job.target_node_code || "").trim();
|
||
const action = inspectionActionBucket(job);
|
||
if (!nodeCode || !action) {
|
||
continue;
|
||
}
|
||
const row = ensureRow(nodeCode);
|
||
if (!row.job_map[action]) {
|
||
row.job_map[action] = job;
|
||
}
|
||
const candidateTime = String(job.updated_at || job.finished_at || job.started_at || job.created_at || "").trim();
|
||
const currentLatestTime = String(row.last_updated_at || "").trim();
|
||
if (!currentLatestTime || candidateTime > currentLatestTime) {
|
||
row.last_updated_at = candidateTime;
|
||
row.latest_job = job;
|
||
}
|
||
}
|
||
|
||
for (const node of inspectionVisibleFallbackNodes.value) {
|
||
const nodeCode = String(node.node_code || "").trim();
|
||
if (nodeCode) {
|
||
ensureRow(nodeCode);
|
||
}
|
||
}
|
||
|
||
const deriveInspectionOverallStatus = (row: GenericRecord) => {
|
||
const jobMap = (row.job_map || {}) as Record<string, GenericRecord>;
|
||
const statuses = INSPECTION_ACTION_KEYS.map((key) => String(jobMap[key]?.status || "").trim());
|
||
const presentStatuses = statuses.filter(Boolean);
|
||
if (!presentStatuses.length) {
|
||
if (isInspectionVisibleRow(row)) {
|
||
if (!isInspectionEligibleRow(row)) {
|
||
return "attention";
|
||
}
|
||
if (isInspectionParticipatingRow(row)) {
|
||
return "attention";
|
||
}
|
||
}
|
||
return "missing";
|
||
}
|
||
if (presentStatuses.some((status) => ["failed", "blocked", "cancelled"].includes(status))) {
|
||
return "attention";
|
||
}
|
||
if (presentStatuses.some((status) => ["queued", "dispatching", "running", "awaiting_approval"].includes(status))) {
|
||
return "running";
|
||
}
|
||
if (INSPECTION_ACTION_KEYS.every((key) => String(jobMap[key]?.status || "").trim() === "success")) {
|
||
return "healthy";
|
||
}
|
||
return "attention";
|
||
};
|
||
|
||
const rows = Array.from(rowMap.values()).map((row) => {
|
||
const jobMap = row.job_map || {};
|
||
const successCount = INSPECTION_ACTION_KEYS.filter((key) => String(jobMap[key]?.status || "").trim() === "success").length;
|
||
const issueSummary = buildInspectionIssueSummary(row);
|
||
return {
|
||
...row,
|
||
success_count: successCount,
|
||
overall_status: deriveInspectionOverallStatus(row),
|
||
issue_summary: issueSummary.summary,
|
||
problem_kind: issueSummary.problemKind,
|
||
problem_label: issueSummary.problemLabel,
|
||
problem_title: issueSummary.problemTitle,
|
||
problem_level: issueSummary.problemLevel,
|
||
problem_keys: issueSummary.problemKeys,
|
||
recommended_action: issueSummary.recommendedAction,
|
||
recommended_action_code: issueSummary.recommendedActionCode,
|
||
};
|
||
});
|
||
|
||
const weightMap: Record<string, number> = {
|
||
attention: 0,
|
||
running: 1,
|
||
missing: 2,
|
||
healthy: 3,
|
||
};
|
||
|
||
return rows.sort((left, right) => {
|
||
const weightDiff = (weightMap[left.overall_status] ?? 99) - (weightMap[right.overall_status] ?? 99);
|
||
if (weightDiff !== 0) {
|
||
return weightDiff;
|
||
}
|
||
const timeLeft = String(left.last_updated_at || "").trim();
|
||
const timeRight = String(right.last_updated_at || "").trim();
|
||
if (timeLeft !== timeRight) {
|
||
return timeRight.localeCompare(timeLeft);
|
||
}
|
||
return String(left.node_code || "").localeCompare(String(right.node_code || ""));
|
||
});
|
||
});
|
||
|
||
const inspectionSummary = computed(() => {
|
||
if (hasBackendInspection.value && inspectionPayload.value.summary) {
|
||
return {
|
||
healthy: Number(inspectionPayload.value.summary.healthy || 0),
|
||
running: Number(inspectionPayload.value.summary.running || 0),
|
||
attention: Number(inspectionPayload.value.summary.attention || 0),
|
||
missing: Number(inspectionPayload.value.summary.missing || 0),
|
||
};
|
||
}
|
||
|
||
const summary = {
|
||
healthy: 0,
|
||
running: 0,
|
||
attention: 0,
|
||
missing: 0,
|
||
};
|
||
for (const row of inspectionRows.value) {
|
||
const status = String(row.overall_status || "missing").trim();
|
||
if (status in summary) {
|
||
summary[status as keyof typeof summary] += 1;
|
||
}
|
||
}
|
||
return summary;
|
||
});
|
||
|
||
const inspectionPriorityQueue = computed<GenericRecord[]>(() => {
|
||
if (hasBackendInspection.value && Array.isArray(inspectionPayload.value.priority_queue)) {
|
||
return inspectionPayload.value.priority_queue as GenericRecord[];
|
||
}
|
||
|
||
const priorityWeight: Record<string, number> = {
|
||
attention: 0,
|
||
running: 1,
|
||
missing: 2,
|
||
healthy: 9,
|
||
};
|
||
return inspectionRows.value
|
||
.filter((row) => String(row.overall_status || "") !== "healthy")
|
||
.map((row) => {
|
||
const overallStatus = String(row.overall_status || "missing");
|
||
const priorityLabel = overallStatus === "attention" ? "P1" : overallStatus === "running" ? "P2" : "P3";
|
||
const priorityType = overallStatus === "attention" ? "danger" : overallStatus === "running" ? "warning" : "info";
|
||
return {
|
||
...row,
|
||
priority_label: priorityLabel,
|
||
priority_type: priorityType,
|
||
priority_weight: priorityWeight[overallStatus] ?? 99,
|
||
};
|
||
})
|
||
.sort((left, right) => {
|
||
const weightDiff = Number(left.priority_weight || 99) - Number(right.priority_weight || 99);
|
||
if (weightDiff !== 0) {
|
||
return weightDiff;
|
||
}
|
||
return String(right.last_updated_at || "").localeCompare(String(left.last_updated_at || ""));
|
||
});
|
||
});
|
||
|
||
const executionNodeStateTagType = (row: GenericRecord) => {
|
||
const state = String(row?.participation_state || "").trim();
|
||
if (state === "load_syncing") return "danger";
|
||
if (state === "running" || state === "claimed") return "warning";
|
||
if (state === "recent_throughput") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const executionNodeWorkloadText = (row: GenericRecord) => {
|
||
const running = Number(row?.items_running || 0);
|
||
const claimed = Number(row?.items_claimed || 0);
|
||
const load = Number(row?.current_load || 0);
|
||
if (running > 0 || claimed > 0) {
|
||
return `执行 ${running} / 已领 ${claimed}`;
|
||
}
|
||
return `负载 ${load}`;
|
||
};
|
||
|
||
const executionNodeRecentText = (row: GenericRecord) => {
|
||
const recent = Number(row?.processed_recent || 0);
|
||
const perMinute = Number(row?.processed_per_minute || 0);
|
||
if (recent > 0 || perMinute > 0) {
|
||
return `${recent} 项 / ${perMinute.toFixed(1)} 项/分`;
|
||
}
|
||
if (String(row?.participation_state || "").trim() === "load_syncing") {
|
||
return "当前负载待确认";
|
||
}
|
||
return "当前无近窗吞吐";
|
||
};
|
||
|
||
const executionNodeReasonText = (row: GenericRecord) =>
|
||
String(row?.participation_reason || row?.detail || row?.phase || "-").trim() || "-";
|
||
|
||
const rolloutPreviewAgentTagType = (row: GenericRecord) => {
|
||
if (Boolean(row?.remote_agent_ready)) return "success";
|
||
if (String(row?.is_managed || "") === "true" || Boolean(row?.is_managed)) return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const rolloutPreviewExecutionMode = computed(
|
||
() => String(rolloutPreviewReleaseGate.value.execution_mode || rolloutForm.value.execution_mode || "").trim() || "remote-agent"
|
||
);
|
||
|
||
const rolloutPreviewExecutionTagType = (row: GenericRecord) => {
|
||
if (Boolean(row?.execution_ready)) return "success";
|
||
if (Boolean(row?.is_managed) && Boolean(row?.is_enabled)) return "warning";
|
||
if (Boolean(row?.is_managed)) return "info";
|
||
return "danger";
|
||
};
|
||
|
||
const rolloutPreviewExecutionLabel = (row: GenericRecord) => {
|
||
if (rolloutPreviewExecutionMode.value === "ssh") {
|
||
return Boolean(row?.execution_ready) ? "SSH 已备好" : "SSH 未备好";
|
||
}
|
||
return Boolean(row?.execution_ready) ? "Agent 可下发" : "Agent 未就绪";
|
||
};
|
||
|
||
const rolloutPreviewExecutionReasonText = (row: GenericRecord) => {
|
||
if (rolloutPreviewExecutionMode.value === "ssh") {
|
||
if (Boolean(row?.execution_ready)) {
|
||
return "当前节点已纳管、已启用,且 SSH 入口完整。";
|
||
}
|
||
if (!Boolean(row?.is_managed)) {
|
||
return "当前节点尚未纳管,SSH 发布无法直接下发。";
|
||
}
|
||
if (!Boolean(row?.is_enabled)) {
|
||
return "当前节点已纳管但未启用,需先恢复启用。";
|
||
}
|
||
if (!Boolean(row?.has_ssh_access)) {
|
||
return "当前节点尚未配置完整 SSH 入口。";
|
||
}
|
||
return "SSH 入口状态待确认。";
|
||
}
|
||
return String(row?.agent_reason || "").trim() || "Agent 接管状态待确认。";
|
||
};
|
||
|
||
const rolloutPreviewInspectionTagType = (row: GenericRecord) => {
|
||
const status = String(row?.inspection_status || "").trim();
|
||
if (status === "healthy") return "success";
|
||
if (status === "running") return "warning";
|
||
if (status === "attention") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const rolloutPreviewReadinessReasonText = (row: GenericRecord) => {
|
||
const parts = [
|
||
rolloutPreviewExecutionReasonText(row),
|
||
String(row?.inspection_reason || "").trim(),
|
||
].filter(Boolean);
|
||
return parts.join(" / ") || "-";
|
||
};
|
||
|
||
const playbookRunStatusLabel = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
if (normalizedStatus === "success") return "成功";
|
||
if (normalizedStatus === "attention") return "待关注";
|
||
if (normalizedStatus === "running") return "收口中";
|
||
if (normalizedStatus === "queued") return "已创建";
|
||
return normalizedStatus || "未知";
|
||
};
|
||
|
||
const playbookRunStatusType = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
if (normalizedStatus === "success") return "success";
|
||
if (normalizedStatus === "attention") return "danger";
|
||
if (normalizedStatus === "running") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const playbookRunFocusAlertType = (level: string) => {
|
||
const normalizedLevel = String(level || "").trim();
|
||
if (normalizedLevel === "danger") return "error";
|
||
if (normalizedLevel === "warning") return "warning";
|
||
if (normalizedLevel === "success") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const playbookRunFocusTagType = (level: string) => {
|
||
const normalizedLevel = String(level || "").trim();
|
||
if (normalizedLevel === "danger") return "danger";
|
||
if (normalizedLevel === "warning") return "warning";
|
||
if (normalizedLevel === "success") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const playbookRunGroupTitle = (groupKey: string) => {
|
||
const normalizedGroupKey = String(groupKey || "").trim();
|
||
if (!normalizedGroupKey) {
|
||
return "-";
|
||
}
|
||
const matched = playbooks.value.find((item) => String(item.group_key || "").trim() === normalizedGroupKey);
|
||
return String(matched?.group_title || normalizedGroupKey).trim() || normalizedGroupKey;
|
||
};
|
||
|
||
const playbookRunCompletedSteps = (run: GenericRecord) =>
|
||
(run.steps || []).filter((step: GenericRecord) => String(step.status || "").trim() === "success").length;
|
||
|
||
const playbookRunProgressText = (run: GenericRecord) => {
|
||
const completedSteps = playbookRunCompletedSteps(run);
|
||
const totalSteps = Number(run.step_count || (run.steps || []).length || 0);
|
||
const terminalJobs = Number(run.terminal_jobs_total || 0);
|
||
const totalJobs = Number(run.jobs_total || 0);
|
||
return `${completedSteps}/${totalSteps} 步;${terminalJobs}/${totalJobs} jobs`;
|
||
};
|
||
|
||
const playbookRunStepProgressText = (step: GenericRecord) => {
|
||
const nodesTotal = Number(step.nodes_total || 0);
|
||
const terminalJobs = Number(step.terminal_jobs_total || 0);
|
||
const totalJobs = Number(step.jobs_total || 0);
|
||
return `${nodesTotal} 台;${terminalJobs}/${totalJobs} jobs`;
|
||
};
|
||
|
||
const playbookRunStatusCountsText = (statusCounts: GenericRecord) => {
|
||
const counts = statusCounts || {};
|
||
const parts = [
|
||
Number(counts.success || 0) > 0 ? `成功 ${Number(counts.success || 0)}` : "",
|
||
Number(counts.running || 0) > 0 ? `执行 ${Number(counts.running || 0)}` : "",
|
||
Number(counts.queued || 0) > 0 ? `排队 ${Number(counts.queued || 0)}` : "",
|
||
Number(counts.awaiting_approval || 0) > 0 ? `待审批 ${Number(counts.awaiting_approval || 0)}` : "",
|
||
Number(counts.failed || 0) > 0 ? `失败 ${Number(counts.failed || 0)}` : "",
|
||
Number(counts.blocked || 0) > 0 ? `阻断 ${Number(counts.blocked || 0)}` : "",
|
||
Number(counts.cancelled || 0) > 0 ? `取消 ${Number(counts.cancelled || 0)}` : "",
|
||
].filter(Boolean);
|
||
return parts.join(" / ") || "暂无";
|
||
};
|
||
|
||
const playbookRunLatestJobText = (job: GenericRecord | null | undefined) => {
|
||
const normalizedJob = job || {};
|
||
if (!normalizedJob.id) {
|
||
return "暂无";
|
||
}
|
||
const status = String(normalizedJob.status_label || "").trim() || playbookRunStatusLabel(String(normalizedJob.status || ""));
|
||
return `${normalizedJob.job_code || "-"} / ${status}`;
|
||
};
|
||
|
||
const canCancelPlaybookRun = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
return normalizedStatus === "running" || normalizedStatus === "queued";
|
||
};
|
||
|
||
const playbookRunEventLevelType = (level: string) => {
|
||
const normalizedLevel = String(level || "").trim();
|
||
if (normalizedLevel === "error") return "danger";
|
||
if (normalizedLevel === "warning") return "warning";
|
||
if (normalizedLevel === "success") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const genericStatusLabel = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
if (normalizedStatus === "attention") return "待处理";
|
||
if (normalizedStatus === "ready") return "已就绪";
|
||
if (normalizedStatus === "success") return "成功";
|
||
if (normalizedStatus === "failed") return "失败";
|
||
if (normalizedStatus === "blocked") return "阻断";
|
||
if (normalizedStatus === "cancelled") return "已取消";
|
||
if (normalizedStatus === "awaiting_approval") return "待审批";
|
||
if (normalizedStatus === "dispatching") return "待派发";
|
||
if (normalizedStatus === "running") return "执行中";
|
||
if (normalizedStatus === "queued") return "排队中";
|
||
if (normalizedStatus === "completed") return "已完成";
|
||
if (normalizedStatus === "completed_with_issues") return "完成但有异常";
|
||
if (normalizedStatus === "ready_for_next_batch") return "待下一批";
|
||
if (normalizedStatus === "halted") return "已暂停";
|
||
if (normalizedStatus === "planned") return "已规划";
|
||
if (normalizedStatus === "active") return "已激活";
|
||
if (normalizedStatus === "ready") return "就绪";
|
||
if (normalizedStatus === "draft") return "草稿";
|
||
return normalizedStatus || "未知";
|
||
};
|
||
|
||
const runbookSequenceStatusLabel = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
if (normalizedStatus === "attention") return "待处理";
|
||
if (normalizedStatus === "ready") return "已就绪";
|
||
if (normalizedStatus === "planned") return "已规划";
|
||
return genericStatusLabel(normalizedStatus);
|
||
};
|
||
|
||
const runbookSequenceStatusType = (status: string) => {
|
||
const normalizedStatus = String(status || "").trim();
|
||
if (normalizedStatus === "attention") return "warning";
|
||
if (normalizedStatus === "ready") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const codexRecommendationLabel = (recommendation: string, fallback = "") => {
|
||
const normalizedRecommendation = String(recommendation || "").trim();
|
||
if (normalizedRecommendation === "auto_execute") return "可自动执行";
|
||
if (normalizedRecommendation === "confirm_then_execute") return "建议确认";
|
||
if (normalizedRecommendation === "resolve_first") return "先复核";
|
||
if (normalizedRecommendation === "open_ui") return "进入工作区";
|
||
if (normalizedRecommendation === "blocked") return "当前阻断";
|
||
return fallback || normalizedRecommendation || "待判断";
|
||
};
|
||
|
||
const codexRecommendationTagType = (recommendation: string) => {
|
||
const normalizedRecommendation = String(recommendation || "").trim();
|
||
if (normalizedRecommendation === "auto_execute") return "success";
|
||
if (normalizedRecommendation === "confirm_then_execute" || normalizedRecommendation === "resolve_first") return "warning";
|
||
if (normalizedRecommendation === "blocked") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const codexAutomationLevelLabel = (level: string) => {
|
||
const normalizedLevel = String(level || "").trim();
|
||
if (normalizedLevel === "safe_auto") return "safe_auto";
|
||
if (normalizedLevel === "guarded_auto") return "guarded_auto";
|
||
if (normalizedLevel === "mixed") return "mixed";
|
||
if (normalizedLevel === "ui_only") return "ui_only";
|
||
if (normalizedLevel === "blocked") return "blocked";
|
||
return normalizedLevel || "unknown";
|
||
};
|
||
|
||
const codexAutomationTagType = (level: string) => {
|
||
const normalizedLevel = String(level || "").trim();
|
||
if (normalizedLevel === "safe_auto") return "success";
|
||
if (normalizedLevel === "guarded_auto" || normalizedLevel === "mixed") return "warning";
|
||
if (normalizedLevel === "blocked") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const codexExecutorModeLabel = (mode: string) => {
|
||
const normalizedMode = String(mode || "").trim();
|
||
if (normalizedMode === "settings") return "settings";
|
||
if (normalizedMode === "ops-playbook") return "ops-playbook";
|
||
if (normalizedMode === "smart-release-rollout") return "smart-release-rollout";
|
||
if (normalizedMode === "cluster-sync-or-ui") return "cluster-sync-or-ui";
|
||
if (normalizedMode === "ui-intent") return "ui-intent";
|
||
if (normalizedMode === "manual-or-ui") return "manual-or-ui";
|
||
return normalizedMode || "-";
|
||
};
|
||
|
||
const activityKindLabel = (kind: string) => {
|
||
const normalizedKind = String(kind || "").trim();
|
||
if (normalizedKind === "playbook_run") return "编排";
|
||
if (normalizedKind === "ops_job") return "任务";
|
||
if (normalizedKind === "rollout") return "Rollout";
|
||
if (normalizedKind === "runbook_sequence") return "路径";
|
||
if (normalizedKind === "execution_scene") return "现场";
|
||
if (normalizedKind === "log_sync") return "日志";
|
||
return normalizedKind || "活动";
|
||
};
|
||
|
||
const activityKindTagType = (kind: string) => {
|
||
const normalizedKind = String(kind || "").trim();
|
||
if (normalizedKind === "playbook_run") return "warning";
|
||
if (normalizedKind === "ops_job") return "info";
|
||
if (normalizedKind === "rollout") return "success";
|
||
if (normalizedKind === "runbook_sequence") return "";
|
||
if (normalizedKind === "execution_scene") return "success";
|
||
if (normalizedKind === "log_sync") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const activityStatusType = (row: GenericRecord) => {
|
||
const kind = String(row?.kind || "").trim();
|
||
const status = String(row?.status || "").trim();
|
||
if (kind === "playbook_run") {
|
||
return playbookRunStatusType(status);
|
||
}
|
||
if (kind === "rollout") {
|
||
return rolloutStatusType(status);
|
||
}
|
||
if (kind === "runbook_sequence") {
|
||
return runbookSequenceStatusType(status);
|
||
}
|
||
if (kind === "execution_scene" || kind === "log_sync") {
|
||
if (status === "running") return "warning";
|
||
if (status === "attention") return "warning";
|
||
if (status === "ready") return "success";
|
||
return "info";
|
||
}
|
||
return jobStatusType(status);
|
||
};
|
||
|
||
const activityStatusLabel = (row: GenericRecord) => {
|
||
const direct = String(row?.status_label || "").trim();
|
||
if (direct) {
|
||
return direct;
|
||
}
|
||
const kind = String(row?.kind || "").trim();
|
||
const status = String(row?.status || "").trim();
|
||
if (kind === "playbook_run") {
|
||
return playbookRunStatusLabel(status);
|
||
}
|
||
if (kind === "runbook_sequence") {
|
||
return runbookSequenceStatusLabel(status);
|
||
}
|
||
if (kind === "execution_scene" || kind === "log_sync") {
|
||
return runbookSequenceStatusLabel(status);
|
||
}
|
||
return genericStatusLabel(status);
|
||
};
|
||
|
||
const activityActionLabel = (row: GenericRecord) => {
|
||
const kind = String(row?.kind || "").trim();
|
||
if (kind === "playbook_run") return "查看编排";
|
||
if (kind === "rollout") return "查看任务";
|
||
if (kind === "runbook_sequence") return "查看路径";
|
||
if (kind === "execution_scene" || kind === "log_sync") return "查看现场";
|
||
if (String(row?.ui_intent?.kind || "").trim() === "job_detail") return "看详情";
|
||
return "看事件";
|
||
};
|
||
|
||
const activityExecutionModeLabel = (row: GenericRecord) => {
|
||
const kind = String(row?.kind || "").trim();
|
||
if (kind === "runbook_sequence") {
|
||
return "";
|
||
}
|
||
return String(row?.execution_mode_label || "").trim() || executionModeLabel(String(row?.execution_mode || "").trim());
|
||
};
|
||
|
||
const actionTemplateNodePreviewTagType = (row: GenericRecord) => {
|
||
if (row?.blocked) return "danger";
|
||
if (row?.approval_required) return "warning";
|
||
if ((row?.warnings || []).length) return "warning";
|
||
return "success";
|
||
};
|
||
|
||
const actionTemplateNodePreviewLabel = (row: GenericRecord) => {
|
||
if (row?.blocked) return "阻断";
|
||
if (row?.approval_required) return "建议审批";
|
||
if ((row?.warnings || []).length) return "需关注";
|
||
return "通过";
|
||
};
|
||
|
||
const actionTemplateNodePreviewReasonText = (row: GenericRecord) => {
|
||
const parts = [
|
||
...(row?.blocking_reasons || []),
|
||
...(row?.approval_reasons || []),
|
||
...(row?.warnings || []),
|
||
]
|
||
.map((item: unknown) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
return parts.join(" / ") || "当前未发现额外风险";
|
||
};
|
||
|
||
const driverHandoverGapNodes = computed<GenericRecord[]>(() =>
|
||
nodes.value.filter((item) => isEffectiveWorkerNode(item) && !isManagedAgentReadyNode(item))
|
||
);
|
||
|
||
const driverParticipatingNodeCodes = computed(() =>
|
||
bulkDiagnosticsParticipatingNodes.value.map((item) => String(item.node_code || "").trim()).filter(Boolean)
|
||
);
|
||
|
||
const driverStandbyNodeCodes = computed(() =>
|
||
bulkDiagnosticsStandbyNodes.value.map((item) => String(item.node_code || "").trim()).filter(Boolean)
|
||
);
|
||
|
||
const backendDriverRecommendations = computed<GenericRecord[]>(() => overview.value.driver_recommendations || []);
|
||
|
||
const findOpsNodeByCode = (nodeCode: string): GenericRecord => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (!normalizedNodeCode) {
|
||
return {};
|
||
}
|
||
return (
|
||
nodes.value.find((item) => String(item.node_code || "").trim() === normalizedNodeCode) ||
|
||
clusterNodes.value.find((item) => String(item.node_code || "").trim() === normalizedNodeCode) ||
|
||
{}
|
||
);
|
||
};
|
||
|
||
const openPlaybookRunDetailByCode = async (
|
||
runCode: string,
|
||
options: {
|
||
focusStepKey?: string;
|
||
} = {}
|
||
) => {
|
||
const normalizedRunCode = String(runCode || "").trim();
|
||
if (!normalizedRunCode) {
|
||
ElMessage.warning("当前缺少编排 run_code,无法打开详情");
|
||
return;
|
||
}
|
||
const { focusStepKey = "" } = options;
|
||
playbookRunEventFilters.value = {
|
||
step_key: "",
|
||
node_code: "",
|
||
};
|
||
playbookRunEventsPayload.value = {};
|
||
activePlaybookRun.value =
|
||
playbookRuns.value.find((item) => String(item.run_code || "").trim() === normalizedRunCode) ||
|
||
({ run_code: normalizedRunCode } as GenericRecord);
|
||
playbookRunDetailVisible.value = true;
|
||
await Promise.all([loadPlaybookRunDetail(normalizedRunCode), loadPlaybookRunEvents(normalizedRunCode, { silent: true })]);
|
||
if (String(focusStepKey || "").trim()) {
|
||
playbookRunEventFilters.value.step_key = String(focusStepKey || "").trim();
|
||
await loadPlaybookRunEvents(normalizedRunCode, { silent: true });
|
||
}
|
||
};
|
||
|
||
const openPlaybookRunLatestEvents = async (runCode: string, focusStepKey = "") => {
|
||
await openPlaybookRunDetailByCode(runCode, { focusStepKey });
|
||
const latestJob = activePlaybookRun.value?.latest_job || {};
|
||
if (latestJob?.id) {
|
||
await openJobEvents(latestJob);
|
||
}
|
||
};
|
||
|
||
const focusOpsRunbookSequenceCard = async (sequenceKey: string) => {
|
||
const normalizedSequenceKey = String(sequenceKey || "").trim();
|
||
if (!normalizedSequenceKey) {
|
||
return false;
|
||
}
|
||
const focusElement = async () => {
|
||
await nextTick();
|
||
return document.getElementById(`runbook-sequence-card-${normalizedSequenceKey}`);
|
||
};
|
||
let element = await focusElement();
|
||
if (!element) {
|
||
const response = await opsApi.runbook().catch(() => ({ data: {} }));
|
||
runbookPayload.value = response?.data || {};
|
||
element = await focusElement();
|
||
}
|
||
if (!element) {
|
||
return false;
|
||
}
|
||
focusedRunbookSequenceKey.value = normalizedSequenceKey;
|
||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
if (focusedRunbookSequenceTimer) {
|
||
window.clearTimeout(focusedRunbookSequenceTimer);
|
||
}
|
||
focusedRunbookSequenceTimer = window.setTimeout(() => {
|
||
if (focusedRunbookSequenceKey.value === normalizedSequenceKey) {
|
||
focusedRunbookSequenceKey.value = "";
|
||
}
|
||
focusedRunbookSequenceTimer = null;
|
||
}, 2400);
|
||
return true;
|
||
};
|
||
|
||
const focusExecutionScenePanel = async (options: { openLogDrawer?: boolean; nodeCode?: string } = {}) => {
|
||
const { openLogDrawer = false, nodeCode = "" } = options;
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
await nextTick();
|
||
const element = document.getElementById("ops-execution-scene-panel");
|
||
if (element) {
|
||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
}
|
||
if (openLogDrawer) {
|
||
if (normalizedNodeCode) {
|
||
await openRemoteLogDrawer(normalizedNodeCode);
|
||
return true;
|
||
}
|
||
if (opsWorkerLogPreviewLines.value.length) {
|
||
await openRemoteLogDrawer();
|
||
return true;
|
||
}
|
||
}
|
||
return Boolean(element) || (openLogDrawer && (Boolean(normalizedNodeCode) || opsWorkerLogPreviewLines.value.length > 0));
|
||
};
|
||
|
||
const applyDriverFocusRef = async (focusRef: GenericRecord = {}) => {
|
||
const kind = String(focusRef?.kind || "").trim();
|
||
if (!kind) {
|
||
return false;
|
||
}
|
||
if (kind === "runbook_sequence") {
|
||
return await focusOpsRunbookSequenceCard(String(focusRef?.sequence_key || ""));
|
||
}
|
||
if (kind === "node_scene_log") {
|
||
return await focusExecutionScenePanel({
|
||
openLogDrawer: true,
|
||
nodeCode: String(focusRef?.node_code || ""),
|
||
});
|
||
}
|
||
if (kind === "playbook_run") {
|
||
const runCode = String(focusRef?.run_code || "").trim();
|
||
if (!runCode) {
|
||
return false;
|
||
}
|
||
await openPlaybookRunDetailByCode(runCode, {
|
||
focusStepKey: String(focusRef?.focus_step_key || ""),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "ops_job") {
|
||
const jobId = Number(focusRef?.job_id || 0);
|
||
if (!jobId) {
|
||
return false;
|
||
}
|
||
await openJobEvents({
|
||
id: jobId,
|
||
job_code: String(focusRef?.job_code || ""),
|
||
action: String(focusRef?.action || ""),
|
||
target_node_code: String(focusRef?.target_node_code || ""),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "release_rollout") {
|
||
const releaseId = Number(focusRef?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
}
|
||
const rolloutId = Number(focusRef?.rollout_id || 0);
|
||
if (rolloutId > 0) {
|
||
await openRolloutJobsById(rolloutId);
|
||
return true;
|
||
}
|
||
return releaseId > 0;
|
||
}
|
||
if (kind === "release_hub") {
|
||
await scrollToOpsPanel("ops-release-hub-panel");
|
||
const releaseId = Number(focusRef?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
} else if (!selectedReleaseId.value && preferredNodeOpsRelease.value.id) {
|
||
selectedReleaseId.value = Number(preferredNodeOpsRelease.value.id || 0) || null;
|
||
}
|
||
const rolloutId = Number(focusRef?.rollout_id || 0);
|
||
if (rolloutId > 0 && String(focusRef?.section || "").trim() === "rollout_jobs") {
|
||
await openRolloutJobsById(rolloutId);
|
||
return true;
|
||
}
|
||
if (String(focusRef?.section || "").trim() === "release_launchpad") {
|
||
const mode = String(focusRef?.mode || "").trim() === "control" ? "control" : "worker";
|
||
if (String(focusRef?.mode || "").trim()) {
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
await openReleaseLaunchpadReview(mode);
|
||
}
|
||
return true;
|
||
}
|
||
return releaseId > 0 || Boolean(selectedReleaseId.value);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const applyDriverUiIntent = async (intent: GenericRecord = {}) => {
|
||
const kind = String(intent?.kind || "").trim();
|
||
if (!kind) {
|
||
return false;
|
||
}
|
||
if (kind === "focus_ref") {
|
||
return await applyDriverFocusRef((intent?.focus_ref || intent) as GenericRecord);
|
||
}
|
||
if (kind === "focus_runbook_sequence") {
|
||
return await focusOpsRunbookSequenceCard(String(intent?.sequence_key || ""));
|
||
}
|
||
if (kind === "focus_execution_scene") {
|
||
return await focusExecutionScenePanel({
|
||
openLogDrawer: Boolean(intent?.open_log_drawer),
|
||
});
|
||
}
|
||
if (kind === "playbook_run_detail") {
|
||
await openPlaybookRunDetailByCode(String(intent?.run_code || ""), {
|
||
focusStepKey: String(intent?.focus_step_key || ""),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "playbook_run_latest_events") {
|
||
await openPlaybookRunLatestEvents(String(intent?.run_code || ""), String(intent?.focus_step_key || ""));
|
||
return true;
|
||
}
|
||
if (kind === "managed_node_handover") {
|
||
startNodeHandoverFlow(findOpsNodeByCode(String(intent?.node_code || "")));
|
||
return true;
|
||
}
|
||
if (kind === "managed_node_edit") {
|
||
openManagedNodeDialog(findOpsNodeByCode(String(intent?.node_code || "")));
|
||
return true;
|
||
}
|
||
if (kind === "job_events") {
|
||
const jobId = Number(intent?.job_id || 0);
|
||
if (!jobId) {
|
||
return false;
|
||
}
|
||
await openJobEvents({
|
||
id: jobId,
|
||
job_code: String(intent?.job_code || ""),
|
||
action: String(intent?.action || ""),
|
||
target_node_code: String(intent?.target_node_code || ""),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "job_detail") {
|
||
const jobId = Number(intent?.job_id || 0);
|
||
if (!jobId) {
|
||
return false;
|
||
}
|
||
await openJobDetail({
|
||
id: jobId,
|
||
job_code: String(intent?.job_code || ""),
|
||
action: String(intent?.action || ""),
|
||
target_node_code: String(intent?.target_node_code || ""),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "rollout_jobs") {
|
||
const rolloutId = Number(intent?.rollout_id || 0);
|
||
if (!rolloutId) {
|
||
return false;
|
||
}
|
||
await openRolloutJobsById(rolloutId);
|
||
return true;
|
||
}
|
||
if (kind === "open_rollout_dialog") {
|
||
const releaseId = Number(intent?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
rolloutTargetReleaseId.value = releaseId;
|
||
openRolloutDialog(findReleaseById(releaseId));
|
||
return true;
|
||
}
|
||
openRolloutDialog();
|
||
return true;
|
||
}
|
||
if (kind === "open_release_dialog") {
|
||
openReleaseDialog();
|
||
return true;
|
||
}
|
||
if (kind === "open_stack_diagnosis") {
|
||
await loadOverview();
|
||
await openOpsStackDiagnosisOperatorDecisionWorkspace();
|
||
return true;
|
||
}
|
||
if (kind === "release_launchpad_review") {
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
const mode = String(intent?.mode || "worker").trim() === "control" ? "control" : "worker";
|
||
await openReleaseLaunchpadReview(mode);
|
||
return true;
|
||
}
|
||
if (kind === "open_playbook_dialog") {
|
||
const playbookKey = String(intent?.playbook_key || "").trim();
|
||
if (!playbookKey) {
|
||
return false;
|
||
}
|
||
let playbook = findPlaybookByKey(playbookKey);
|
||
if (!playbook.key) {
|
||
await loadPlaybooks();
|
||
playbook = findPlaybookByKey(playbookKey);
|
||
}
|
||
if (!playbook.key) {
|
||
ElMessage.warning(`当前还没有找到 playbook:${playbookKey}`);
|
||
return true;
|
||
}
|
||
await openPlaybookDialog(playbook, {
|
||
targetNodeCodes: normalizeDriverNodeCodes(intent?.target_node_codes),
|
||
executionMode: String(intent?.execution_mode || "").trim(),
|
||
autoApprove: intent?.auto_approve === undefined ? undefined : Boolean(intent?.auto_approve),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "open_action_template_dialog") {
|
||
const templateKey = String(intent?.template_key || "").trim();
|
||
if (!templateKey) {
|
||
return false;
|
||
}
|
||
let template = findActionTemplateByKey(templateKey);
|
||
if (!template.key) {
|
||
await loadActionTemplates();
|
||
template = findActionTemplateByKey(templateKey);
|
||
}
|
||
if (!template.key) {
|
||
ElMessage.warning(`当前还没有找到动作模板:${templateKey}`);
|
||
return true;
|
||
}
|
||
await openActionTemplateDialog(template, {
|
||
targetNodeCodes: normalizeDriverNodeCodes(intent?.target_node_codes),
|
||
prefillPayload: { ...(intent?.payload || {}) },
|
||
executionMode: String(intent?.execution_mode || "").trim(),
|
||
autoApprove: intent?.auto_approve === undefined ? undefined : Boolean(intent?.auto_approve),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "open_release_deploy_template") {
|
||
const releaseId = Number(intent?.release_id || 0);
|
||
const templateKey = String(intent?.template_key || "deploy.release.custom").trim() || "deploy.release.custom";
|
||
const targetNodeCodes = normalizeDriverNodeCodes(intent?.target_node_codes);
|
||
const release = releaseId > 0 ? findReleaseById(releaseId) : preferredNodeOpsRelease.value;
|
||
openReleaseDeployTemplateDialog(release, templateKey, {
|
||
targetNodeCodes,
|
||
executionMode: String(intent?.execution_mode || "").trim(),
|
||
});
|
||
return true;
|
||
}
|
||
if (kind === "focus_release_hub") {
|
||
const applied = await applyDriverFocusRef((intent?.focus_ref || {}) as GenericRecord);
|
||
if (applied) {
|
||
return true;
|
||
}
|
||
const releaseId = Number(intent?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
return true;
|
||
}
|
||
if (!selectedReleaseId.value && preferredNodeOpsRelease.value.id) {
|
||
selectedReleaseId.value = Number(preferredNodeOpsRelease.value.id || 0) || null;
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const runLocalDriverActionCode = async (actionCode: string, nodeCodes: string[] = [], actionPayload: GenericRecord = {}) => {
|
||
const normalizedActionCode = String(actionCode || "").trim();
|
||
const normalizedNodeCodes = Array.from(new Set((nodeCodes || []).map((item) => String(item || "").trim()).filter(Boolean)));
|
||
if (!normalizedActionCode) {
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "focus_activity_item") {
|
||
const applied = await applyDriverUiIntent((actionPayload?.ui_intent || {}) as GenericRecord);
|
||
if (!applied) {
|
||
await applyDriverFocusRef((actionPayload?.focus_ref || {}) as GenericRecord);
|
||
}
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "focus_latest_job_events") {
|
||
const applied = await applyDriverUiIntent((actionPayload?.ui_intent || {}) as GenericRecord);
|
||
if (!applied && normalizedNodeCodes[0]) {
|
||
await openInspectionRowEvents({ node_code: normalizedNodeCodes[0] });
|
||
return;
|
||
}
|
||
if (!applied) {
|
||
await applyDriverFocusRef((actionPayload?.focus_ref || {}) as GenericRecord);
|
||
}
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "focus_playbook_run") {
|
||
await openPlaybookRunDetailByCode(String(actionPayload?.run_code || ""), {
|
||
focusStepKey: String(actionPayload?.focus_step_key || ""),
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_playbook_run_latest_events") {
|
||
await openPlaybookRunLatestEvents(
|
||
String(actionPayload?.run_code || ""),
|
||
String(actionPayload?.focus_step_key || "")
|
||
);
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "handover_first_gap") {
|
||
startNodeHandoverFlow(findOpsNodeByCode(normalizedNodeCodes[0] || ""));
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "view_first_gap") {
|
||
openManagedNodeDialog(findOpsNodeByCode(normalizedNodeCodes[0] || ""));
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "disable_log_sync") {
|
||
await persistOpsWorkerLogSync(false, "key");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "enable_log_sync_key") {
|
||
await persistOpsWorkerLogSync(true, "key");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "enable_log_sync_full") {
|
||
await persistOpsWorkerLogSync(true, "full");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_worker_logs_participating" || normalizedActionCode === "open_worker_logs_standby") {
|
||
openPrefilledActionTemplateDialog("logs.collect", normalizedNodeCodes, buildWorkerLogCollectPayload(120));
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_worker_logs") {
|
||
openPrefilledActionTemplateDialog("logs.collect", normalizedNodeCodes, buildWorkerLogCollectPayload(120));
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "run_inspection_participating" || normalizedActionCode === "run_inspection_standby") {
|
||
await runStandardInspectionSequenceForNodes(normalizedNodeCodes, {
|
||
requestedBy: `web-ui/driver/${normalizedActionCode}`,
|
||
successLabel: `${normalizedNodeCodes.length} 台节点`,
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "run_standard_inspection") {
|
||
await runStandardInspectionSequenceForNodes(normalizedNodeCodes, {
|
||
requestedBy: `web-ui/driver/${normalizedActionCode}`,
|
||
successLabel: `${normalizedNodeCodes.length} 台节点`,
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_diagnostics_participating") {
|
||
openPrefilledActionTemplateDialog("diagnostics.collect", normalizedNodeCodes, { lines: 200 });
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_diagnostics") {
|
||
openPrefilledActionTemplateDialog("diagnostics.collect", normalizedNodeCodes, { lines: 200 });
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_rollout_dialog") {
|
||
const releaseId = Number(actionPayload?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
rolloutTargetReleaseId.value = releaseId;
|
||
openRolloutDialog(findReleaseById(releaseId));
|
||
return;
|
||
}
|
||
openRolloutDialog();
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "publish_latest_worker") {
|
||
await createReleaseFromLatestPackageAndCreateSmartRollout("worker");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "review_smart_rollout_preview") {
|
||
await openReleaseLaunchpadReview("worker");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "review_control_rollout") {
|
||
await openReleaseLaunchpadReview("control");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "fix_rollout_blockers") {
|
||
const release =
|
||
(Number(releaseLaunchpadLatestRelease.value.id || 0) > 0 ? releaseLaunchpadLatestRelease.value : latestRelease.value) || {};
|
||
if (Number(release?.id || 0) > 0) {
|
||
await openRolloutDialog(release);
|
||
} else {
|
||
await openReleaseDialog();
|
||
await importLatestReleasePackage();
|
||
}
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "fix_managed_nodes") {
|
||
if (releaseLaunchpadFirstGapNode.value?.node_code) {
|
||
startNodeHandoverFlow(releaseLaunchpadFirstGapNode.value);
|
||
} else {
|
||
await syncNodesFromCluster();
|
||
}
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "bootstrap_run" || normalizedActionCode === "run_acceptance") {
|
||
const fallbackNodeCode = String(
|
||
actionPayload?.node_code || releaseLaunchpadFirstGapNode.value?.node_code || normalizedNodeCodes[0] || ""
|
||
).trim();
|
||
if (!fallbackNodeCode) {
|
||
ElMessage.warning("当前缺少目标节点,无法执行接管恢复动作");
|
||
return;
|
||
}
|
||
loading.value.driverActionKey = `local-recovery:${fallbackNodeCode}`;
|
||
try {
|
||
const response = await opsApi.executeNodeOnboardingRecovery(fallbackNodeCode, {
|
||
requested_by: `web-ui/driver/${normalizedActionCode}`,
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
});
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadNodes(),
|
||
loadReleaseLaunchpad({ silent: true }),
|
||
refreshAgentOnboardingState({ silent: true }),
|
||
]);
|
||
ElMessage.success(response.message || `${fallbackNodeCode} 接管恢复任务已创建`);
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "release_package") {
|
||
await openReleaseDialog();
|
||
await importLatestReleasePackage();
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "api-restart") {
|
||
await loadOverview();
|
||
await openOpsStackDiagnosisOperatorDecisionWorkspace();
|
||
ElMessage.warning("当前更像控制面 API 还没刷新到最新代码,请优先执行 runtime-refresh-recover。");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "release_prepare") {
|
||
await loadLatestReleasePackageMetadata({ silent: true });
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
await openReleaseDialog();
|
||
ElMessage.warning("当前无法直接代替后端执行最终签收,请在控制面后端动作成功后再继续发布。");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_release_dialog") {
|
||
openReleaseDialog();
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "create_smart_release_rollout_worker") {
|
||
await createReleaseFromLatestPackageAndCreateSmartRollout("worker");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "create_smart_release_rollout_control") {
|
||
await createReleaseFromLatestPackageAndCreateSmartRollout("control");
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "create_release_rollout_worker") {
|
||
const releaseId = Number(actionPayload?.release_id || selectedReleaseId.value || 0);
|
||
await createSmartRolloutForRelease("worker", releaseId > 0 ? findReleaseById(releaseId) : undefined);
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "create_release_rollout_control") {
|
||
const releaseId = Number(actionPayload?.release_id || selectedReleaseId.value || 0);
|
||
await createSmartRolloutForRelease("control", releaseId > 0 ? findReleaseById(releaseId) : undefined);
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_playbook_dialog") {
|
||
const playbookKey = String(actionPayload?.playbook_key || "").trim();
|
||
if (!playbookKey) {
|
||
return;
|
||
}
|
||
let playbook = findPlaybookByKey(playbookKey);
|
||
if (!playbook.key) {
|
||
await loadPlaybooks();
|
||
playbook = findPlaybookByKey(playbookKey);
|
||
}
|
||
if (!playbook.key) {
|
||
ElMessage.warning(`当前还没有找到 playbook:${playbookKey}`);
|
||
return;
|
||
}
|
||
await openPlaybookDialog(playbook, {
|
||
targetNodeCodes: normalizeDriverNodeCodes(actionPayload?.target_node_codes || normalizedNodeCodes),
|
||
executionMode: String(actionPayload?.execution_mode || "").trim(),
|
||
autoApprove: actionPayload?.auto_approve === undefined ? undefined : Boolean(actionPayload?.auto_approve),
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "open_action_template_dialog") {
|
||
const templateKey = String(actionPayload?.template_key || "").trim();
|
||
if (!templateKey) {
|
||
return;
|
||
}
|
||
let template = findActionTemplateByKey(templateKey);
|
||
if (!template.key) {
|
||
await loadActionTemplates();
|
||
template = findActionTemplateByKey(templateKey);
|
||
}
|
||
if (!template.key) {
|
||
ElMessage.warning(`当前还没有找到动作模板:${templateKey}`);
|
||
return;
|
||
}
|
||
await openActionTemplateDialog(template, {
|
||
targetNodeCodes: normalizeDriverNodeCodes(actionPayload?.target_node_codes || normalizedNodeCodes),
|
||
prefillPayload: { ...(actionPayload?.payload || {}) },
|
||
executionMode: String(actionPayload?.execution_mode || "").trim(),
|
||
autoApprove: actionPayload?.auto_approve === undefined ? undefined : Boolean(actionPayload?.auto_approve),
|
||
});
|
||
return;
|
||
}
|
||
if (
|
||
normalizedActionCode === "open_release_deploy_control" ||
|
||
normalizedActionCode === "open_release_deploy_worker" ||
|
||
normalizedActionCode === "open_release_deploy_custom"
|
||
) {
|
||
const templateKeyMap: Record<string, string> = {
|
||
open_release_deploy_control: "deploy.release.control",
|
||
open_release_deploy_worker: "deploy.release.worker",
|
||
open_release_deploy_custom: "deploy.release.custom",
|
||
};
|
||
const releaseId = Number(actionPayload?.release_id || 0);
|
||
const release = releaseId > 0 ? findReleaseById(releaseId) : preferredNodeOpsRelease.value;
|
||
openReleaseDeployTemplateDialog(
|
||
release,
|
||
templateKeyMap[normalizedActionCode] || "deploy.release.custom",
|
||
{
|
||
targetNodeCodes: normalizeDriverNodeCodes(actionPayload?.target_node_codes || normalizedNodeCodes),
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
if (normalizedActionCode === "focus_release_hub") {
|
||
const applied = await applyDriverFocusRef((actionPayload?.focus_ref || {}) as GenericRecord);
|
||
if (applied) {
|
||
return;
|
||
}
|
||
const releaseId = Number(actionPayload?.release_id || 0);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
return;
|
||
}
|
||
if (!selectedReleaseId.value && preferredNodeOpsRelease.value.id) {
|
||
selectedReleaseId.value = Number(preferredNodeOpsRelease.value.id || 0) || null;
|
||
}
|
||
return;
|
||
}
|
||
};
|
||
|
||
const handleDriverActionResponse = async (
|
||
response: GenericRecord,
|
||
actionCode: string,
|
||
nodeCodes: string[] = [],
|
||
actionPayload: GenericRecord = {}
|
||
) => {
|
||
const normalizedActionCode = String(actionCode || "").trim();
|
||
const normalizedNodeCodes = Array.from(new Set((nodeCodes || []).map((item) => String(item || "").trim()).filter(Boolean)));
|
||
if (!response?.data?.handled) {
|
||
return false;
|
||
}
|
||
if (response?.data?.mode === "smart-release-rollout") {
|
||
await handleDriverSmartReleaseRolloutResult(normalizedActionCode, normalizedNodeCodes, actionPayload, response);
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "首屏动作已进入发布/放量链路",
|
||
detail: String(response?.message || "当前动作已交给 Release / Rollout 处理链继续执行。").trim(),
|
||
});
|
||
return true;
|
||
}
|
||
if (response?.data?.mode === "ui-intent") {
|
||
const applied = await applyDriverUiIntent(response?.data?.ui_intent || {});
|
||
if (!applied) {
|
||
await runLocalDriverActionCode(normalizedActionCode, normalizedNodeCodes, actionPayload);
|
||
}
|
||
setOpsCockpitActionFeedback({
|
||
type: applied ? "success" : "warning",
|
||
title: applied ? "首屏动作已定位到处理面板" : "首屏动作需要进入工作区继续处理",
|
||
detail: String(response?.message || (applied ? "当前已跳转到对应工作区。" : "当前已回退到本地处理入口。")).trim(),
|
||
});
|
||
return true;
|
||
}
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadJobs(),
|
||
refreshInspectionPanel(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
loadNodes(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
if (response.message) {
|
||
ElMessage.success(response.message);
|
||
}
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "首屏动作执行成功",
|
||
detail: String(response?.message || "当前动作已执行并刷新总览。").trim(),
|
||
});
|
||
return true;
|
||
};
|
||
|
||
const runDriverActionCode = async (actionCode: string, nodeCodes: string[] = [], actionPayload: GenericRecord = {}) => {
|
||
const normalizedActionCode = String(actionCode || "").trim();
|
||
const normalizedNodeCodes = Array.from(new Set((nodeCodes || []).map((item) => String(item || "").trim()).filter(Boolean)));
|
||
if (!normalizedActionCode) {
|
||
return;
|
||
}
|
||
try {
|
||
const response = await opsApi.executeDriverAction({
|
||
action_code: normalizedActionCode,
|
||
node_codes: normalizedNodeCodes,
|
||
action_payload: actionPayload,
|
||
requested_by: "web-ui/driver",
|
||
});
|
||
if (await handleDriverActionResponse(response, normalizedActionCode, normalizedNodeCodes, actionPayload)) {
|
||
return;
|
||
}
|
||
} catch (error: any) {
|
||
const errorMessage = String(error?.message || "");
|
||
if (!errorMessage) {
|
||
throw error;
|
||
}
|
||
}
|
||
await runLocalDriverActionCode(normalizedActionCode, normalizedNodeCodes, actionPayload);
|
||
};
|
||
|
||
const driverRecommendationCards = computed(() => {
|
||
if (backendDriverRecommendations.value.length) {
|
||
return backendDriverRecommendations.value.map((raw) => {
|
||
const sharedNodeCodes = normalizeDriverNodeCodes(raw.node_codes || []);
|
||
const primaryNodeCodes = normalizeDriverNodeCodes(raw.primary_node_codes || sharedNodeCodes);
|
||
const secondaryNodeCodes = normalizeDriverNodeCodes(raw.secondary_node_codes || sharedNodeCodes);
|
||
const sharedActionPayload = { ...(raw.action_payload || {}) };
|
||
const primaryActionPayload = { ...sharedActionPayload, ...(raw.primary_action_payload || {}) };
|
||
const secondaryActionPayload = { ...sharedActionPayload, ...(raw.secondary_action_payload || {}) };
|
||
return {
|
||
key: String(raw.key || "").trim() || "driver",
|
||
title: String(raw.title || "").trim() || "驾驶建议",
|
||
levelLabel: String(raw.level_label || "").trim() || "推荐",
|
||
tagType: String(raw.tag_type || "").trim() || "info",
|
||
summary: String(raw.summary || "").trim(),
|
||
reason: String(raw.reason || "").trim(),
|
||
nodeCodesText: String(raw.meta_text || "").trim() || sharedNodeCodes.join(" / "),
|
||
executorKind: "driver_action",
|
||
primaryLabel: String(raw.primary_label || "").trim() || "执行",
|
||
secondaryLabel: String(raw.secondary_label || "").trim(),
|
||
primaryType: String(raw.primary_type || "").trim() || "primary",
|
||
disabled: Boolean(raw.disabled),
|
||
previewable: hasDriverActionContractPreview({
|
||
executorKind: "driver_action",
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
}),
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
primaryAction: () => runDriverActionCode(String(raw.primary_action_code || ""), primaryNodeCodes, primaryActionPayload),
|
||
secondaryAction: () => runDriverActionCode(String(raw.secondary_action_code || ""), secondaryNodeCodes, secondaryActionPayload),
|
||
};
|
||
});
|
||
}
|
||
|
||
const cards: Array<Record<string, any>> = [];
|
||
const handoverGapNodes = driverHandoverGapNodes.value;
|
||
const firstGapNode = handoverGapNodes[0] || {};
|
||
const firstGapRecoveryRow = releaseLaunchpadFirstGapRecoveryRow.value || {};
|
||
const firstGapRecoveryAction = String(firstGapRecoveryRow.recovery_action || "").trim();
|
||
const firstGapRecoveryLabel =
|
||
firstGapRecoveryAction === "run_acceptance"
|
||
? "跑接管验收"
|
||
: firstGapRecoveryAction === "bootstrap_run"
|
||
? "跑接入收口"
|
||
: firstGapNode.is_managed
|
||
? "为首台补签 Token"
|
||
: "纳管首台节点";
|
||
const firstGapRecoverySummary =
|
||
String(firstGapRecoveryRow.recovery_summary || "").trim() ||
|
||
(firstGapRecoveryAction === "run_acceptance"
|
||
? "首台缺口节点已经完成接入,下一步应直接跑接管验收,确认它正式进入标准执行器就绪状态。"
|
||
: firstGapRecoveryAction === "bootstrap_run"
|
||
? "首台缺口节点已经收敛到接入收口动作,优先执行 bootstrap,让 register 与 heartbeat 正式进入稳定状态。"
|
||
: `当前有 ${handoverGapNodes.length} 台有效执行节点还没进入标准执行器就绪状态,继续扩机器前要先把接管链路补齐。`);
|
||
const firstGapRecoveryReason =
|
||
firstGapRecoveryAction === "run_acceptance"
|
||
? "接管动作已经收敛到单节点验收步骤,页面、CLI、Codex 都应该围绕同一个目标节点推进。"
|
||
: firstGapRecoveryAction === "bootstrap_run"
|
||
? "接管动作已经收敛到单节点接入收口,不需要再回到泛化的托管节点修复视角。"
|
||
: "节点虽然在线且属于有效执行面,SSH 已可承担日志、诊断和部分服务控制,但标准巡检编排、正式发布与 Rollout 仍以 Agent 在线为准。";
|
||
const participatingNodeCodes = driverParticipatingNodeCodes.value;
|
||
const standbyNodeCodes = driverStandbyNodeCodes.value;
|
||
const remoteLogSampleCount = Number(executionScene.value.log_sync?.line_count || 0);
|
||
const remoteLogSourceCount = Number(executionScene.value.log_sync?.source_node_count || 0);
|
||
|
||
if (handoverGapNodes.length) {
|
||
cards.push({
|
||
key: firstGapRecoveryAction ? `handover-gap-${firstGapRecoveryAction}` : "handover-gap",
|
||
title: firstGapRecoveryAction ? "先执行首台节点收口" : "先补接管缺口",
|
||
levelLabel: firstGapRecoveryAction === "run_acceptance" ? "推荐" : "最高优先",
|
||
tagType: firstGapRecoveryAction === "run_acceptance" ? "success" : firstGapRecoveryAction === "bootstrap_run" ? "warning" : "danger",
|
||
summary: firstGapRecoverySummary,
|
||
reason: firstGapRecoveryReason,
|
||
nodeCodesText: handoverGapNodes.map((item) => String(item.node_code || "").trim()).filter(Boolean).join(" / "),
|
||
primaryLabel: firstGapRecoveryLabel,
|
||
secondaryLabel: "查看目标节点",
|
||
primaryType: firstGapRecoveryAction === "run_acceptance" ? "success" : firstGapRecoveryAction === "bootstrap_run" ? "warning" : "danger",
|
||
disabled: !firstGapNode.node_code,
|
||
previewable: false,
|
||
primaryAction: () =>
|
||
firstGapRecoveryAction
|
||
? runDriverActionCode(firstGapRecoveryAction, [String(firstGapNode.node_code || "").trim()], {
|
||
node_code: String(firstGapNode.node_code || "").trim(),
|
||
})
|
||
: startNodeHandoverFlow(firstGapNode),
|
||
secondaryAction: () => openManagedNodeDialog(firstGapNode),
|
||
});
|
||
}
|
||
|
||
if (participatingNodeCodes.length && !opsWorkerLogSyncEnabled.value) {
|
||
cards.push({
|
||
key: "enable-scene-log-sync",
|
||
title: "先补现场日志回传",
|
||
levelLabel: "最高优先",
|
||
tagType: "danger",
|
||
summary: `当前有 ${participatingNodeCodes.length} 台节点正在真实参与检测,但海外控制面还看不到过程日志,排障会处于半盲态。`,
|
||
reason: "先把远端日志回传切到关键模式,才能在不放大噪音的前提下看到阶段、异常、代理与执行过程。",
|
||
nodeCodesText: participatingNodeCodes.join(" / "),
|
||
primaryLabel: "开启关键回传",
|
||
secondaryLabel: "开启全量回传",
|
||
primaryType: "danger",
|
||
disabled: false,
|
||
previewable: false,
|
||
primaryAction: () => updateOpsWorkerLogSync(true, "key"),
|
||
secondaryAction: () => updateOpsWorkerLogSync(true, "full"),
|
||
});
|
||
} else if (participatingNodeCodes.length && opsWorkerLogSyncEnabled.value && remoteLogSampleCount <= 0) {
|
||
cards.push({
|
||
key: "inspect-scene-log-gap",
|
||
title: "排查现场日志样本缺口",
|
||
levelLabel: "推荐",
|
||
tagType: "warning",
|
||
summary: `当前日志回传已开启,但 ${participatingNodeCodes.length} 台参与节点还没有回传样本,先确认 Worker 现场输出为什么没回来。`,
|
||
reason: "这通常意味着现场仍在执行,但日志镜像链路、周期或样本采集还没形成有效观测,适合先抓 Worker 日志再看巡检。",
|
||
nodeCodesText: `参与节点 ${participatingNodeCodes.length} 台 / 已回传来源 ${remoteLogSourceCount} 台`,
|
||
primaryLabel: "看 Worker 日志",
|
||
secondaryLabel: "执行标准巡检",
|
||
primaryType: "warning",
|
||
disabled: !participatingNodeCodes.length,
|
||
previewable: false,
|
||
primaryAction: () => openBulkActionTemplateDialog("participating", "logs.collect", buildWorkerLogCollectPayload(120)),
|
||
secondaryAction: () => runBulkInspectionSequence("participating"),
|
||
});
|
||
}
|
||
|
||
cards.push({
|
||
key: "inspect-participating",
|
||
title: "先看执行现场",
|
||
levelLabel: participatingNodeCodes.length ? "推荐" : "待命",
|
||
tagType: participatingNodeCodes.length ? "warning" : "info",
|
||
summary: participatingNodeCodes.length
|
||
? `当前有 ${participatingNodeCodes.length} 台节点正在真实参与检测,优先回收它们的标准巡检结果。`
|
||
: "当前没有节点处于真实执行现场,可先关注待命节点或接管缺口。",
|
||
reason: "真正参与检测的节点最接近现场,最适合先看健康快照、Worker 日志和诊断包。",
|
||
nodeCodesText: participatingNodeCodes.join(" / "),
|
||
primaryLabel: "执行标准巡检",
|
||
secondaryLabel: "打开诊断模板",
|
||
primaryType: "primary",
|
||
disabled: !participatingNodeCodes.length,
|
||
previewable: false,
|
||
primaryAction: () => runBulkInspectionSequence("participating"),
|
||
secondaryAction: () => openBulkActionTemplateDialog("participating", "diagnostics.collect", { lines: 200 }),
|
||
});
|
||
|
||
cards.push({
|
||
key: "inspect-standby",
|
||
title: "排查在线未参与节点",
|
||
levelLabel: standbyNodeCodes.length ? "推荐" : "待命",
|
||
tagType: standbyNodeCodes.length ? "warning" : "info",
|
||
summary: standbyNodeCodes.length
|
||
? `当前有 ${standbyNodeCodes.length} 台节点在线但未参与检测,建议做一轮标准巡检确认是正常待命还是接单异常。`
|
||
: "当前没有在线未参与节点,执行面结构比较干净。",
|
||
reason: "这组节点最适合定位“在线但不接单”“控制面兼跑未开始执行”“接管完成但还没真正纳入巡检”类问题。",
|
||
nodeCodesText: standbyNodeCodes.join(" / "),
|
||
primaryLabel: "执行标准巡检",
|
||
secondaryLabel: "看 Worker 日志",
|
||
primaryType: "primary",
|
||
disabled: !standbyNodeCodes.length,
|
||
previewable: false,
|
||
primaryAction: () => runBulkInspectionSequence("standby"),
|
||
secondaryAction: () => openBulkActionTemplateDialog("standby", "logs.collect", { lines: 120 }),
|
||
});
|
||
|
||
cards.push({
|
||
key: "release-readiness",
|
||
title: preferredNodeOpsRelease.value.id ? "进入发布准备" : "先建立 Release",
|
||
levelLabel: preferredNodeOpsRelease.value.id ? "准备就绪" : "待补齐",
|
||
tagType: preferredNodeOpsRelease.value.id ? "success" : "info",
|
||
summary: preferredNodeOpsRelease.value.id
|
||
? `当前默认发布版本为 ${preferredNodeOpsReleaseLabel.value},已经可以进入定向 Rollout 或灰度批次。`
|
||
: "当前还没有可用于 Rollout 的 Release,正式运维闭环还差最后一个版本入口。",
|
||
reason: preferredNodeOpsRelease.value.id
|
||
? "标准巡检和接管链路已经开始收口,后续应把发布也统一到 Release / Rollout 模型。"
|
||
: "没有 Release 时,节点更新仍然很容易回到临时 git pull 或手工覆盖。",
|
||
nodeCodesText: preferredNodeOpsRelease.value.id ? `默认版本:${preferredNodeOpsReleaseLabel.value}` : "",
|
||
primaryLabel: preferredNodeOpsRelease.value.id ? "创建 Rollout" : "创建 Release",
|
||
secondaryLabel: preferredNodeOpsRelease.value.id ? "点状灰度" : "",
|
||
primaryType: preferredNodeOpsRelease.value.id ? "success" : "primary",
|
||
disabled: false,
|
||
previewable: false,
|
||
primaryAction: () => (preferredNodeOpsRelease.value.id ? openRolloutDialog(preferredNodeOpsRelease.value) : openReleaseDialog()),
|
||
secondaryAction: () => {
|
||
if (!preferredNodeOpsRelease.value.id) {
|
||
return;
|
||
}
|
||
openReleaseDeployTemplateDialog(preferredNodeOpsRelease.value, "deploy.release.custom");
|
||
},
|
||
});
|
||
|
||
return cards;
|
||
});
|
||
|
||
const driverFeedCards = computed(() =>
|
||
(driverFeed.value.entries || []).map((raw: GenericRecord) => {
|
||
const sharedNodeCodes = normalizeDriverNodeCodes(raw.node_codes || []);
|
||
const primaryNodeCodes = normalizeDriverNodeCodes(raw.primary_node_codes || sharedNodeCodes);
|
||
const secondaryNodeCodes = normalizeDriverNodeCodes(raw.secondary_node_codes || sharedNodeCodes);
|
||
const primaryActionPayload = { ...(raw.primary_action_payload || {}) };
|
||
const secondaryActionPayload = { ...(raw.secondary_action_payload || {}) };
|
||
const status = String(raw.status || "").trim();
|
||
return {
|
||
key: String(raw.key || "").trim() || "driver-feed-entry",
|
||
kind: String(raw.kind || "").trim(),
|
||
executorKind: String(raw.executor_kind || "driver_action").trim() || "driver_action",
|
||
sequenceKey: String(raw.sequence_key || "").trim(),
|
||
title: String(raw.title || "").trim() || "驾驶主线",
|
||
summary: String(raw.summary || "").trim() || "当前还没有摘要。",
|
||
reason: String(raw.reason || "").trim() || "当前还没有补充说明。",
|
||
levelLabel: String(raw.level_label || "").trim() || "推荐",
|
||
laneLabel: String(raw.lane_label || raw.lane || "").trim() || "主线",
|
||
tagType: String(raw.tag_type || "").trim() || "info",
|
||
statusText: runbookSequenceStatusLabel(status),
|
||
nodeCodesText: String(raw.meta_text || "").trim() || (sharedNodeCodes.length ? sharedNodeCodes.join(" / ") : ""),
|
||
detailLines: (raw.detail_lines || []).map((item: unknown) => String(item || "").trim()).filter(Boolean),
|
||
primaryLabel: String(raw.primary_label || "").trim() || "执行",
|
||
secondaryLabel: String(raw.secondary_label || "").trim(),
|
||
primaryType: String(raw.tag_type || "").trim() === "danger" ? "danger" : "primary",
|
||
primaryPlain: String(raw.tag_type || "").trim() !== "danger",
|
||
disabled:
|
||
!(String(raw.primary_action_code || "").trim() || (String(raw.executor_kind || "").trim() === "runbook_sequence" && String(raw.sequence_key || "").trim())),
|
||
disabledSecondary:
|
||
!(String(raw.secondary_action_code || "").trim() || (String(raw.executor_kind || "").trim() === "runbook_sequence" && String(raw.sequence_key || "").trim())),
|
||
previewable: hasDriverActionContractPreview({
|
||
executorKind: String(raw.executor_kind || "driver_action").trim() || "driver_action",
|
||
sequenceKey: String(raw.sequence_key || "").trim(),
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
}),
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
contractKeys: contractNavigationKeys(raw),
|
||
contractNavigation: { ...(raw.contract_navigation || {}) },
|
||
};
|
||
})
|
||
);
|
||
|
||
const codexBriefCards = computed(() =>
|
||
(codexBrief.value.entries || []).map((raw: GenericRecord) => {
|
||
const autopilot = (raw.autopilot || {}) as GenericRecord;
|
||
const primaryExecution = (raw.primary_execution || {}) as GenericRecord;
|
||
const secondaryExecution = (raw.secondary_execution || {}) as GenericRecord;
|
||
const sharedNodeCodes = normalizeDriverNodeCodes(raw.node_codes || []);
|
||
const primaryNodeCodes = normalizeDriverNodeCodes(raw.primary_node_codes || primaryExecution.node_codes || sharedNodeCodes);
|
||
const secondaryNodeCodes = normalizeDriverNodeCodes(raw.secondary_node_codes || secondaryExecution.node_codes || sharedNodeCodes);
|
||
const primaryActionPayload = { ...(raw.primary_action_payload || primaryExecution.action_payload || {}) };
|
||
const secondaryActionPayload = { ...(raw.secondary_action_payload || secondaryExecution.action_payload || {}) };
|
||
const recommendation = String(autopilot.recommendation || "").trim();
|
||
const automationLevel = String(autopilot.automation_level || "").trim();
|
||
const riskLevel = String(autopilot.risk_level || primaryExecution.risk_level || "unknown").trim() || "unknown";
|
||
const executorMode = String(autopilot.executor_mode_hint || "").trim();
|
||
const targetApi = String(autopilot.target_api || "").trim();
|
||
const detailLines = [
|
||
...((raw.detail_lines || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)),
|
||
String(autopilot.reason || "").trim(),
|
||
targetApi ? `执行入口:${String(autopilot.target_method || "POST").trim() || "POST"} ${targetApi}` : "",
|
||
(primaryExecution.missing_fields || []).length
|
||
? `缺少上下文:${(primaryExecution.missing_fields || []).map((item: unknown) => String(item || "").trim()).filter(Boolean).join("、")}`
|
||
: "",
|
||
].filter(Boolean);
|
||
const primaryBlocked = Boolean(primaryExecution.blocked);
|
||
const secondaryBlocked = Boolean(secondaryExecution.blocked);
|
||
|
||
return {
|
||
key: String(raw.key || "").trim() || "codex-brief-entry",
|
||
kind: String(raw.kind || "").trim(),
|
||
executorKind: String(raw.executor_kind || "driver_action").trim() || "driver_action",
|
||
sequenceKey: String(raw.sequence_key || "").trim(),
|
||
title: String(raw.title || "").trim() || "自动驾驶判断",
|
||
summary: String(raw.summary || "").trim() || "当前还没有摘要。",
|
||
reason: String(raw.reason || "").trim() || String(autopilot.reason || "").trim() || "当前还没有补充说明。",
|
||
detailLines,
|
||
recommendationLabel: codexRecommendationLabel(recommendation, String(autopilot.status_text || "").trim()),
|
||
recommendationTagType: codexRecommendationTagType(recommendation),
|
||
automationLevelLabel: codexAutomationLevelLabel(automationLevel),
|
||
automationTagType: codexAutomationTagType(automationLevel),
|
||
riskLevelLabel: riskLevel,
|
||
riskTagType: riskType(riskLevel),
|
||
executorModeText: executorMode ? `执行模式:${codexExecutorModeLabel(executorMode)}` : "",
|
||
nodeCodesText: sharedNodeCodes.length ? `目标节点:${sharedNodeCodes.join(" / ")}` : "",
|
||
primaryLabel: String(raw.primary_label || "").trim() || "执行",
|
||
secondaryLabel: String(raw.secondary_label || "").trim(),
|
||
primaryType:
|
||
recommendation === "auto_execute"
|
||
? "success"
|
||
: recommendation === "confirm_then_execute" || recommendation === "resolve_first"
|
||
? "warning"
|
||
: "primary",
|
||
primaryPlain: recommendation === "open_ui" || recommendation === "blocked",
|
||
disabled:
|
||
primaryBlocked ||
|
||
!(String(raw.primary_action_code || "").trim() || (String(raw.executor_kind || "").trim() === "runbook_sequence" && String(raw.sequence_key || "").trim())),
|
||
disabledSecondary:
|
||
secondaryBlocked ||
|
||
!(String(raw.secondary_action_code || "").trim() || (String(raw.executor_kind || "").trim() === "runbook_sequence" && String(raw.sequence_key || "").trim())),
|
||
previewable: hasDriverActionContractPreview({
|
||
executorKind: String(raw.executor_kind || "driver_action").trim() || "driver_action",
|
||
sequenceKey: String(raw.sequence_key || "").trim(),
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
}),
|
||
primaryActionCode: String(raw.primary_action_code || "").trim(),
|
||
secondaryActionCode: String(raw.secondary_action_code || "").trim(),
|
||
primaryNodeCodes,
|
||
secondaryNodeCodes,
|
||
primaryActionPayload,
|
||
secondaryActionPayload,
|
||
targetApi,
|
||
targetMethod: String(autopilot.target_method || "").trim() || "POST",
|
||
primaryExecution: {
|
||
...primaryExecution,
|
||
},
|
||
secondaryExecution: {
|
||
...secondaryExecution,
|
||
},
|
||
};
|
||
})
|
||
);
|
||
|
||
const runOpsRunbookControlSequence = async (card: GenericRecord, options: { secondary?: boolean } = {}) => {
|
||
const secondary = Boolean(options.secondary);
|
||
const actionKey = `runbook-sequence-${String(card?.key || "").trim() || "sequence"}-${secondary ? "secondary" : "primary"}`;
|
||
loading.value.driverActionKey = actionKey;
|
||
try {
|
||
const sequenceKey = String(card?.key || "").trim();
|
||
if (!sequenceKey) {
|
||
return;
|
||
}
|
||
const response = await opsApi.executeRunbookSequence(sequenceKey, {
|
||
secondary,
|
||
requested_by: "web-ui/runbook-sequence",
|
||
});
|
||
const actionCode = String(response?.data?.driver_action_code || "").trim();
|
||
const nodeCodes = normalizeDriverNodeCodes(response?.data?.driver_node_codes || []);
|
||
const actionPayload = { ...(response?.data?.driver_action_payload || {}) };
|
||
if (await handleDriverActionResponse(response, actionCode, nodeCodes, actionPayload)) {
|
||
return;
|
||
}
|
||
if (actionCode) {
|
||
await runLocalDriverActionCode(actionCode, nodeCodes, actionPayload);
|
||
}
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const bulkDiagnosticsCards = computed(() => {
|
||
const participating = bulkDiagnosticsParticipatingNodes.value;
|
||
const standby = bulkDiagnosticsStandbyNodes.value;
|
||
const eligible = bulkDiagnosticsEligibleNodes.value;
|
||
const excludedCount = bulkDiagnosticsExcludedNodes.value.length;
|
||
|
||
return [
|
||
{
|
||
key: "participating",
|
||
title: "真正参与检测节点",
|
||
description: "优先排查正在领任务、正在执行或最近有吞吐的节点,适合联调与故障快照。",
|
||
summary: "这组节点最接近真实执行现场,回收诊断包价值最高。",
|
||
nodes: participating,
|
||
excludedCount,
|
||
},
|
||
{
|
||
key: "standby",
|
||
title: "在线未参与节点",
|
||
description: "节点在线但当前没有参与检测,适合快速确认为什么没有领任务或只是待命。",
|
||
summary: "这组节点常用于定位“在线但不接单”“控制面兼跑未启动”“节点接管不完整”等问题。",
|
||
nodes: standby,
|
||
excludedCount,
|
||
},
|
||
{
|
||
key: "effective-workers",
|
||
title: "全部有效执行节点",
|
||
description: "把独立 Worker 和控制面兼跑节点一起纳入,适合一轮完整巡检或发布前验收。",
|
||
summary: "用于做全量巡检,统一确认所有真正能跑检测的节点当前状态。",
|
||
nodes: eligible,
|
||
excludedCount,
|
||
},
|
||
].map((item) => {
|
||
const nodeCodes = item.nodes.map((node: GenericRecord) => String(node.node_code || "").trim()).filter(Boolean);
|
||
return {
|
||
...item,
|
||
count: nodeCodes.length,
|
||
nodeCodes,
|
||
nodeCodesText: nodeCodes.length ? nodeCodes.join(" / ") : "当前没有符合条件的节点",
|
||
};
|
||
});
|
||
});
|
||
|
||
const selectedRelease = computed(
|
||
() => releases.value.find((item) => Number(item.id) === Number(selectedReleaseId.value || 0)) || {}
|
||
);
|
||
|
||
const rolloutTargetRelease = computed(
|
||
() => releases.value.find((item) => Number(item.id) === Number(rolloutTargetReleaseId.value || 0)) || selectedRelease.value || {}
|
||
);
|
||
|
||
const primaryRollout = computed(() => rollouts.value[0] || {});
|
||
const rolloutPreviewHasData = computed(
|
||
() =>
|
||
Boolean(rolloutPreview.value.target_summary) ||
|
||
Boolean(rolloutPreview.value.operational_readiness) ||
|
||
Boolean((rolloutPreview.value.blocking_reasons || []).length) ||
|
||
Boolean((rolloutPreview.value.warnings || []).length)
|
||
);
|
||
const rolloutPreviewTargetSummary = computed(() => rolloutPreview.value.target_summary || {});
|
||
const rolloutPreviewBatchPlan = computed(() => rolloutPreview.value.batch_plan || {});
|
||
const rolloutPreviewGuardrails = computed(() => rolloutPreview.value.cluster_guardrails || {});
|
||
const rolloutPreviewRelease = computed(() => rolloutPreview.value.release || {});
|
||
const rolloutPreviewReleaseGate = computed<GenericRecord>(() => rolloutPreview.value.release_gate || {});
|
||
const rolloutPreviewReleaseGateStatusType = computed(() => String(rolloutPreviewReleaseGate.value.status_type || "").trim() || "info");
|
||
const rolloutPreviewOperationalReadiness = computed(() => rolloutPreview.value.operational_readiness || {});
|
||
const rolloutPreviewReadinessSummary = computed(() => rolloutPreviewOperationalReadiness.value.summary || {});
|
||
const rolloutPreviewExecutionModeLabel = computed(
|
||
() =>
|
||
String(rolloutPreviewReleaseGate.value.execution_mode_label || rolloutForm.value.execution_mode || "").trim() ||
|
||
"执行方式未定"
|
||
);
|
||
const rolloutPreviewReadinessRows = computed<GenericRecord[]>(() => rolloutPreviewOperationalReadiness.value.rows || []);
|
||
const rolloutPreviewWarnings = computed(() => rolloutPreview.value.warnings || []);
|
||
const rolloutPreviewBlockingReasons = computed(() => rolloutPreview.value.blocking_reasons || []);
|
||
const rolloutPreviewApprovalReasons = computed(() => rolloutPreview.value.approval_reasons || []);
|
||
const rolloutPreviewRecommendations = computed(() => rolloutPreview.value.recommendations || []);
|
||
const activeReleaseLabel = computed(() => {
|
||
const activeByChannel = overview.value.release_hub?.active_by_channel || {};
|
||
return activeByChannel.stable || activeByChannel.beta || activeByChannel.canary || "-";
|
||
});
|
||
|
||
const preferredNodeOpsRelease = computed<GenericRecord>(() => {
|
||
if (selectedRelease.value.id) {
|
||
return selectedRelease.value;
|
||
}
|
||
if (latestRelease.value.id) {
|
||
return latestRelease.value;
|
||
}
|
||
return releases.value[0] || {};
|
||
});
|
||
|
||
const selectedRolloutTargetRelease = computed<GenericRecord>(
|
||
() =>
|
||
releases.value.find((item) => Number(item.id) === Number(rolloutTargetReleaseId.value || 0)) ||
|
||
selectedRelease.value ||
|
||
{}
|
||
);
|
||
|
||
const preferredNodeOpsReleaseLabel = computed(() => {
|
||
const release = preferredNodeOpsRelease.value || {};
|
||
if (!release.id) {
|
||
return "未配置 Release";
|
||
}
|
||
const version = String(release.release_version || release.version || "-").trim() || "-";
|
||
const channel = String(release.channel || "stable").trim() || "stable";
|
||
return `${version} / ${channel}`;
|
||
});
|
||
|
||
const controlPlaneBaseUrl = computed(() => {
|
||
const raw = String(import.meta.env.VITE_API_BASE_URL || "").trim();
|
||
if (raw) {
|
||
return raw.replace(/\/api\/v1\/?$/, "");
|
||
}
|
||
if (typeof window !== "undefined") {
|
||
return window.location.origin;
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const buildAbsoluteControlPlaneUrl = (rawPath: string) => {
|
||
const normalizedPath = String(rawPath || "").trim();
|
||
if (!normalizedPath) {
|
||
return "";
|
||
}
|
||
if (/^https?:\/\//i.test(normalizedPath)) {
|
||
return normalizedPath;
|
||
}
|
||
const baseUrl = String(controlPlaneBaseUrl.value || "").trim().replace(/\/+$/, "");
|
||
if (!baseUrl) {
|
||
return normalizedPath;
|
||
}
|
||
if (normalizedPath.startsWith("/")) {
|
||
return `${baseUrl}${normalizedPath}`;
|
||
}
|
||
return `${baseUrl}/${normalizedPath}`;
|
||
};
|
||
|
||
const latestReleasePackageAvailable = computed(() => Boolean(latestReleasePackage.value?.available));
|
||
const latestReleasePackageReleaseActionAllowed = computed(
|
||
() => Boolean(latestReleasePackage.value?.available) && Boolean(latestReleasePackage.value?.final_release_ok)
|
||
);
|
||
const latestReleasePackageReleaseActionBlockMessage = computed(() => {
|
||
if (!latestReleasePackage.value?.available || latestReleasePackage.value?.final_release_ok) {
|
||
return "";
|
||
}
|
||
const staleReason = String(latestReleasePackage.value?.final_release_report_stale_reason || "").trim();
|
||
const gateDecision = String(latestReleasePackage.value?.final_release_gate_decision || "").trim();
|
||
const gateBlockedReasons = ((latestReleasePackage.value?.final_release_gate_blocked_reasons || []) as unknown[])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean);
|
||
if (staleReason) {
|
||
return `最终签收未完成:${staleReason}`;
|
||
}
|
||
if (!latestReleasePackage.value?.final_release_report_available) {
|
||
return "最终签收未完成:缺少 final_release_report.json";
|
||
}
|
||
if (gateBlockedReasons.length) {
|
||
return `最终签收未完成:${gateBlockedReasons.join(" / ")}`;
|
||
}
|
||
if (gateDecision) {
|
||
return `最终签收未完成:release_gate=${gateDecision}`;
|
||
}
|
||
if (!latestReleasePackage.value?.smoke_test_ok) {
|
||
return "最终签收未完成:smoke test 未通过";
|
||
}
|
||
return "最终签收未完成:当前最新发布包还不能进入正式发布动作";
|
||
});
|
||
const latestReleasePackageDownloadUrl = computed(() =>
|
||
buildAbsoluteControlPlaneUrl(String(latestReleasePackage.value?.download_path || ""))
|
||
);
|
||
const latestReleasePackageSha256Url = computed(() =>
|
||
buildAbsoluteControlPlaneUrl(String(latestReleasePackage.value?.sha256_download_path || ""))
|
||
);
|
||
const releaseLaunchpadFirstGapNode = computed<GenericRecord>(() => driverHandoverGapNodes.value[0] || {});
|
||
const releaseLaunchpadFirstGapRecoveryRow = computed<GenericRecord>(() => {
|
||
const firstGapNodeCode = String(releaseLaunchpadFirstGapNode.value?.node_code || "").trim();
|
||
if (!firstGapNodeCode) {
|
||
return {};
|
||
}
|
||
return (
|
||
releaseGateReadinessRows.value.find((row) => String(row?.node_code || "").trim() === firstGapNodeCode) || {}
|
||
);
|
||
});
|
||
const latestReleasePackageManifest = computed<GenericRecord>(() => {
|
||
const manifest = latestReleasePackage.value?.metadata?.manifest || {};
|
||
return typeof manifest === "object" && manifest ? manifest : {};
|
||
});
|
||
|
||
const releaseLaunchpadRolloutLabel = (preview: GenericRecord) => {
|
||
const smartRollout = (preview?.smart_rollout || {}) as GenericRecord;
|
||
const modeLabel = String(smartRollout.mode_label || "").trim();
|
||
return modeLabel || "智能 Rollout";
|
||
};
|
||
|
||
const releaseLaunchpadRolloutStatusType = (preview: GenericRecord) => {
|
||
if (preview?.blocked) return "danger";
|
||
if (preview?.requires_confirmation) return "warning";
|
||
if (preview?.smart_rollout?.available) return "success";
|
||
return "info";
|
||
};
|
||
|
||
const releaseLaunchpadRolloutStatusLabel = (preview: GenericRecord) => {
|
||
if (preview?.blocked) return "阻断";
|
||
if (preview?.requires_confirmation) return "待确认";
|
||
if (preview?.smart_rollout?.available) return "已可发";
|
||
return "待补齐";
|
||
};
|
||
|
||
const releaseLaunchpadRolloutSummary = (preview: GenericRecord) => {
|
||
const smartRollout = (preview?.smart_rollout || {}) as GenericRecord;
|
||
return (
|
||
String(smartRollout.summary || "").trim() ||
|
||
String(smartRollout.reason || "").trim() ||
|
||
String(preview?.message || "").trim() ||
|
||
"当前还没有智能 Rollout 摘要。"
|
||
);
|
||
};
|
||
|
||
const agentTargetNodeCode = computed(() =>
|
||
String(lastIssuedToken.value.node_code || agentTokenForm.value.node_code || "").trim()
|
||
);
|
||
|
||
const agentTargetNode = computed<GenericRecord>(
|
||
() => nodes.value.find((item) => String(item.node_code || "").trim() === agentTargetNodeCode.value) || {}
|
||
);
|
||
|
||
const agentHandoverStage = computed<GenericRecord>(() => agentHandoverPayload.value.stage || {});
|
||
|
||
const agentHandoverSummary = computed(() =>
|
||
String(agentHandoverPayload.value.summary || agentHandoverStage.value.summary || "").trim()
|
||
);
|
||
|
||
const agentHandoverBlockingItems = computed<string[]>(() =>
|
||
Array.isArray(agentHandoverPayload.value.blocking_items)
|
||
? agentHandoverPayload.value.blocking_items.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: []
|
||
);
|
||
|
||
const agentHandoverAttentionItems = computed<string[]>(() =>
|
||
Array.isArray(agentHandoverPayload.value.attention_items)
|
||
? agentHandoverPayload.value.attention_items.map((item) => String(item || "").trim()).filter(Boolean)
|
||
: []
|
||
);
|
||
|
||
const agentObservedServiceNames = computed(() => {
|
||
const metadata = agentTargetNode.value?.metadata || {};
|
||
const serviceNames = metadata.service_names || {};
|
||
return {
|
||
nodeAgent: String(serviceNames.node_agent || "domaincheck-node-agent"),
|
||
worker: String(serviceNames.worker || "domaincheck-worker"),
|
||
};
|
||
});
|
||
|
||
const buildWorkerLogCollectPayload = (lines = 120): GenericRecord => ({
|
||
service_name: agentObservedServiceNames.value.worker,
|
||
lines,
|
||
});
|
||
|
||
const agentOnboardingHint = computed(() => {
|
||
const row = agentTargetNode.value || {};
|
||
const state = String(row.agent_state || "").trim();
|
||
if (!agentTargetNodeCode.value) {
|
||
return "先选择目标节点并生成接入方案。";
|
||
}
|
||
if (state === "online" || state === "online_busy") {
|
||
return "节点已经完成 Node Agent 接管,可以继续派发运维任务或发布任务。";
|
||
}
|
||
if (state === "pending_bootstrap") {
|
||
return "已签发有效 Token,下一步是在目标节点执行接入片段并启动 domaincheck-node-agent。";
|
||
}
|
||
if (state === "stale") {
|
||
return "节点曾经接入过,但心跳已经过期。优先检查 domaincheck-node-agent 服务状态与网络连通性。";
|
||
}
|
||
if (state === "token_expired" || state === "token_disabled") {
|
||
return "当前 Token 不可继续使用,建议重新生成接入方案并再次下发。";
|
||
}
|
||
if (state === "runtime_only") {
|
||
return "控制面已经能看到这台机器的运行态,但 Node Agent 还没有正式接管;请执行接入片段。";
|
||
}
|
||
return "接入状态暂未建立,建议先完成纳管并在目标节点执行接入命令。";
|
||
});
|
||
|
||
const agentOnboardingRecoveryDecision = computed<GenericRecord>(
|
||
() => onboardingState.value.recovery_decision || {}
|
||
);
|
||
|
||
const agentOnboardingRecoveryDecisionTagType = computed(() => {
|
||
const action = String(agentOnboardingRecoveryDecision.value.action || "").trim();
|
||
if (action === "run_acceptance") return "success";
|
||
if (action === "bootstrap_run") return "warning";
|
||
return "info";
|
||
});
|
||
|
||
const rolloutReadinessRecoveryTagType = (row: GenericRecord) => {
|
||
const action = String(row?.recovery_action || "").trim();
|
||
if (action === "run_acceptance") return "success";
|
||
if (action === "bootstrap_run") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const rolloutReadinessRecoveryLabel = (row: GenericRecord) => {
|
||
return (
|
||
String(row?.recovery_label || "").trim() ||
|
||
String(row?.onboarding_stage_label || "").trim() ||
|
||
"暂无恢复动作"
|
||
);
|
||
};
|
||
|
||
const rolloutReadinessRecoverySummary = (row: GenericRecord) => {
|
||
return (
|
||
String(row?.recovery_summary || "").trim() ||
|
||
String(row?.onboarding_summary || "").trim() ||
|
||
String(row?.agent_reason || "").trim() ||
|
||
"当前没有额外恢复摘要。"
|
||
);
|
||
};
|
||
|
||
const agentTokenStatusLabel = computed(() => {
|
||
const tokenStatus = String(agentTargetNode.value?.latest_token?.token_status || "").trim();
|
||
if (tokenStatus === "active") return "有效";
|
||
if (tokenStatus === "expired") return "已过期";
|
||
if (tokenStatus === "disabled") return "已禁用";
|
||
return "未签发";
|
||
});
|
||
|
||
const agentHandoverStageTagType = (stageCode: string) => {
|
||
const normalized = String(stageCode || "").trim();
|
||
if (normalized === "ready") return "success";
|
||
if (normalized === "disabled") return "danger";
|
||
if (["stale", "token_issue", "runtime_only", "pending_bootstrap"].includes(normalized)) return "warning";
|
||
if (["unmanaged", "profile_incomplete"].includes(normalized)) return "info";
|
||
return "info";
|
||
};
|
||
|
||
const agentOnboardingReadyForOps = computed(() => {
|
||
const state = String(agentTargetNode.value?.agent_state || "").trim();
|
||
return state === "online" || state === "online_busy";
|
||
});
|
||
|
||
const agentOnboardingAcceptanceDefinitions = computed(() => [
|
||
{
|
||
key: "health.snapshot",
|
||
title: "1. 健康快照",
|
||
description: "先确认节点已经能被控制面稳定调度。",
|
||
payload: {},
|
||
loadingKey: "health.snapshot",
|
||
},
|
||
{
|
||
key: "service.status",
|
||
title: "2. Node Agent 状态",
|
||
description: "确认 Agent 服务在线且没有启动异常。",
|
||
payload: { service_name: agentObservedServiceNames.value.nodeAgent },
|
||
loadingKey: `service.status:${agentObservedServiceNames.value.nodeAgent}`,
|
||
},
|
||
{
|
||
key: "logs.collect",
|
||
title: "3. Node Agent 日志",
|
||
description: "抓最近日志,确认 register / heartbeat / pull 是否正常。",
|
||
payload: { service_name: agentObservedServiceNames.value.nodeAgent, lines: 120 },
|
||
loadingKey: `logs.collect:${agentObservedServiceNames.value.nodeAgent}`,
|
||
},
|
||
{
|
||
key: "service.status",
|
||
title: "4. Worker 状态",
|
||
description: "确认检测进程状态和服务托管是否正常。",
|
||
payload: { service_name: agentObservedServiceNames.value.worker },
|
||
loadingKey: `service.status:${agentObservedServiceNames.value.worker}`,
|
||
},
|
||
{
|
||
key: "logs.collect",
|
||
title: "5. Worker 日志",
|
||
description: "确认 Worker 当前线程、代理和调度是否正常。",
|
||
payload: { service_name: agentObservedServiceNames.value.worker, lines: 120 },
|
||
loadingKey: `logs.collect:${agentObservedServiceNames.value.worker}`,
|
||
},
|
||
]);
|
||
|
||
const agentOnboardingRecentJobs = computed<GenericRecord[]>(() =>
|
||
jobs.value
|
||
.filter((item) => {
|
||
if (String(item.target_node_code || "").trim() !== agentTargetNodeCode.value) {
|
||
return false;
|
||
}
|
||
return ["health.snapshot", "service.status", "logs.collect"].includes(String(item.action || "").trim());
|
||
})
|
||
.slice(0, 8)
|
||
);
|
||
|
||
const agentOnboardingStepSnapshots = computed(() =>
|
||
agentOnboardingAcceptanceDefinitions.value.map((step) => ({
|
||
step,
|
||
job: agentOnboardingRecentJobs.value.find((job) => agentOnboardingJobMatchesStep(job, step)) || {},
|
||
}))
|
||
);
|
||
|
||
const agentOnboardingLatestFailedJob = computed<GenericRecord>(
|
||
() =>
|
||
agentOnboardingRecentJobs.value.find((job) =>
|
||
["failed", "blocked", "cancelled"].includes(String(job.status || "").trim())
|
||
) || {}
|
||
);
|
||
|
||
const agentOnboardingAcceptanceSummary = computed(() => {
|
||
const snapshots = agentOnboardingStepSnapshots.value;
|
||
const total = snapshots.length;
|
||
let successCount = 0;
|
||
let failedCount = 0;
|
||
let runningCount = 0;
|
||
let pendingCount = 0;
|
||
|
||
for (const item of snapshots) {
|
||
const status = String(item.job?.status || "").trim();
|
||
if (!status) {
|
||
pendingCount += 1;
|
||
continue;
|
||
}
|
||
if (status === "success") {
|
||
successCount += 1;
|
||
continue;
|
||
}
|
||
if (["failed", "blocked", "cancelled"].includes(status)) {
|
||
failedCount += 1;
|
||
continue;
|
||
}
|
||
if (["queued", "dispatching", "running", "awaiting_approval"].includes(status)) {
|
||
runningCount += 1;
|
||
continue;
|
||
}
|
||
pendingCount += 1;
|
||
}
|
||
|
||
const firstPendingStep = snapshots.find((item) => String(item.job?.status || "").trim() !== "success") || null;
|
||
if (!agentTargetNodeCode.value) {
|
||
return {
|
||
status: "idle",
|
||
label: "未开始",
|
||
type: "info",
|
||
summary: "先选择节点并生成接入方案,再开始接管验收。",
|
||
nextStep: null,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
if (!agentOnboardingReadyForOps.value) {
|
||
return {
|
||
status: "waiting_handover",
|
||
label: "等待接管",
|
||
type: "warning",
|
||
summary: "节点尚未进入已接管状态,先执行接入片段并等待 register / heartbeat 成功。",
|
||
nextStep: null,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
if (failedCount > 0) {
|
||
return {
|
||
status: "failed",
|
||
label: "验收异常",
|
||
type: "danger",
|
||
summary: "最近一轮验收至少有一步失败,建议先看失败任务事件,再决定是否重试对应步骤。",
|
||
nextStep: firstPendingStep,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
if (successCount >= total && total > 0) {
|
||
return {
|
||
status: "passed",
|
||
label: "接管通过",
|
||
type: "success",
|
||
summary: "标准接管验收步骤已全部通过,这台节点可以进入后续巡检、发布或批量调度。",
|
||
nextStep: null,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
if (runningCount > 0) {
|
||
return {
|
||
status: "running",
|
||
label: "验收进行中",
|
||
type: "warning",
|
||
summary: "验收任务已进入队列或正在执行,请等待任务回执后再看下一步。",
|
||
nextStep: firstPendingStep,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
if (successCount > 0) {
|
||
return {
|
||
status: "partial",
|
||
label: "部分完成",
|
||
type: "warning",
|
||
summary: "已有部分验收步骤完成,建议继续执行剩余步骤完成整体验收。",
|
||
nextStep: firstPendingStep,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
}
|
||
return {
|
||
status: "ready_to_verify",
|
||
label: "待验收",
|
||
type: "info",
|
||
summary: "节点已完成接管,但还没有跑标准验收;建议先执行一键跑验收。",
|
||
nextStep: firstPendingStep,
|
||
counts: { total, successCount, failedCount, runningCount, pendingCount },
|
||
};
|
||
});
|
||
|
||
const agentBootstrapSnippet = computed(() => {
|
||
if (agentBootstrapPlan.value.command_block) {
|
||
return String(agentBootstrapPlan.value.command_block || "");
|
||
}
|
||
if (!lastIssuedToken.value.token) {
|
||
return "";
|
||
}
|
||
const nodeCode = lastIssuedToken.value.node_code || agentTokenForm.value.node_code || "mainland-worker-01";
|
||
const nodeRegion = agentTokenForm.value.node_region || "mainland";
|
||
const nodeRole = agentTokenForm.value.node_role || "worker";
|
||
return [
|
||
"cat >/etc/default/domaincheck-node-agent <<'EOF'",
|
||
`OPS_CONTROL_PLANE_BASE_URL=${controlPlaneBaseUrl.value}`,
|
||
`OPS_AGENT_TOKEN=${lastIssuedToken.value.token}`,
|
||
`NODE_CODE=${nodeCode}`,
|
||
`NODE_REGION=${nodeRegion}`,
|
||
`NODE_ROLE=${nodeRole}`,
|
||
"OPS_AGENT_POLL_INTERVAL_SECONDS=5",
|
||
"EOF",
|
||
"systemctl daemon-reload",
|
||
"systemctl enable --now domaincheck-node-agent",
|
||
"systemctl status domaincheck-node-agent --no-pager -l"
|
||
].join("\n");
|
||
});
|
||
|
||
const agentEnvContent = computed(() => {
|
||
if (agentBootstrapPlan.value.env_content) {
|
||
return String(agentBootstrapPlan.value.env_content || "");
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const agentBootstrapScriptName = computed(() => String(agentBootstrapPlan.value.bootstrap_script_name || "").trim());
|
||
|
||
const agentBootstrapScriptContent = computed(() => {
|
||
if (agentBootstrapPlan.value.bootstrap_script_content) {
|
||
return String(agentBootstrapPlan.value.bootstrap_script_content || "");
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const agentBootstrapRunBlock = computed(() => {
|
||
if (agentBootstrapPlan.value.bootstrap_run_script_block) {
|
||
return String(agentBootstrapPlan.value.bootstrap_run_script_block || "");
|
||
}
|
||
return "";
|
||
});
|
||
|
||
const formatJsonBlock = (value: unknown) => {
|
||
try {
|
||
return JSON.stringify(value ?? {}, null, 2);
|
||
} catch {
|
||
return String(value ?? "");
|
||
}
|
||
};
|
||
|
||
const activeJobPayloadText = computed(() => formatJsonBlock(activeJob.value?.payload || {}));
|
||
const activeJobResultText = computed(() => formatJsonBlock(activeJob.value?.result || {}));
|
||
const activeJobMetadataText = computed(() => formatJsonBlock(activeJob.value?.metadata || {}));
|
||
const activeJobPolicyText = computed(() => formatJsonBlock(activeJob.value?.policy || {}));
|
||
const activeJobSteps = computed<GenericRecord[]>(() => activeJob.value?.steps || []);
|
||
const activeJobStatusText = computed(() => String(activeJob.value?.status_label || activeJob.value?.status || "").trim() || "-");
|
||
const activeJobApprovalStatusText = computed(() => {
|
||
const direct = String(activeJob.value?.approval_status_label || "").trim();
|
||
if (direct) {
|
||
const approvedBy = String(activeJob.value?.approved_by || "").trim();
|
||
if (direct === "已审批" && approvedBy) {
|
||
return `${direct} / ${approvedBy}`;
|
||
}
|
||
return direct;
|
||
}
|
||
const approvalStatus = String(activeJob.value?.approval_status || "").trim();
|
||
if (!approvalStatus || approvalStatus === "not_required") {
|
||
return activeJob.value?.approval_required ? "待审批" : "无需审批";
|
||
}
|
||
if (approvalStatus === "approved") {
|
||
const approvedBy = String(activeJob.value?.approved_by || "").trim();
|
||
return approvedBy ? `已审批 / ${approvedBy}` : "已审批";
|
||
}
|
||
if (approvalStatus === "blocked") {
|
||
return "已阻断";
|
||
}
|
||
return approvalStatus;
|
||
});
|
||
const activeJobStepProgressText = computed(() => {
|
||
const stepsLoaded = Boolean(activeJob.value?.steps_loaded);
|
||
const stepsTotal = Number(activeJob.value?.steps_total || 0);
|
||
const stepsSuccess = Number(activeJob.value?.steps_success || 0);
|
||
const stepsRunning = Number(activeJob.value?.steps_running || 0);
|
||
const stepsFailed = Number(activeJob.value?.steps_failed || 0);
|
||
if (!stepsLoaded && !stepsTotal) {
|
||
return "未加载";
|
||
}
|
||
return `${stepsSuccess}/${stepsTotal} 成功 / 执行中 ${stepsRunning} / 失败 ${stepsFailed}`;
|
||
});
|
||
const activeJobIsBootstrap = computed(() => String(activeJob.value?.action || "").trim() === "node.bootstrap");
|
||
const activeJobBootstrapResult = computed<GenericRecord>(() => {
|
||
const result = activeJob.value?.result || {};
|
||
return typeof result === "object" && result ? result : {};
|
||
});
|
||
const activeJobBootstrapPlan = computed<GenericRecord>(() => {
|
||
const plan = activeJobBootstrapResult.value.bootstrap_plan || {};
|
||
return typeof plan === "object" && plan ? plan : {};
|
||
});
|
||
|
||
const jobStepOutputSummary = (row: GenericRecord) => {
|
||
const stderrText = String(row?.stderr || "").trim();
|
||
if (stderrText) {
|
||
return stderrText.length > 160 ? `${stderrText.slice(0, 160)}...` : stderrText;
|
||
}
|
||
const stdoutText = String(row?.stdout || "").trim();
|
||
if (stdoutText) {
|
||
return stdoutText.length > 160 ? `${stdoutText.slice(0, 160)}...` : stdoutText;
|
||
}
|
||
const compactResult = formatJsonBlock(row?.result || {});
|
||
return compactResult.length > 160 ? `${compactResult.slice(0, 160)}...` : compactResult;
|
||
};
|
||
|
||
const splitTextList = (raw: string) =>
|
||
raw
|
||
.split(/[\n,]+/g)
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
|
||
const parseActionTemplateBoolean = (rawValue: unknown) => {
|
||
if (typeof rawValue === "boolean") return rawValue;
|
||
if (typeof rawValue === "number") return rawValue !== 0;
|
||
const normalized = String(rawValue ?? "").trim().toLowerCase();
|
||
if (["1", "true", "yes", "y", "on", "enable", "enabled"].includes(normalized)) return true;
|
||
if (["0", "false", "no", "n", "off", "disable", "disabled"].includes(normalized)) return false;
|
||
return Boolean(normalized);
|
||
};
|
||
|
||
const normalizeActionTemplateFieldValue = (field: GenericRecord, rawValue: unknown) => {
|
||
const fieldType = String(field?.type || "text").trim();
|
||
if (fieldType === "number") {
|
||
const fallback = Number(field?.default ?? field?.min ?? 0);
|
||
const numericValue = Number(rawValue ?? fallback);
|
||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||
}
|
||
if (fieldType === "boolean") {
|
||
return parseActionTemplateBoolean(rawValue ?? field?.default ?? false);
|
||
}
|
||
if (fieldType === "text_list") {
|
||
if (Array.isArray(rawValue)) {
|
||
return rawValue
|
||
.map((item) => String(item ?? "").trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
return String(rawValue ?? "").replace(/\r\n/g, "\n");
|
||
}
|
||
return rawValue == null ? "" : String(rawValue);
|
||
};
|
||
|
||
const buildActionTemplateFormPayload = (
|
||
template: GenericRecord | null | undefined,
|
||
payloadSources: GenericRecord[] = []
|
||
) => {
|
||
const normalizedTemplate = template || {};
|
||
const normalizedPayload: GenericRecord = {};
|
||
for (const field of normalizedTemplate.fields || []) {
|
||
const fieldKey = String(field?.key || "").trim();
|
||
if (!fieldKey) {
|
||
continue;
|
||
}
|
||
let rawValue: unknown = field?.default;
|
||
for (const source of payloadSources) {
|
||
if (source && Object.prototype.hasOwnProperty.call(source, fieldKey)) {
|
||
const candidate = normalizeActionTemplateFieldValue(field, source[fieldKey]);
|
||
const fieldType = String(field?.type || "text").trim();
|
||
const shouldOverride =
|
||
fieldType === "boolean" ||
|
||
fieldType === "number" ||
|
||
String(candidate ?? "").trim() !== "";
|
||
if (shouldOverride) {
|
||
rawValue = source[fieldKey];
|
||
}
|
||
}
|
||
}
|
||
normalizedPayload[fieldKey] = normalizeActionTemplateFieldValue(field, rawValue);
|
||
}
|
||
return normalizedPayload;
|
||
};
|
||
|
||
const buildActionTemplateDefaults = (template: GenericRecord | null | undefined) => {
|
||
const normalizedTemplate = template || {};
|
||
return {
|
||
template_key: String(normalizedTemplate.key || ""),
|
||
target_node_codes: [] as string[],
|
||
execution_mode:
|
||
String(normalizedTemplate.default_execution_mode || actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
auto_approve: Boolean(normalizedTemplate.default_auto_approve),
|
||
payload: buildActionTemplateFormPayload(normalizedTemplate)
|
||
};
|
||
};
|
||
|
||
const buildPlaybookDefaults = (playbook: GenericRecord | null | undefined) => {
|
||
const normalizedPlaybook = playbook || {};
|
||
return {
|
||
playbook_key: String(normalizedPlaybook.key || ""),
|
||
target_node_codes: [] as string[],
|
||
execution_mode: String(normalizedPlaybook.default_execution_mode || "remote-agent").trim() || "remote-agent",
|
||
auto_approve: Boolean(normalizedPlaybook.default_auto_approve ?? true),
|
||
};
|
||
};
|
||
|
||
const copyText = async (text: string, successMessage: string) => {
|
||
if (!text) {
|
||
ElMessage.warning("当前没有可复制内容");
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
ElMessage.success(successMessage);
|
||
} catch {
|
||
ElMessage.error("复制失败,请手动复制");
|
||
}
|
||
};
|
||
|
||
const setOpsCockpitActionFeedback = (payload: GenericRecord = {}) => {
|
||
opsCockpitActionFeedback.value = {
|
||
visible: true,
|
||
type: String(payload.type || "info").trim() || "info",
|
||
title: String(payload.title || "").trim() || "动作已处理",
|
||
detail: String(payload.detail || "").trim(),
|
||
};
|
||
};
|
||
|
||
const clearOpsCockpitActionFeedback = () => {
|
||
opsCockpitActionFeedback.value = {
|
||
visible: false,
|
||
type: "info",
|
||
title: "",
|
||
detail: "",
|
||
};
|
||
};
|
||
|
||
const resetManagedNodeForm = () => {
|
||
managedNodeForm.value = {
|
||
node_code: "",
|
||
title: "",
|
||
region: "mainland",
|
||
role: "worker",
|
||
ssh_host: "",
|
||
ssh_port: 22,
|
||
ssh_user: "root",
|
||
auth_mode: "key",
|
||
deploy_channel: "stable",
|
||
is_enabled: true,
|
||
metadata: {}
|
||
};
|
||
managedNodePostAction.value = "";
|
||
};
|
||
|
||
const resetReleaseForm = () => {
|
||
releaseForm.value = {
|
||
release_version: "",
|
||
channel: "stable",
|
||
status: "ready",
|
||
commit_sha: "",
|
||
artifact_url: "",
|
||
checksum: "",
|
||
notes: "",
|
||
metadata: {},
|
||
};
|
||
};
|
||
|
||
const resetRolloutForm = () => {
|
||
rolloutForm.value = {
|
||
node_codes_text: "",
|
||
region: "",
|
||
role: "",
|
||
only_enabled: true,
|
||
only_online: true,
|
||
only_effective_workers: false,
|
||
execution_mode: "remote-agent",
|
||
first_batch_size: 1,
|
||
batch_size: 2,
|
||
auto_start: true,
|
||
auto_dispatch: false,
|
||
auto_advance: true,
|
||
auto_approve: false,
|
||
continue_on_failure: false,
|
||
continue_on_partial: false,
|
||
restart_services_text: "domaincheck-api\ndomaincheck-worker",
|
||
health_check_urls_text: "http://127.0.0.1:8100/health",
|
||
health_check_services_text: "domaincheck-api\ndomaincheck-worker",
|
||
health_check_timeout_seconds: 10,
|
||
health_check_retries: 2,
|
||
health_check_interval_seconds: 2,
|
||
rollback_on_failure: true,
|
||
switch_current: true
|
||
};
|
||
rolloutSmartPrefillHint.value = "";
|
||
};
|
||
|
||
const findReleaseById = (releaseId: number) =>
|
||
releases.value.find((item) => Number(item.id) === Number(releaseId || 0)) || {};
|
||
|
||
const buildPolicyPreviewPayload = (payload: {
|
||
releaseId: number;
|
||
targetSelector: GenericRecord;
|
||
policy: GenericRecord;
|
||
}) => ({
|
||
action: "deploy.release",
|
||
target_type: "rollout",
|
||
release_id: Number(payload.releaseId || 0),
|
||
target_selector: payload.targetSelector,
|
||
policy: payload.policy,
|
||
});
|
||
|
||
const resetActionTemplateForm = () => {
|
||
actionTemplateExecutionModeTouched.value = false;
|
||
actionTemplateForm.value = {
|
||
template_key: "",
|
||
target_node_codes: [],
|
||
execution_mode: String(actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
auto_approve: false,
|
||
payload: {}
|
||
};
|
||
actionTemplatePreview.value = {};
|
||
};
|
||
|
||
const resetPlaybookForm = () => {
|
||
playbookExecutionModeTouched.value = false;
|
||
playbookForm.value = {
|
||
playbook_key: "",
|
||
target_node_codes: [],
|
||
execution_mode: "remote-agent",
|
||
auto_approve: true,
|
||
};
|
||
playbookPreview.value = {};
|
||
};
|
||
|
||
const applyActionTemplate = (
|
||
template: GenericRecord | null | undefined,
|
||
options: {
|
||
preserveTargets?: boolean;
|
||
preservePayload?: boolean;
|
||
prefillPayload?: GenericRecord;
|
||
} = {}
|
||
) => {
|
||
const { preserveTargets = false, preservePayload = false, prefillPayload = {} } = options;
|
||
const defaults = buildActionTemplateDefaults(template);
|
||
actionTemplateExecutionModeTouched.value = false;
|
||
const payloadSources: GenericRecord[] = [];
|
||
if (preservePayload) {
|
||
payloadSources.push({ ...(actionTemplateForm.value.payload || {}) });
|
||
}
|
||
if (prefillPayload && Object.keys(prefillPayload).length) {
|
||
payloadSources.push(prefillPayload);
|
||
}
|
||
actionTemplateForm.value = {
|
||
...defaults,
|
||
target_node_codes: preserveTargets ? [...(actionTemplateForm.value.target_node_codes || [])] : defaults.target_node_codes,
|
||
payload: payloadSources.length ? buildActionTemplateFormPayload(template, payloadSources) : { ...defaults.payload }
|
||
};
|
||
actionTemplatePreview.value = {};
|
||
};
|
||
|
||
const applyPlaybook = (
|
||
playbook: GenericRecord | null | undefined,
|
||
options: {
|
||
preserveTargets?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { preserveTargets = false } = options;
|
||
const defaults = buildPlaybookDefaults(playbook);
|
||
playbookExecutionModeTouched.value = false;
|
||
playbookForm.value = {
|
||
...defaults,
|
||
target_node_codes: preserveTargets ? [...(playbookForm.value.target_node_codes || [])] : defaults.target_node_codes,
|
||
};
|
||
playbookPreview.value = {};
|
||
};
|
||
|
||
const buildRolloutTargetSelector = () => ({
|
||
node_codes: splitTextList(rolloutForm.value.node_codes_text),
|
||
region: rolloutForm.value.region || undefined,
|
||
role: rolloutForm.value.role || undefined,
|
||
only_enabled: rolloutForm.value.only_enabled,
|
||
only_online: rolloutForm.value.only_online,
|
||
only_effective_workers: rolloutForm.value.only_effective_workers
|
||
});
|
||
|
||
const buildRolloutPolicy = () => ({
|
||
auto_start: rolloutForm.value.auto_start,
|
||
auto_dispatch: rolloutForm.value.auto_dispatch,
|
||
auto_advance: rolloutForm.value.auto_advance,
|
||
auto_approve: rolloutForm.value.auto_approve,
|
||
continue_on_failure: rolloutForm.value.continue_on_failure,
|
||
continue_on_partial: rolloutForm.value.continue_on_partial,
|
||
execution_mode: rolloutForm.value.execution_mode,
|
||
first_batch_size: rolloutForm.value.first_batch_size,
|
||
batch_size: rolloutForm.value.batch_size,
|
||
deploy_payload: {
|
||
restart_services: splitTextList(rolloutForm.value.restart_services_text),
|
||
health_check_urls: splitTextList(rolloutForm.value.health_check_urls_text),
|
||
health_check_services: splitTextList(rolloutForm.value.health_check_services_text),
|
||
health_check_timeout_seconds: rolloutForm.value.health_check_timeout_seconds,
|
||
health_check_retries: rolloutForm.value.health_check_retries,
|
||
health_check_interval_seconds: rolloutForm.value.health_check_interval_seconds,
|
||
rollback_on_failure: rolloutForm.value.rollback_on_failure,
|
||
switch_current: rolloutForm.value.switch_current
|
||
}
|
||
});
|
||
|
||
const loadOverview = async () => {
|
||
const [overviewResponse, capabilitiesResponse, contractsResponse, goLiveSummaryResponse, doctorDecisionResponse, goLiveSignoffResponse, goLiveBundleResponse, goLiveReviewResponse, stackDiagnosisResponse, linkSnapshotResponse, blueprintResponse, runbookResponse, codexBriefResponse] = await Promise.all([
|
||
opsApi.overview(),
|
||
opsApi.capabilities(),
|
||
opsApi.contracts().catch(() => ({ data: {} })),
|
||
opsApi.goLiveSummary({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.doctorDecision({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.goLiveSignoff({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.goLiveBundle({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.goLiveReview({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.stackDiagnosis({ base_url: controlPlaneBaseUrl.value || undefined }).catch(() => ({ data: {} })),
|
||
opsApi.linkSnapshot().catch(() => ({ data: {} })),
|
||
opsApi.blueprint().catch(() => ({ data: {} })),
|
||
opsApi.runbook().catch(() => ({ data: {} })),
|
||
opsApi.codexBrief().catch(() => ({ data: {} })),
|
||
]);
|
||
overview.value = overviewResponse.data || {};
|
||
if (overview.value.release_hub?.launchpad) {
|
||
releaseLaunchpad.value = overview.value.release_hub.launchpad as GenericRecord;
|
||
}
|
||
capabilitiesState.value = capabilitiesResponse.data || {};
|
||
contractsPayload.value = contractsResponse?.data || {};
|
||
goLiveSummaryPayload.value = goLiveSummaryResponse?.data || {};
|
||
doctorDecisionPayload.value = doctorDecisionResponse?.data || {};
|
||
goLiveSignoffPayload.value = goLiveSignoffResponse?.data || {};
|
||
goLiveBundlePayload.value = goLiveBundleResponse?.data || {};
|
||
goLiveReviewPayload.value = goLiveReviewResponse?.data || {};
|
||
stackDiagnosisPayload.value = stackDiagnosisResponse?.data || {};
|
||
linkSnapshotPayload.value = linkSnapshotResponse?.data || {};
|
||
blueprintPayload.value = blueprintResponse?.data || {};
|
||
runbookPayload.value = runbookResponse?.data || {};
|
||
codexBriefPayload.value = codexBriefResponse?.data || {};
|
||
driverFeedPayload.value = codexBriefPayload.value.driver_feed || {};
|
||
if (!Object.keys(driverFeedPayload.value).length) {
|
||
const driverFeedResponse = await opsApi.driverFeed().catch(() => ({ data: {} }));
|
||
driverFeedPayload.value = driverFeedResponse?.data || {};
|
||
}
|
||
if (!inspectionHasFilters.value) {
|
||
inspectionPayloadState.value = (overview.value.inspection as GenericRecord) || {};
|
||
}
|
||
if (remoteLogDrawerVisible.value && remoteLogSelectedNodeCode.value) {
|
||
await loadRemoteLogNodeDetail(remoteLogSelectedNodeCode.value, { silent: true });
|
||
}
|
||
};
|
||
|
||
const loadRemoteLogNodeDetail = async (nodeCode: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (!normalizedNodeCode) {
|
||
remoteLogNodeDetail.value = {};
|
||
return false;
|
||
}
|
||
if (!options.silent) {
|
||
loading.value.sceneNodeLog = true;
|
||
}
|
||
try {
|
||
const response = await opsApi.nodeSceneLog(normalizedNodeCode, {
|
||
limit: 120,
|
||
mode: opsWorkerLogSyncEnabled.value ? opsWorkerLogSyncMode.value : "key",
|
||
});
|
||
remoteLogNodeDetail.value = response.data || {};
|
||
return true;
|
||
} catch (error: any) {
|
||
if (!options.silent) {
|
||
ElMessage.error(error?.message || "读取节点现场日志失败");
|
||
}
|
||
return false;
|
||
} finally {
|
||
if (!options.silent) {
|
||
loading.value.sceneNodeLog = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleRemoteLogNodeSelectionChange = async (nodeCode: string) => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
remoteLogSelectedNodeCode.value = normalizedNodeCode;
|
||
if (!normalizedNodeCode) {
|
||
remoteLogNodeDetail.value = {};
|
||
return;
|
||
}
|
||
await loadRemoteLogNodeDetail(normalizedNodeCode);
|
||
};
|
||
|
||
const openRemoteLogDrawer = async (nodeCode = "") => {
|
||
remoteLogDrawerVisible.value = true;
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
remoteLogSelectedNodeCode.value = normalizedNodeCode;
|
||
if (!normalizedNodeCode) {
|
||
remoteLogNodeDetail.value = {};
|
||
return;
|
||
}
|
||
await loadRemoteLogNodeDetail(normalizedNodeCode);
|
||
};
|
||
|
||
const refreshRemoteLogDrawer = async () => {
|
||
loading.value.sceneNodeLog = true;
|
||
try {
|
||
await loadOverview();
|
||
} finally {
|
||
loading.value.sceneNodeLog = false;
|
||
}
|
||
};
|
||
|
||
const loadOpsContractDetail = async (contractKey: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedContractKey = String(contractKey || "").trim();
|
||
if (!normalizedContractKey) {
|
||
if (!options.silent) {
|
||
ElMessage.warning("当前 contract 缺少 key,无法读取详情");
|
||
}
|
||
return false;
|
||
}
|
||
loading.value.contractDetail = true;
|
||
try {
|
||
const response = await opsApi.contractDetail(normalizedContractKey);
|
||
selectedOpsContractKey.value = normalizedContractKey;
|
||
opsContractDetailPayload.value = response.data || {};
|
||
return true;
|
||
} catch (error: any) {
|
||
selectedOpsContractKey.value = normalizedContractKey;
|
||
opsContractDetailPayload.value = {};
|
||
if (!options.silent) {
|
||
ElMessage.warning(error?.message || `读取 contract 详情失败:${normalizedContractKey}`);
|
||
}
|
||
return false;
|
||
} finally {
|
||
loading.value.contractDetail = false;
|
||
}
|
||
};
|
||
|
||
const openOpsContractDetail = async (contractKey: string) => {
|
||
const normalizedContractKey = String(contractKey || "").trim();
|
||
if (!normalizedContractKey) {
|
||
ElMessage.warning("当前 contract 缺少 key,无法打开详情");
|
||
return;
|
||
}
|
||
selectedOpsContractKey.value = normalizedContractKey;
|
||
opsContractDetailVisible.value = true;
|
||
await loadOpsContractDetail(normalizedContractKey, { silent: true });
|
||
};
|
||
|
||
const refreshOpsContractDetail = async () => {
|
||
if (!selectedOpsContractKey.value) {
|
||
ElMessage.warning("当前还没有选中的 contract");
|
||
return;
|
||
}
|
||
await loadOpsContractDetail(selectedOpsContractKey.value);
|
||
};
|
||
|
||
const loadActionTemplates = async () => {
|
||
const response = await opsApi.actionTemplates();
|
||
actionTemplatesPayload.value = response.data || {};
|
||
if (!actionTemplateForm.value.template_key && actionTemplates.value.length) {
|
||
applyActionTemplate(actionTemplates.value[0]);
|
||
}
|
||
};
|
||
|
||
const loadPlaybooks = async () => {
|
||
const response = await opsApi.playbooks();
|
||
playbooksPayload.value = response.data || {};
|
||
if (!playbookForm.value.playbook_key && playbooks.value.length) {
|
||
applyPlaybook(playbooks.value[0]);
|
||
}
|
||
};
|
||
|
||
const loadPlaybookRuns = async () => {
|
||
const response = await opsApi.playbookRuns({
|
||
limit: 12,
|
||
status: playbookRunFilters.value.status,
|
||
group_key: playbookRunFilters.value.group_key,
|
||
query: playbookRunFilters.value.query,
|
||
});
|
||
playbookRunsPayload.value = response.data || {};
|
||
};
|
||
|
||
const loadActivityStream = async () => {
|
||
const response = await opsApi.activityStream({
|
||
limit: 18,
|
||
kind: activityStreamFilters.value.kind,
|
||
status: activityStreamFilters.value.status,
|
||
execution_mode: activityStreamFilters.value.execution_mode,
|
||
query: activityStreamFilters.value.query,
|
||
});
|
||
activityStreamPayload.value = response.data || {};
|
||
};
|
||
|
||
const loadInspectionOverview = async () => {
|
||
const response = await opsApi.inspectionOverview({
|
||
limit: OPS_JOB_FETCH_LIMIT,
|
||
status: inspectionFilters.value.status,
|
||
problem_kind: inspectionFilters.value.problem_kind,
|
||
query: inspectionFilters.value.query,
|
||
only_problem: inspectionFilters.value.only_problem,
|
||
only_participating: inspectionFilters.value.only_participating,
|
||
});
|
||
inspectionPayloadState.value = response.data || {};
|
||
};
|
||
|
||
const applyInspectionFilters = async () => {
|
||
try {
|
||
await loadInspectionOverview();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "筛选巡检收口失败");
|
||
}
|
||
};
|
||
|
||
const resetInspectionFilters = async () => {
|
||
inspectionFilters.value = {
|
||
status: "",
|
||
problem_kind: "",
|
||
query: "",
|
||
only_problem: false,
|
||
only_participating: false,
|
||
};
|
||
await applyInspectionFilters();
|
||
};
|
||
|
||
const refreshInspectionPanel = async () => {
|
||
if (!inspectionHasFilters.value) {
|
||
inspectionPayloadState.value = (overview.value.inspection as GenericRecord) || {};
|
||
return;
|
||
}
|
||
await loadInspectionOverview();
|
||
};
|
||
|
||
const applyActivityStreamFilters = async () => {
|
||
try {
|
||
await loadActivityStream();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "筛选活动流失败");
|
||
}
|
||
};
|
||
|
||
const resetActivityStreamFilters = async () => {
|
||
activityStreamFilters.value = {
|
||
kind: "",
|
||
status: "",
|
||
execution_mode: "",
|
||
query: "",
|
||
};
|
||
await applyActivityStreamFilters();
|
||
};
|
||
|
||
const resetExecutionSceneFilters = () => {
|
||
executionSceneFilters.value = {
|
||
state_group: "",
|
||
role: "",
|
||
};
|
||
};
|
||
|
||
const loadPlaybookRunDetail = async (runCode: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedRunCode = String(runCode || "").trim();
|
||
if (!normalizedRunCode) {
|
||
return null;
|
||
}
|
||
const { silent = false } = options;
|
||
loading.value.playbookRunDetail = true;
|
||
try {
|
||
const response = await opsApi.playbookRunDetail(normalizedRunCode);
|
||
activePlaybookRun.value = response.data || {};
|
||
return activePlaybookRun.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取编排回执详情失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.playbookRunDetail = false;
|
||
}
|
||
};
|
||
|
||
const loadPlaybookRunEvents = async (runCode: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedRunCode = String(runCode || "").trim();
|
||
if (!normalizedRunCode) {
|
||
playbookRunEventsPayload.value = {};
|
||
return null;
|
||
}
|
||
const { silent = false } = options;
|
||
try {
|
||
const response = await opsApi.playbookRunEvents(normalizedRunCode, {
|
||
limit: 80,
|
||
step_key: playbookRunEventFilters.value.step_key,
|
||
node_code: playbookRunEventFilters.value.node_code,
|
||
});
|
||
playbookRunEventsPayload.value = response.data || {};
|
||
return playbookRunEventsPayload.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取编排回执事件失败");
|
||
}
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const refreshActivePlaybookRunDetail = async () => {
|
||
const runCode = String(activePlaybookRun.value?.run_code || "").trim();
|
||
if (!runCode) {
|
||
return;
|
||
}
|
||
await Promise.all([loadPlaybookRunDetail(runCode), loadPlaybookRunEvents(runCode, { silent: true })]);
|
||
};
|
||
|
||
const applyPlaybookRunFilters = async () => {
|
||
try {
|
||
await loadPlaybookRuns();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "筛选编排回执失败");
|
||
}
|
||
};
|
||
|
||
const resetPlaybookRunFilters = async () => {
|
||
playbookRunFilters.value = {
|
||
status: "",
|
||
group_key: "",
|
||
query: "",
|
||
};
|
||
await applyPlaybookRunFilters();
|
||
};
|
||
|
||
const applyPlaybookRunEventFilters = async () => {
|
||
const runCode = String(activePlaybookRun.value?.run_code || "").trim();
|
||
if (!runCode) {
|
||
return;
|
||
}
|
||
await loadPlaybookRunEvents(runCode);
|
||
};
|
||
|
||
const resetPlaybookRunEventFilters = async () => {
|
||
playbookRunEventFilters.value = {
|
||
step_key: "",
|
||
node_code: "",
|
||
};
|
||
await applyPlaybookRunEventFilters();
|
||
};
|
||
|
||
const loadNodes = async () => {
|
||
const response = await opsApi.nodes();
|
||
nodes.value = response.data?.nodes || [];
|
||
nodesSummary.value = response.data?.summary || {};
|
||
};
|
||
|
||
const loadManagedNodeDeliveryQueue = async (nodeCode: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (!normalizedNodeCode) {
|
||
activeManagedNodeDeliveryQueue.value = {};
|
||
return null;
|
||
}
|
||
const { silent = false } = options;
|
||
loading.value.deliveryQueue = true;
|
||
try {
|
||
const response = await opsApi.deliveryQueue(normalizedNodeCode);
|
||
activeManagedNodeDeliveryQueue.value = response.data || {};
|
||
return activeManagedNodeDeliveryQueue.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取节点回执队列失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.deliveryQueue = false;
|
||
}
|
||
};
|
||
|
||
const loadManagedNodeDeliveryQueueRecords = async (nodeCode: string, options: { silent?: boolean } = {}) => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (!normalizedNodeCode) {
|
||
managedNodeDeliveryQueueRecordsPayload.value = {};
|
||
return null;
|
||
}
|
||
const { silent = false } = options;
|
||
loading.value.deliveryQueue = true;
|
||
try {
|
||
const response = await opsApi.deliveryQueueRecords(normalizedNodeCode, {
|
||
state: managedNodeDeliveryQueueFilters.value.state,
|
||
limit: managedNodeDeliveryQueueFilters.value.limit,
|
||
});
|
||
managedNodeDeliveryQueueRecordsPayload.value = response.data || {};
|
||
return managedNodeDeliveryQueueRecordsPayload.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取节点回执队列记录失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.deliveryQueue = false;
|
||
}
|
||
};
|
||
|
||
const refreshManagedNodeDeliveryQueueRecords = async () => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
return;
|
||
}
|
||
await loadManagedNodeDeliveryQueueRecords(nodeCode);
|
||
};
|
||
|
||
const refreshManagedNodeDeliveryQueueDrawer = async () => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
return;
|
||
}
|
||
await Promise.all([
|
||
loadManagedNodeDeliveryQueue(nodeCode),
|
||
loadManagedNodeDeliveryQueueRecords(nodeCode, { silent: true }),
|
||
]);
|
||
};
|
||
|
||
const refreshOpsCenterAfterDeliveryQueueAction = async (nodeCode = "") => {
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadNodes(),
|
||
loadJobs(),
|
||
loadActivityStream(),
|
||
]);
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (normalizedNodeCode && managedNodeDeliveryQueueVisible.value) {
|
||
await Promise.all([
|
||
loadManagedNodeDeliveryQueue(normalizedNodeCode, { silent: true }),
|
||
loadManagedNodeDeliveryQueueRecords(normalizedNodeCode, { silent: true }),
|
||
]);
|
||
}
|
||
};
|
||
|
||
const openManagedNodeDeliveryQueueDrawer = async (row?: GenericRecord) => {
|
||
const nodeCode = String(row?.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前节点缺少 node_code,无法查看回执队列");
|
||
return;
|
||
}
|
||
managedNodeDeliveryQueueFilters.value = {
|
||
state: "",
|
||
limit: 20,
|
||
};
|
||
activeManagedNodeDeliveryQueue.value = {
|
||
node_code: nodeCode,
|
||
node: {
|
||
node_code: nodeCode,
|
||
title: String(row?.title || row?.node_code || "").trim(),
|
||
region: String(row?.region || "").trim(),
|
||
role: String(row?.role || "").trim(),
|
||
agent_state_label: String(row?.agent_state_label || "").trim(),
|
||
remote_access_label: String(row?.remote_access_label || "").trim(),
|
||
},
|
||
summary: {
|
||
state: String(row?.delivery_queue_state || "").trim(),
|
||
label: String(row?.delivery_queue_label || "").trim(),
|
||
reason: String(row?.delivery_queue_reason || "").trim(),
|
||
pending_count: Number(row?.delivery_queue_pending_count || 0),
|
||
dead_letter_count: Number(row?.delivery_queue_dead_letter_count || 0),
|
||
last_flush_at: String(row?.delivery_queue_last_flush_at || "").trim(),
|
||
oldest_pending_at: String(row?.delivery_queue_oldest_pending_at || "").trim(),
|
||
oldest_dead_letter_at: String(row?.delivery_queue_oldest_dead_letter_at || "").trim(),
|
||
},
|
||
head_records: {
|
||
pending: row?.delivery_queue_oldest_pending_request_id
|
||
? {
|
||
record_id: String(row?.delivery_queue_oldest_pending_request_id || "").trim(),
|
||
state: "pending",
|
||
request_kind: String(row?.delivery_queue_oldest_pending_kind || "").trim(),
|
||
created_at: String(row?.delivery_queue_oldest_pending_at || "").trim(),
|
||
record_source: "agent-heartbeat-snapshot",
|
||
label: "待重试头部记录",
|
||
}
|
||
: {},
|
||
dead_letter: row?.delivery_queue_oldest_dead_letter_request_id
|
||
? {
|
||
record_id: String(row?.delivery_queue_oldest_dead_letter_request_id || "").trim(),
|
||
state: "dead_letter",
|
||
request_kind: String(row?.delivery_queue_oldest_dead_letter_kind || "").trim(),
|
||
dead_letter_at: String(row?.delivery_queue_oldest_dead_letter_at || "").trim(),
|
||
record_source: "agent-heartbeat-snapshot",
|
||
label: "死信头部记录",
|
||
}
|
||
: {},
|
||
},
|
||
};
|
||
managedNodeDeliveryQueueRecordsPayload.value = {};
|
||
managedNodeDeliveryQueueVisible.value = true;
|
||
await Promise.all([
|
||
loadManagedNodeDeliveryQueue(nodeCode, { silent: true }),
|
||
loadManagedNodeDeliveryQueueRecords(nodeCode, { silent: true }),
|
||
]);
|
||
};
|
||
|
||
const openManagedNodeDeliveryQueueTemplate = async (templateKey: string, payload: GenericRecord = {}) => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
if (!actionTemplates.value.length) {
|
||
await loadActionTemplates();
|
||
}
|
||
openPrefilledActionTemplateDialog(templateKey, [nodeCode], payload);
|
||
};
|
||
|
||
const openManagedNodeDeliveryQueueTemplateForRecord = async (record: GenericRecord) => {
|
||
const templateKey = String(record?.state || "").trim() === "dead_letter" ? "delivery.queue.replay" : "delivery.queue.flush";
|
||
const payload =
|
||
templateKey === "delivery.queue.replay"
|
||
? {
|
||
record_id: managedNodeDeliveryQueueRecordId(record),
|
||
flush_after_replay: true,
|
||
reason: "",
|
||
}
|
||
: {
|
||
limit: 1,
|
||
};
|
||
await openManagedNodeDeliveryQueueTemplate(templateKey, payload);
|
||
};
|
||
|
||
const flushManagedNodeDeliveryQueue = async () => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
const limit = Math.min(Math.max(Number(managedNodeDeliveryQueueSummary.value.pending_count || 1), 1), 20);
|
||
loading.value.deliveryQueueActionKey = `flush:${nodeCode}`;
|
||
try {
|
||
const response = await opsApi.flushDeliveryQueue(nodeCode, {
|
||
limit,
|
||
requested_by: "web-ui/ops-center-delivery-queue",
|
||
});
|
||
await refreshOpsCenterAfterDeliveryQueueAction(nodeCode);
|
||
ElMessage.success(response.message || "回执队列冲刷任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建回执队列冲刷任务失败");
|
||
} finally {
|
||
loading.value.deliveryQueueActionKey = "";
|
||
}
|
||
};
|
||
|
||
const replayManagedNodeDeliveryQueue = async () => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
const limit = Math.min(Math.max(Number(managedNodeDeliveryQueueSummary.value.dead_letter_count || 1), 1), 20);
|
||
loading.value.deliveryQueueActionKey = `replay:${nodeCode}`;
|
||
try {
|
||
const response = await opsApi.replayDeliveryQueue(nodeCode, {
|
||
limit,
|
||
flush_after_replay: true,
|
||
reason: "web-ui quick replay from ops center",
|
||
requested_by: "web-ui/ops-center-delivery-queue",
|
||
});
|
||
await refreshOpsCenterAfterDeliveryQueueAction(nodeCode);
|
||
ElMessage.success(response.message || "死信重放任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建死信重放任务失败");
|
||
} finally {
|
||
loading.value.deliveryQueueActionKey = "";
|
||
}
|
||
};
|
||
|
||
const replayManagedNodeDeliveryQueueRecord = async (record: GenericRecord) => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || record?.node_code || "").trim();
|
||
const recordId = managedNodeDeliveryQueueRecordId(record);
|
||
if (!nodeCode || !recordId) {
|
||
ElMessage.warning("当前记录缺少可用 ID,无法重放");
|
||
return;
|
||
}
|
||
loading.value.deliveryQueueActionKey = `replay:${nodeCode}:${recordId}`;
|
||
try {
|
||
const response = await opsApi.replayDeliveryQueueRecord(nodeCode, recordId, {
|
||
limit: 1,
|
||
flush_after_replay: true,
|
||
reason: "web-ui quick replay head record",
|
||
requested_by: "web-ui/ops-center-delivery-queue",
|
||
});
|
||
await refreshOpsCenterAfterDeliveryQueueAction(nodeCode);
|
||
ElMessage.success(response.message || "单条死信重放任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建单条死信重放任务失败");
|
||
} finally {
|
||
loading.value.deliveryQueueActionKey = "";
|
||
}
|
||
};
|
||
|
||
const discardManagedNodeDeliveryQueueRecord = async (record: GenericRecord) => {
|
||
const nodeCode = String(managedNodeDeliveryQueueNode.value.node_code || record?.node_code || "").trim();
|
||
const recordId = managedNodeDeliveryQueueRecordId(record);
|
||
if (!nodeCode || !recordId) {
|
||
ElMessage.warning("当前记录缺少可用 ID,无法丢弃");
|
||
return;
|
||
}
|
||
try {
|
||
const { value } = await ElMessageBox.prompt(
|
||
`请填写死信 ${recordId} 的丢弃原因。该操作会把记录移出活动队列并归档到 discarded 目录。`,
|
||
"丢弃死信",
|
||
{
|
||
confirmButtonText: "确认丢弃",
|
||
cancelButtonText: "取消",
|
||
inputType: "textarea",
|
||
inputPlaceholder: "例如:确认该记录不可恢复,且已人工记录现场信息",
|
||
inputValidator: (inputValue: string) => String(inputValue || "").trim() ? true : "必须填写丢弃原因",
|
||
}
|
||
);
|
||
loading.value.deliveryQueueActionKey = `discard:${nodeCode}:${recordId}`;
|
||
const response = await opsApi.discardDeliveryQueueRecord(nodeCode, recordId, {
|
||
limit: 1,
|
||
reason: String(value || "").trim(),
|
||
discarded_by: "web-ui",
|
||
requested_by: "web-ui/ops-center-delivery-queue",
|
||
});
|
||
await refreshOpsCenterAfterDeliveryQueueAction(nodeCode);
|
||
ElMessage.success(response.message || "死信丢弃任务已创建");
|
||
} catch (error: any) {
|
||
if (error === "cancel" || error === "close" || String(error?.message || "").includes("cancel")) {
|
||
return;
|
||
}
|
||
ElMessage.error(error?.message || "创建死信丢弃任务失败");
|
||
} finally {
|
||
loading.value.deliveryQueueActionKey = "";
|
||
}
|
||
};
|
||
|
||
const refreshAgentOnboardingState = async (options: { silent?: boolean } = {}) => {
|
||
const { silent = false } = options;
|
||
if (!agentTargetNodeCode.value) {
|
||
agentHandoverPayload.value = {};
|
||
onboardingState.value = {};
|
||
return;
|
||
}
|
||
loading.value.agentOnboardingRefresh = true;
|
||
try {
|
||
await Promise.all([
|
||
loadNodes(),
|
||
(async () => {
|
||
const response = await opsApi.nodeOnboarding(agentTargetNodeCode.value, {
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
});
|
||
onboardingState.value = response.data || {};
|
||
agentHandoverPayload.value = response.data?.handover || {};
|
||
})(),
|
||
]);
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "刷新节点接管状态失败");
|
||
}
|
||
} finally {
|
||
loading.value.agentOnboardingRefresh = false;
|
||
}
|
||
};
|
||
|
||
const loadJobs = async () => {
|
||
const response = await opsApi.jobs({ limit: OPS_JOB_FETCH_LIMIT });
|
||
jobs.value = response.data?.jobs || [];
|
||
};
|
||
|
||
const loadLatestReleasePackageMetadata = async (
|
||
options: {
|
||
silent?: boolean;
|
||
showToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { silent = false, showToast = false } = options;
|
||
loading.value.releasePackageMetadata = true;
|
||
try {
|
||
const response = await opsApi.latestReleasePackageMetadata();
|
||
latestReleasePackage.value = response.data || {};
|
||
if (showToast) {
|
||
if (latestReleasePackage.value?.available) {
|
||
ElMessage.success(`已读取最新发布包:${latestReleasePackage.value.package_name || "-"}`);
|
||
} else {
|
||
ElMessage.info(String(latestReleasePackage.value?.reason || "当前没有可导入的发布包"));
|
||
}
|
||
}
|
||
return latestReleasePackage.value;
|
||
} catch (error: any) {
|
||
latestReleasePackage.value = {};
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取最新发布包元数据失败");
|
||
}
|
||
return latestReleasePackage.value;
|
||
} finally {
|
||
loading.value.releasePackageMetadata = false;
|
||
}
|
||
};
|
||
|
||
const loadReleaseLaunchpad = async (
|
||
options: {
|
||
silent?: boolean;
|
||
showToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { silent = false, showToast = false } = options;
|
||
loading.value.releaseLaunchpad = true;
|
||
try {
|
||
const response = await opsApi.releaseLaunchpad("stable");
|
||
releaseLaunchpad.value = response.data || {};
|
||
if (showToast) {
|
||
ElMessage.success(
|
||
`发布驾驶舱已刷新:${String(releaseLaunchpad.value?.launchpad_status?.status_label || "已更新")}`
|
||
);
|
||
}
|
||
return releaseLaunchpad.value;
|
||
} catch (error: any) {
|
||
releaseLaunchpad.value = {};
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取发布驾驶舱失败");
|
||
}
|
||
return releaseLaunchpad.value;
|
||
} finally {
|
||
loading.value.releaseLaunchpad = false;
|
||
}
|
||
};
|
||
|
||
const loadJobDetail = async (jobId: number, options: { silent?: boolean } = {}) => {
|
||
const normalizedJobId = Number(jobId || 0);
|
||
if (!normalizedJobId) {
|
||
return null;
|
||
}
|
||
const { silent = false } = options;
|
||
loading.value.jobDetail = true;
|
||
try {
|
||
const response = await opsApi.jobDetail(normalizedJobId);
|
||
activeJob.value = response.data || {};
|
||
return activeJob.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "读取任务详情失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.jobDetail = false;
|
||
}
|
||
};
|
||
|
||
const loadReleases = async () => {
|
||
const [listResponse, latestResponse] = await Promise.all([
|
||
opsApi.releases({ limit: 12 }),
|
||
opsApi.latestRelease("stable"),
|
||
loadLatestReleasePackageMetadata({ silent: true }),
|
||
]);
|
||
releases.value = listResponse.data?.releases || [];
|
||
latestRelease.value = latestResponse.data || {};
|
||
if (!selectedReleaseId.value && releases.value.length) {
|
||
selectedReleaseId.value = Number(latestRelease.value.id || releases.value[0]?.id || 0) || null;
|
||
}
|
||
};
|
||
|
||
const loadRollouts = async (releaseId?: number | null) => {
|
||
const targetReleaseId = Number(releaseId || selectedReleaseId.value || 0);
|
||
if (!targetReleaseId) {
|
||
rollouts.value = [];
|
||
return;
|
||
}
|
||
const response = await opsApi.releaseRollouts(targetReleaseId, { limit: 12 });
|
||
rollouts.value = response.data?.rollouts || [];
|
||
};
|
||
|
||
const loadAll = async () => {
|
||
loading.value.all = true;
|
||
try {
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadNodes(),
|
||
loadJobs(),
|
||
loadReleases(),
|
||
loadReleaseLaunchpad({ silent: true }),
|
||
loadActionTemplates(),
|
||
loadPlaybooks(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
]);
|
||
if (inspectionHasFilters.value) {
|
||
await loadInspectionOverview();
|
||
}
|
||
await loadRollouts();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "读取运维中枢失败");
|
||
} finally {
|
||
loading.value.all = false;
|
||
}
|
||
};
|
||
|
||
const handleReleaseChange = async () => {
|
||
try {
|
||
await loadRollouts();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "读取 rollout 失败");
|
||
}
|
||
};
|
||
|
||
const handleReleaseRowClick = async (row: GenericRecord) => {
|
||
selectedReleaseId.value = Number(row.id || 0) || null;
|
||
await handleReleaseChange();
|
||
};
|
||
|
||
const syncNodesFromCluster = async () => {
|
||
loading.value.nodesSync = true;
|
||
try {
|
||
const response = await opsApi.syncNodesFromCluster(false);
|
||
ElMessage.success(response.message || "节点同步成功");
|
||
await Promise.all([loadOverview(), loadNodes()]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "同步节点失败");
|
||
} finally {
|
||
loading.value.nodesSync = false;
|
||
}
|
||
};
|
||
|
||
const openManagedNodeDialog = (
|
||
row?: GenericRecord,
|
||
options: {
|
||
generateBootstrapAfterSave?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { generateBootstrapAfterSave = false } = options;
|
||
resetManagedNodeForm();
|
||
managedNodeEditing.value = Boolean(row?.node_code && row?.is_managed);
|
||
managedNodePostAction.value = generateBootstrapAfterSave ? "bootstrap-plan" : "";
|
||
if (row?.node_code) {
|
||
managedNodeForm.value = {
|
||
node_code: String(row.node_code || ""),
|
||
title: String(row.title || row.node_code || ""),
|
||
region: String(row.region || "mainland"),
|
||
role: String(row.role || "worker"),
|
||
ssh_host: String(row.ssh_host || row.cluster_ip || row.agent_ip || row.metadata?.ip || ""),
|
||
ssh_port: Number(row.ssh_port || 22) || 22,
|
||
ssh_user: String(row.ssh_user || "root"),
|
||
auth_mode: String(row.auth_mode || "key"),
|
||
deploy_channel: String(row.deploy_channel || "stable"),
|
||
is_enabled: Boolean(row.is_managed ? row.is_enabled : true),
|
||
metadata: {
|
||
...(row.metadata || {}),
|
||
cluster_status: row.cluster_status || row.metadata?.cluster_status || "",
|
||
cluster_last_heartbeat_at: row.cluster_last_heartbeat_at || row.metadata?.last_heartbeat_at || "",
|
||
imported_from_cluster_fallback: Boolean(row.metadata?.imported_from_cluster_fallback),
|
||
}
|
||
};
|
||
}
|
||
managedNodeVisible.value = true;
|
||
};
|
||
|
||
const findActionTemplateByKey = (templateKey: string): GenericRecord =>
|
||
actionTemplates.value.find((item) => String(item.key || "").trim() === String(templateKey || "").trim()) || {};
|
||
|
||
const findPlaybookByKey = (playbookKey: string): GenericRecord =>
|
||
playbooks.value.find((item) => String(item.key || "").trim() === String(playbookKey || "").trim()) || {};
|
||
|
||
const openActionTemplateDialog = async (
|
||
template?: GenericRecord,
|
||
options: {
|
||
targetNodeCodes?: string[];
|
||
prefillPayload?: GenericRecord;
|
||
executionMode?: string;
|
||
autoApprove?: boolean;
|
||
} = {}
|
||
) => {
|
||
const {
|
||
targetNodeCodes = [],
|
||
prefillPayload = {},
|
||
executionMode = "",
|
||
autoApprove,
|
||
} = options;
|
||
if (!actionTemplates.value.length) {
|
||
await loadActionTemplates();
|
||
}
|
||
const resolvedTemplate =
|
||
(template?.key ? findActionTemplateByKey(String(template.key || "")) : {}) ||
|
||
{};
|
||
if (resolvedTemplate?.key) {
|
||
applyActionTemplate(resolvedTemplate, { preserveTargets: false, prefillPayload });
|
||
} else if (!actionTemplateForm.value.template_key && actionTemplates.value.length) {
|
||
applyActionTemplate(actionTemplates.value[0], { preserveTargets: false, prefillPayload });
|
||
}
|
||
actionTemplateForm.value.target_node_codes = normalizeDriverNodeCodes(targetNodeCodes);
|
||
if (String(executionMode || "").trim()) {
|
||
actionTemplateForm.value.execution_mode = String(executionMode || "").trim();
|
||
actionTemplateExecutionModeTouched.value = true;
|
||
} else {
|
||
syncActionTemplateExecutionModeRecommendation({ force: true });
|
||
}
|
||
if (typeof autoApprove === "boolean") {
|
||
actionTemplateForm.value.auto_approve = autoApprove;
|
||
}
|
||
actionTemplateVisible.value = true;
|
||
};
|
||
|
||
const openPlaybookDialog = async (
|
||
playbook?: GenericRecord,
|
||
options: {
|
||
targetNodeCodes?: string[];
|
||
executionMode?: string;
|
||
autoApprove?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { targetNodeCodes = [], executionMode = "", autoApprove } = options;
|
||
if (!playbooks.value.length) {
|
||
await loadPlaybooks();
|
||
}
|
||
const resolvedPlaybook =
|
||
(playbook?.key ? findPlaybookByKey(String(playbook.key || "")) : {}) ||
|
||
{};
|
||
if (resolvedPlaybook?.key) {
|
||
applyPlaybook(resolvedPlaybook, { preserveTargets: false });
|
||
} else if (!playbookForm.value.playbook_key && playbooks.value.length) {
|
||
applyPlaybook(playbooks.value[0], { preserveTargets: false });
|
||
}
|
||
playbookForm.value.target_node_codes = normalizeDriverNodeCodes(targetNodeCodes);
|
||
if (String(executionMode || "").trim()) {
|
||
playbookForm.value.execution_mode = String(executionMode || "").trim();
|
||
playbookExecutionModeTouched.value = true;
|
||
} else {
|
||
syncPlaybookExecutionModeRecommendation({ force: true });
|
||
}
|
||
if (typeof autoApprove === "boolean") {
|
||
playbookForm.value.auto_approve = autoApprove;
|
||
}
|
||
playbookPreview.value = {};
|
||
playbookVisible.value = true;
|
||
};
|
||
|
||
const syncActionTemplateExecutionModeRecommendation = (options: { force?: boolean } = {}) => {
|
||
const { force = false } = options;
|
||
const recommendation = actionTemplateExecutionModeRecommendation.value;
|
||
const currentMode = String(actionTemplateForm.value.execution_mode || "").trim();
|
||
const currentOption = selectedActionTemplateModeOptions.value.find((item) => String(item.value || "").trim() === currentMode);
|
||
if (!recommendation.recommendedMode) {
|
||
return;
|
||
}
|
||
if (
|
||
force ||
|
||
!actionTemplateExecutionModeTouched.value ||
|
||
!recommendation.supportedModes.includes(currentMode) ||
|
||
Boolean(currentOption?.disabled)
|
||
) {
|
||
actionTemplateForm.value.execution_mode = recommendation.recommendedMode;
|
||
}
|
||
};
|
||
|
||
const syncPlaybookExecutionModeRecommendation = (options: { force?: boolean } = {}) => {
|
||
const { force = false } = options;
|
||
const recommendation = playbookExecutionModeRecommendation.value;
|
||
const currentMode = String(playbookForm.value.execution_mode || "").trim();
|
||
const currentOption = selectedPlaybookModeOptions.value.find((item) => String(item.value || "").trim() === currentMode);
|
||
if (!recommendation.recommendedMode) {
|
||
return;
|
||
}
|
||
if (
|
||
force ||
|
||
!playbookExecutionModeTouched.value ||
|
||
!recommendation.supportedModes.includes(currentMode) ||
|
||
Boolean(currentOption?.disabled)
|
||
) {
|
||
playbookForm.value.execution_mode = recommendation.recommendedMode;
|
||
}
|
||
};
|
||
|
||
const handleActionTemplateChange = () => {
|
||
if (!selectedActionTemplate.value.key) {
|
||
actionTemplateForm.value.payload = {};
|
||
return;
|
||
}
|
||
applyActionTemplate(selectedActionTemplate.value, { preserveTargets: true, preservePayload: true });
|
||
syncActionTemplateExecutionModeRecommendation({ force: true });
|
||
};
|
||
|
||
const handlePlaybookChange = () => {
|
||
if (!selectedPlaybook.value.key) {
|
||
playbookPreview.value = {};
|
||
return;
|
||
}
|
||
applyPlaybook(selectedPlaybook.value, { preserveTargets: true });
|
||
syncPlaybookExecutionModeRecommendation({ force: true });
|
||
};
|
||
|
||
const handleActionTemplateExecutionModeChange = () => {
|
||
actionTemplateExecutionModeTouched.value = true;
|
||
};
|
||
|
||
const handlePlaybookExecutionModeChange = () => {
|
||
playbookExecutionModeTouched.value = true;
|
||
};
|
||
|
||
const saveManagedNode = async () => {
|
||
if (!managedNodeForm.value.node_code.trim()) {
|
||
ElMessage.warning("请先填写节点编码");
|
||
return;
|
||
}
|
||
loading.value.nodeSave = true;
|
||
try {
|
||
const response = await opsApi.upsertNode({
|
||
node_code: managedNodeForm.value.node_code.trim(),
|
||
title: managedNodeForm.value.title.trim() || managedNodeForm.value.node_code.trim(),
|
||
region: managedNodeForm.value.region,
|
||
role: managedNodeForm.value.role,
|
||
ssh_host: managedNodeForm.value.ssh_host.trim(),
|
||
ssh_port: managedNodeForm.value.ssh_port,
|
||
ssh_user: managedNodeForm.value.ssh_user.trim(),
|
||
auth_mode: managedNodeForm.value.auth_mode,
|
||
deploy_channel: managedNodeForm.value.deploy_channel,
|
||
is_enabled: managedNodeForm.value.is_enabled,
|
||
metadata: {
|
||
...(managedNodeForm.value.metadata || {}),
|
||
updated_from: "ops-center"
|
||
}
|
||
});
|
||
const savedNode = response.data?.node || {
|
||
node_code: managedNodeForm.value.node_code.trim(),
|
||
region: managedNodeForm.value.region,
|
||
role: managedNodeForm.value.role,
|
||
ssh_host: managedNodeForm.value.ssh_host.trim(),
|
||
ssh_port: managedNodeForm.value.ssh_port,
|
||
ssh_user: managedNodeForm.value.ssh_user.trim(),
|
||
auth_mode: managedNodeForm.value.auth_mode,
|
||
deploy_channel: managedNodeForm.value.deploy_channel,
|
||
is_enabled: managedNodeForm.value.is_enabled,
|
||
metadata: { ...(managedNodeForm.value.metadata || {}) },
|
||
is_managed: true
|
||
};
|
||
const postAction = managedNodePostAction.value;
|
||
managedNodeVisible.value = false;
|
||
await Promise.all([loadNodes(), loadOverview()]);
|
||
ElMessage.success(response.message || "托管节点已保存");
|
||
if (postAction === "bootstrap-plan") {
|
||
openAgentTokenDialog(savedNode);
|
||
await issueAgentToken();
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "保存托管节点失败");
|
||
} finally {
|
||
loading.value.nodeSave = false;
|
||
}
|
||
};
|
||
|
||
const createActionTemplateJobs = async () => {
|
||
if (!actionTemplateForm.value.template_key) {
|
||
ElMessage.warning("请先选择动作模板");
|
||
return;
|
||
}
|
||
if (!(actionTemplateForm.value.target_node_codes || []).length) {
|
||
ElMessage.warning("请至少选择一个目标节点");
|
||
return;
|
||
}
|
||
if (["high", "critical"].includes(String(selectedActionTemplate.value.risk_level || "").trim())) {
|
||
const preview = await requestActionTemplatePreview({ silent: true, showToast: false });
|
||
if (preview?.blocked) {
|
||
ElMessage.warning("当前动作预检存在阻断项,请先处理后再创建任务");
|
||
return;
|
||
}
|
||
}
|
||
loading.value.batchJobCreate = true;
|
||
try {
|
||
const response = await createJobBatchFromTemplate({
|
||
templateKey: actionTemplateForm.value.template_key,
|
||
targetNodeCodes: actionTemplateForm.value.target_node_codes,
|
||
executionMode: actionTemplateForm.value.execution_mode,
|
||
autoApprove: actionTemplateForm.value.auto_approve,
|
||
requestedBy: String(actionTemplateDefaults.value.requested_by || "web-ui"),
|
||
payload: { ...(actionTemplateForm.value.payload || {}) }
|
||
});
|
||
actionTemplateVisible.value = false;
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
refreshInspectionPanel(),
|
||
loadActivityStream(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
ElMessage.success(response.message || "批量运维任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建批量运维任务失败");
|
||
} finally {
|
||
loading.value.batchJobCreate = false;
|
||
}
|
||
};
|
||
|
||
const createJobBatchFromTemplate = async (options: {
|
||
templateKey: string;
|
||
targetNodeCodes: string[];
|
||
executionMode?: string;
|
||
autoApprove?: boolean;
|
||
requestedBy?: string;
|
||
payload?: GenericRecord;
|
||
}) => {
|
||
const {
|
||
templateKey,
|
||
targetNodeCodes,
|
||
executionMode = String(actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
autoApprove = true,
|
||
requestedBy = String(actionTemplateDefaults.value.requested_by || "web-ui"),
|
||
payload = {}
|
||
} = options;
|
||
return opsApi.createJobBatch({
|
||
template_key: templateKey,
|
||
target_node_codes: targetNodeCodes,
|
||
execution_mode: executionMode,
|
||
auto_approve: autoApprove,
|
||
requested_by: requestedBy,
|
||
payload: { ...(payload || {}) }
|
||
});
|
||
};
|
||
|
||
const buildActionTemplatePolicyPreviewPayload = () => ({
|
||
action: selectedActionTemplateAction.value,
|
||
target_type: "batch",
|
||
target_node_codes: [...(actionTemplateForm.value.target_node_codes || [])],
|
||
execution_mode: actionTemplateForm.value.execution_mode,
|
||
payload: { ...(actionTemplateForm.value.payload || {}) },
|
||
});
|
||
|
||
const requestActionTemplatePreview = async (
|
||
options: {
|
||
silent?: boolean;
|
||
showToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { silent = false, showToast = false } = options;
|
||
if (!selectedActionTemplateAction.value) {
|
||
if (!silent) {
|
||
ElMessage.warning("请先选择动作模板");
|
||
}
|
||
return null;
|
||
}
|
||
if (!(actionTemplateForm.value.target_node_codes || []).length) {
|
||
if (!silent) {
|
||
ElMessage.warning("请至少选择一个目标节点");
|
||
}
|
||
return null;
|
||
}
|
||
loading.value.actionTemplatePreview = true;
|
||
try {
|
||
const response = await opsApi.previewPolicy(buildActionTemplatePolicyPreviewPayload());
|
||
actionTemplatePreview.value = response.data || {};
|
||
if (showToast) {
|
||
if (actionTemplatePreview.value.blocked) {
|
||
ElMessage.warning("动作预检完成,但存在阻断项");
|
||
} else if (actionTemplatePreview.value.approval_required || actionTemplatePreviewWarnings.value.length) {
|
||
ElMessage.warning("动作预检完成,存在需要关注的事项");
|
||
} else {
|
||
ElMessage.success("动作预检通过,可创建任务");
|
||
}
|
||
}
|
||
return actionTemplatePreview.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "动作预检失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.actionTemplatePreview = false;
|
||
}
|
||
};
|
||
|
||
const previewActionTemplateExecution = async () => {
|
||
await requestActionTemplatePreview({ showToast: true });
|
||
};
|
||
|
||
const previewPlaybookRequest = async (options: {
|
||
playbookKey: string;
|
||
targetNodeCodes: string[];
|
||
executionMode?: string;
|
||
autoApprove?: boolean;
|
||
requestedBy?: string;
|
||
}) => {
|
||
const {
|
||
playbookKey,
|
||
targetNodeCodes,
|
||
executionMode = "remote-agent",
|
||
autoApprove = true,
|
||
requestedBy = "web-ui",
|
||
} = options;
|
||
return opsApi.previewPlaybook({
|
||
playbook_key: playbookKey,
|
||
node_codes: targetNodeCodes,
|
||
execution_mode: executionMode,
|
||
auto_approve: autoApprove,
|
||
requested_by: requestedBy,
|
||
});
|
||
};
|
||
|
||
const executePlaybookRequest = async (options: {
|
||
playbookKey: string;
|
||
targetNodeCodes: string[];
|
||
executionMode?: string;
|
||
autoApprove?: boolean;
|
||
requestedBy?: string;
|
||
}) => {
|
||
const {
|
||
playbookKey,
|
||
targetNodeCodes,
|
||
executionMode = "remote-agent",
|
||
autoApprove = true,
|
||
requestedBy = "web-ui",
|
||
} = options;
|
||
return opsApi.executePlaybook({
|
||
playbook_key: playbookKey,
|
||
node_codes: targetNodeCodes,
|
||
execution_mode: executionMode,
|
||
auto_approve: autoApprove,
|
||
requested_by: requestedBy,
|
||
});
|
||
};
|
||
|
||
const requestPlaybookPreview = async (
|
||
options: {
|
||
showToast?: boolean;
|
||
silent?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { showToast = false, silent = false } = options;
|
||
if (!playbookForm.value.playbook_key) {
|
||
if (!silent) {
|
||
ElMessage.warning("请先选择 playbook");
|
||
}
|
||
return null;
|
||
}
|
||
if (!(playbookForm.value.target_node_codes || []).length) {
|
||
if (!silent) {
|
||
ElMessage.warning("请至少选择一个目标节点");
|
||
}
|
||
return null;
|
||
}
|
||
loading.value.playbookPreview = true;
|
||
try {
|
||
const response = await previewPlaybookRequest({
|
||
playbookKey: playbookForm.value.playbook_key,
|
||
targetNodeCodes: playbookForm.value.target_node_codes,
|
||
executionMode: playbookForm.value.execution_mode,
|
||
autoApprove: playbookForm.value.auto_approve,
|
||
requestedBy: "web-ui/playbook-preview",
|
||
});
|
||
playbookPreview.value = response.data || {};
|
||
if (showToast) {
|
||
ElMessage.success(response.message || "playbook 预览生成成功");
|
||
}
|
||
return playbookPreview.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "playbook 预览失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.playbookPreview = false;
|
||
}
|
||
};
|
||
|
||
const previewPlaybookExecution = async () => {
|
||
await requestPlaybookPreview({ showToast: true });
|
||
};
|
||
|
||
const createPlaybookJobs = async () => {
|
||
if (!playbookForm.value.playbook_key) {
|
||
ElMessage.warning("请先选择 playbook");
|
||
return;
|
||
}
|
||
if (!(playbookForm.value.target_node_codes || []).length) {
|
||
ElMessage.warning("请至少选择一个目标节点");
|
||
return;
|
||
}
|
||
loading.value.playbookExecute = true;
|
||
try {
|
||
const response = await executePlaybookRequest({
|
||
playbookKey: playbookForm.value.playbook_key,
|
||
targetNodeCodes: playbookForm.value.target_node_codes,
|
||
executionMode: playbookForm.value.execution_mode,
|
||
autoApprove: playbookForm.value.auto_approve,
|
||
requestedBy: "web-ui/playbook",
|
||
});
|
||
playbookVisible.value = false;
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
refreshInspectionPanel(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
loadNodes(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
ElMessage.success(response.message || "playbook 已展开为标准运维任务");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建 playbook 任务失败");
|
||
} finally {
|
||
loading.value.playbookExecute = false;
|
||
}
|
||
};
|
||
|
||
const openAgentTokenDialog = (row?: GenericRecord) => {
|
||
lastIssuedToken.value = {};
|
||
agentBootstrapPlan.value = {};
|
||
agentHandoverPayload.value = {};
|
||
agentTokenForm.value = {
|
||
node_code: "",
|
||
expires_in_hours: 72,
|
||
issued_by: "web-ui",
|
||
node_role: "",
|
||
node_region: "",
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
root_dir: "/opt/domaincheck"
|
||
};
|
||
if (row?.node_code) {
|
||
agentTokenForm.value.node_code = String(row.node_code || "");
|
||
agentTokenForm.value.node_role = String(row.role || "");
|
||
agentTokenForm.value.node_region = String(row.region || "");
|
||
}
|
||
agentTokenVisible.value = true;
|
||
void refreshAgentOnboardingState({ silent: true });
|
||
};
|
||
|
||
const requestRolloutPreview = async (
|
||
options: {
|
||
silent?: boolean;
|
||
showToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { silent = false, showToast = false } = options;
|
||
const nodeCodes = splitTextList(rolloutForm.value.node_codes_text);
|
||
if (!nodeCodes.length && !rolloutForm.value.region && !rolloutForm.value.role) {
|
||
if (!silent) {
|
||
ElMessage.warning("请至少填写目标节点,或选择地域/角色过滤条件");
|
||
}
|
||
return null;
|
||
}
|
||
loading.value.rolloutPreview = true;
|
||
try {
|
||
const response = await opsApi.previewPolicy(
|
||
buildPolicyPreviewPayload({
|
||
releaseId: Number(rolloutTargetReleaseId.value || 0),
|
||
targetSelector: buildRolloutTargetSelector(),
|
||
policy: buildRolloutPolicy()
|
||
})
|
||
);
|
||
rolloutPreview.value = response.data || {};
|
||
if (showToast) {
|
||
if (rolloutPreview.value.blocked) {
|
||
ElMessage.warning("预检完成,但存在阻断项");
|
||
} else if ((rolloutPreview.value.warnings || []).length || rolloutPreview.value.approval_required) {
|
||
ElMessage.warning("预检完成,存在需要关注的事项");
|
||
} else {
|
||
ElMessage.success("预检通过,可推进 rollout");
|
||
}
|
||
}
|
||
return rolloutPreview.value;
|
||
} catch (error: any) {
|
||
if (!silent) {
|
||
ElMessage.error(error?.message || "Rollout 预检失败");
|
||
}
|
||
return null;
|
||
} finally {
|
||
loading.value.rolloutPreview = false;
|
||
}
|
||
};
|
||
|
||
const previewRollout = async () => {
|
||
await requestRolloutPreview({ showToast: true });
|
||
};
|
||
|
||
const buildAdvanceRolloutPreviewPayload = (row: GenericRecord) => {
|
||
const targetNodes = Array.isArray(row?.target_nodes) ? row.target_nodes : [];
|
||
const launchedNodeCodes = new Set(
|
||
(row?.result_summary?.launched_target_codes || []).map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||
);
|
||
const configuredBatchSize = Math.max(1, Number(row?.policy?.batch_size || 1));
|
||
const remainingTargets = targetNodes.filter((item: GenericRecord) => {
|
||
const nodeCode = String(item?.node_code || "").trim();
|
||
return nodeCode && !launchedNodeCodes.has(nodeCode);
|
||
});
|
||
const nextBatchTargets = remainingTargets.slice(0, configuredBatchSize);
|
||
const nextBatchNodeCodes = nextBatchTargets.map((item: GenericRecord) => String(item?.node_code || "").trim()).filter(Boolean);
|
||
if (!nextBatchNodeCodes.length) {
|
||
return null;
|
||
}
|
||
const normalizedBatchSize = nextBatchNodeCodes.length;
|
||
return buildPolicyPreviewPayload({
|
||
releaseId: Number(row?.release_id || selectedReleaseId.value || 0),
|
||
targetSelector: {
|
||
node_codes: nextBatchNodeCodes,
|
||
only_enabled: true,
|
||
only_online: true,
|
||
only_effective_workers: false
|
||
},
|
||
policy: {
|
||
...(row?.policy || {}),
|
||
execution_mode: String(row?.policy?.execution_mode || "remote-agent"),
|
||
first_batch_size: normalizedBatchSize,
|
||
batch_size: normalizedBatchSize
|
||
}
|
||
});
|
||
};
|
||
|
||
const buildRolloutGateDialogText = (gate: GenericRecord, extras: string[] = []) => {
|
||
const parts: string[] = [];
|
||
const statusLabel = String(gate?.status_label || "").trim();
|
||
const summary = String(gate?.summary || "").trim();
|
||
if (statusLabel) {
|
||
parts.push(`门禁状态:${statusLabel}`);
|
||
}
|
||
if (summary) {
|
||
parts.push(summary);
|
||
}
|
||
const blockingReasons = (gate?.blocking_reasons || []).slice(0, 3);
|
||
if (blockingReasons.length) {
|
||
parts.push(`阻断项:${blockingReasons.join(";")}`);
|
||
}
|
||
const warningReasons = (gate?.warning_reasons || []).slice(0, 3);
|
||
if (warningReasons.length) {
|
||
parts.push(`注意事项:${warningReasons.join(";")}`);
|
||
}
|
||
const recommendations = (gate?.recommendations || []).slice(0, 3);
|
||
if (recommendations.length) {
|
||
parts.push(`建议:${recommendations.join(";")}`);
|
||
}
|
||
for (const item of extras) {
|
||
const text = String(item || "").trim();
|
||
if (text) {
|
||
parts.push(text);
|
||
}
|
||
}
|
||
return parts.filter(Boolean).join("\n\n");
|
||
};
|
||
|
||
const issueAgentToken = async () => {
|
||
if (!agentTokenForm.value.node_code.trim()) {
|
||
ElMessage.warning("请先填写节点编码");
|
||
return;
|
||
}
|
||
loading.value.tokenIssue = true;
|
||
try {
|
||
const response = await opsApi.buildNodeHandoverBootstrapPlan(agentTokenForm.value.node_code.trim(), {
|
||
issued_by: agentTokenForm.value.issued_by.trim() || "web-ui",
|
||
expires_in_hours: agentTokenForm.value.expires_in_hours,
|
||
control_plane_base_url: agentTokenForm.value.control_plane_base_url.trim() || controlPlaneBaseUrl.value,
|
||
root_dir: agentTokenForm.value.root_dir.trim() || "/opt/domaincheck",
|
||
metadata: {
|
||
issued_from: "ops-center-bootstrap-plan",
|
||
node_role: agentTokenForm.value.node_role || "",
|
||
node_region: agentTokenForm.value.node_region || ""
|
||
}
|
||
});
|
||
lastIssuedToken.value = response.data || {};
|
||
agentBootstrapPlan.value = response.data?.bootstrap_plan || {};
|
||
agentHandoverPayload.value = response.data?.handover || agentHandoverPayload.value;
|
||
onboardingState.value = response.data?.onboarding || onboardingState.value;
|
||
await Promise.all([loadNodes(), loadOverview()]);
|
||
ElMessage.success(response.message || "接入方案生成成功");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "接入方案生成失败");
|
||
} finally {
|
||
loading.value.tokenIssue = false;
|
||
}
|
||
};
|
||
|
||
const previewAgentOnboardingRecovery = async () => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
loading.value.onboardingRecoveryPreview = true;
|
||
try {
|
||
const response = await opsApi.previewNodeOnboardingRecovery(agentTargetNodeCode.value, {
|
||
requested_by: "web-ui/onboarding-recovery-preview",
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
});
|
||
const recoveryAction = String(response.data?.recovery_action || "").trim();
|
||
const decision = response.data?.recovery_decision || {};
|
||
if (recoveryAction === "bootstrap_run") {
|
||
openNodeScopedPlaybookDialog("onboarding.bootstrap");
|
||
} else if (recoveryAction === "run_acceptance") {
|
||
openNodeScopedPlaybookDialog("onboarding.acceptance");
|
||
}
|
||
ElMessage.success(response.message || String(decision.summary || "自动收口预览已生成"));
|
||
await refreshAgentOnboardingState({ silent: true });
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "自动收口预览失败");
|
||
} finally {
|
||
loading.value.onboardingRecoveryPreview = false;
|
||
}
|
||
};
|
||
|
||
const executeAgentOnboardingRecovery = async () => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
loading.value.onboardingRecoveryExecute = true;
|
||
try {
|
||
const response = await opsApi.executeNodeOnboardingRecovery(agentTargetNodeCode.value, {
|
||
requested_by: "web-ui/onboarding-recovery",
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
});
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
loadActivityStream(),
|
||
loadPlaybookRuns(),
|
||
loadNodes(),
|
||
refreshAgentOnboardingState({ silent: true }),
|
||
]);
|
||
ElMessage.success(response.message || "自动收口任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "执行自动收口失败");
|
||
} finally {
|
||
loading.value.onboardingRecoveryExecute = false;
|
||
}
|
||
};
|
||
|
||
const runRolloutReadinessRecovery = async (row: GenericRecord) => {
|
||
const nodeCode = String(row?.node_code || "").trim();
|
||
const recoveryAction = String(row?.recovery_action || "").trim();
|
||
if (!nodeCode || !recoveryAction || recoveryAction === "noop") {
|
||
ElMessage.info("当前节点没有可执行的接管恢复动作");
|
||
return;
|
||
}
|
||
loading.value.rolloutPreviewActionKey = `recovery:${nodeCode}`;
|
||
try {
|
||
const response = await opsApi.executeNodeOnboardingRecovery(nodeCode, {
|
||
requested_by: "web-ui/release-rollout-readiness",
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
});
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadNodes(),
|
||
loadReleaseLaunchpad({ silent: true }),
|
||
requestRolloutPreview({ silent: true }),
|
||
]);
|
||
ElMessage.success(response.message || `${nodeCode} 自动收口任务已创建`);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "执行节点接管恢复失败");
|
||
} finally {
|
||
loading.value.rolloutPreviewActionKey = "";
|
||
}
|
||
};
|
||
|
||
const startNodeHandoverFlow = (row?: GenericRecord) => {
|
||
if (!row?.node_code) {
|
||
ElMessage.warning("当前没有可接管的目标节点");
|
||
return;
|
||
}
|
||
if (row.is_managed) {
|
||
openAgentTokenDialog(row);
|
||
return;
|
||
}
|
||
openManagedNodeDialog(row, { generateBootstrapAfterSave: true });
|
||
};
|
||
|
||
const queueAgentOnboardingAction = async (templateKey: string, payload: GenericRecord = {}) => {
|
||
if (!agentTargetNodeCode.value) {
|
||
throw new Error("当前没有可执行的目标节点");
|
||
}
|
||
if (!agentOnboardingReadyForOps.value) {
|
||
throw new Error("请先等待节点完成接管,再发起快捷动作");
|
||
}
|
||
return opsApi.createJobBatch({
|
||
template_key: templateKey,
|
||
target_node_codes: [agentTargetNodeCode.value],
|
||
execution_mode: "remote-agent",
|
||
auto_approve: true,
|
||
requested_by: "web-ui/onboarding",
|
||
payload
|
||
});
|
||
};
|
||
|
||
const runAgentOnboardingQuickAction = async (templateKey: string, payload: GenericRecord = {}) => {
|
||
const actionKeySuffix = payload.service_name ? `:${payload.service_name}` : "";
|
||
loading.value.onboardingActionKey = `${templateKey}${actionKeySuffix}`;
|
||
try {
|
||
const response = await queueAgentOnboardingAction(templateKey, payload);
|
||
await Promise.all([loadJobs(), loadOverview(), loadActivityStream(), loadNodes()]);
|
||
ElMessage.success(response.message || "快捷运维任务已创建");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "快捷运维任务创建失败");
|
||
} finally {
|
||
loading.value.onboardingActionKey = "";
|
||
}
|
||
};
|
||
|
||
const persistOpsWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
|
||
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 loadOverview();
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "更新远端日志回传开关失败");
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const updateOpsWorkerLogSync = async (enabled: boolean, mode: "key" | "full") => {
|
||
workerLogSyncLoading.value = true;
|
||
pendingWorkerLogMode.value = enabled ? mode : "off";
|
||
try {
|
||
const actionCode = !enabled
|
||
? "disable_log_sync"
|
||
: mode === "full"
|
||
? "enable_log_sync_full"
|
||
: "enable_log_sync_key";
|
||
await runDriverActionCode(actionCode, [], {});
|
||
} finally {
|
||
workerLogSyncLoading.value = false;
|
||
pendingWorkerLogMode.value = "off";
|
||
}
|
||
};
|
||
|
||
const runAgentOnboardingAcceptanceSequence = async () => {
|
||
if (!agentOnboardingReadyForOps.value) {
|
||
ElMessage.warning("请先等待节点完成接管,再执行一键验收");
|
||
return;
|
||
}
|
||
loading.value.onboardingSequence = true;
|
||
try {
|
||
const recommendation = recommendPlaybookExecutionMode("onboarding.acceptance", [agentTargetNodeCode.value]);
|
||
const response = await executePlaybookRequest({
|
||
playbookKey: "onboarding.acceptance",
|
||
targetNodeCodes: [agentTargetNodeCode.value],
|
||
executionMode: recommendation.recommendedMode,
|
||
autoApprove: true,
|
||
requestedBy: "web-ui/onboarding-acceptance",
|
||
});
|
||
await Promise.all([loadJobs(), loadOverview(), loadPlaybookRuns(), loadActivityStream(), loadNodes()]);
|
||
ElMessage.success(response.message || `已为节点 ${agentTargetNodeCode.value} 创建接管验收编排`);
|
||
} catch (error: any) {
|
||
await Promise.all([loadJobs(), loadOverview(), loadPlaybookRuns(), loadActivityStream(), loadNodes()]);
|
||
ElMessage.error(error?.message || "一键验收任务创建失败");
|
||
} finally {
|
||
loading.value.onboardingSequence = false;
|
||
}
|
||
};
|
||
|
||
const openPrefilledActionTemplateDialog = (
|
||
templateKey: string,
|
||
targetNodeCodes: string[],
|
||
payload: GenericRecord = {}
|
||
) => {
|
||
const normalizedNodeCodes = Array.from(
|
||
new Set((targetNodeCodes || []).map((item) => String(item || "").trim()).filter(Boolean))
|
||
);
|
||
if (!normalizedNodeCodes.length) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
const template = actionTemplates.value.find((item) => String(item.key || "").trim() === templateKey);
|
||
if (!template) {
|
||
ElMessage.warning("当前模板尚未加载,请先刷新页面");
|
||
return;
|
||
}
|
||
applyActionTemplate(template, { prefillPayload: payload });
|
||
actionTemplateForm.value.target_node_codes = normalizedNodeCodes;
|
||
syncActionTemplateExecutionModeRecommendation({ force: true });
|
||
actionTemplateForm.value.auto_approve = Boolean(template.default_auto_approve);
|
||
actionTemplateVisible.value = true;
|
||
};
|
||
|
||
const buildReleaseDeployTemplatePayload = (release: GenericRecord) => ({
|
||
release_version: String(release?.release_version || ""),
|
||
artifact_url: String(release?.artifact_url || ""),
|
||
checksum: String(release?.checksum || ""),
|
||
});
|
||
|
||
const normalizeDriverNodeCodes = (nodeCodes: unknown): string[] =>
|
||
Array.from(new Set((Array.isArray(nodeCodes) ? nodeCodes : []).map((item) => String(item || "").trim()).filter(Boolean)));
|
||
|
||
const normalizeExecutionModes = (rawModes: unknown, fallbackMode = "remote-agent") => {
|
||
const normalizedModes = Array.from(
|
||
new Set((Array.isArray(rawModes) ? rawModes : []).map((item) => String(item || "").trim()).filter(Boolean))
|
||
);
|
||
return normalizedModes.length ? normalizedModes : [String(fallbackMode || "remote-agent").trim() || "remote-agent"];
|
||
};
|
||
|
||
const executionModeLabel = (mode: string) => {
|
||
const normalizedMode = String(mode || "").trim();
|
||
if (normalizedMode === "ssh") return "SSH";
|
||
if (normalizedMode === "control-plane") return "控制面";
|
||
if (normalizedMode === "local-runtime") return "本机运行时";
|
||
if (normalizedMode === "remote-agent") return "远端 Agent";
|
||
return normalizedMode || "远端 Agent";
|
||
};
|
||
|
||
const normalizeExecutionModeOption = (rawOption: unknown): { value: string; label: string } | null => {
|
||
const option = rawOption as GenericRecord;
|
||
const value = String(option?.value || "").trim();
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
return {
|
||
value,
|
||
label: String(option?.label || "").trim() || executionModeLabel(value),
|
||
};
|
||
};
|
||
|
||
const resolveExecutionModeOptions = (rawOptions: unknown, rawModes: unknown, fallbackMode = "remote-agent") => {
|
||
const normalizedOptions = (Array.isArray(rawOptions) ? rawOptions : [])
|
||
.map((item) => normalizeExecutionModeOption(item))
|
||
.filter((item): item is { value: string; label: string } => Boolean(item));
|
||
if (normalizedOptions.length) {
|
||
return normalizedOptions;
|
||
}
|
||
return normalizeExecutionModes(rawModes, fallbackMode).map((value) => ({
|
||
value,
|
||
label: executionModeLabel(value),
|
||
}));
|
||
};
|
||
|
||
const evaluateExecutionModeAvailability = (
|
||
mode: string,
|
||
nodeCodes: string[] = []
|
||
) => {
|
||
const normalizedMode = String(mode || "").trim();
|
||
const normalizedNodeCodes = normalizeDriverNodeCodes(nodeCodes || []);
|
||
if (!normalizedMode) {
|
||
return { enabled: false, reason: "当前执行方式为空。" };
|
||
}
|
||
if (!normalizedNodeCodes.length) {
|
||
return { enabled: true, reason: "" };
|
||
}
|
||
if (normalizedMode === "control-plane") {
|
||
return { enabled: true, reason: "" };
|
||
}
|
||
if (
|
||
normalizedMode === "local-runtime" &&
|
||
currentOpsNodeCode.value &&
|
||
normalizedNodeCodes.every((nodeCode) => nodeCode === currentOpsNodeCode.value)
|
||
) {
|
||
return { enabled: true, reason: "" };
|
||
}
|
||
|
||
const sourceMap = new Map(
|
||
collectExecutionRecommendationNodeSource()
|
||
.map((item) => [String(item.node_code || "").trim(), item] as const)
|
||
.filter(([nodeCode]) => Boolean(nodeCode))
|
||
);
|
||
const resolvedNodes = normalizedNodeCodes
|
||
.map((nodeCode) => sourceMap.get(nodeCode))
|
||
.filter((item): item is GenericRecord => Boolean(item));
|
||
const allResolved = resolvedNodes.length === normalizedNodeCodes.length;
|
||
|
||
if (normalizedMode === "local-runtime") {
|
||
return {
|
||
enabled: false,
|
||
reason: currentOpsNodeCode.value
|
||
? "仅当前控制面本机节点支持本机运行时。"
|
||
: "当前还未识别控制面本机节点,暂不建议走本机运行时。",
|
||
};
|
||
}
|
||
if (normalizedMode === "remote-agent") {
|
||
return {
|
||
enabled: allResolved && resolvedNodes.every((item) => Boolean(item.is_managed) && item.is_enabled !== false && Boolean(item.is_agent_online)),
|
||
reason: "目标节点还没有全部满足已纳管 + 已启用 + Agent 在线。",
|
||
};
|
||
}
|
||
if (normalizedMode === "ssh") {
|
||
return {
|
||
enabled: allResolved && resolvedNodes.every((item) => Boolean(item.is_managed) && item.is_enabled !== false && Boolean(item.has_ssh_access)),
|
||
reason: "目标节点还没有全部满足已纳管 + 已启用 + SSH 已备好。",
|
||
};
|
||
}
|
||
return { enabled: true, reason: "" };
|
||
};
|
||
|
||
const resolveAvailableExecutionModeOptions = (
|
||
rawOptions: unknown,
|
||
rawModes: unknown,
|
||
fallbackMode = "remote-agent",
|
||
nodeCodes: string[] = []
|
||
) =>
|
||
resolveExecutionModeOptions(rawOptions, rawModes, fallbackMode).map((item) => {
|
||
const availability = evaluateExecutionModeAvailability(item.value, nodeCodes);
|
||
return {
|
||
...item,
|
||
disabled: !availability.enabled,
|
||
reason: availability.enabled ? "" : String(availability.reason || "").trim(),
|
||
label: availability.enabled ? item.label : `${item.label}(当前不可用)`,
|
||
};
|
||
});
|
||
|
||
const executionModesText = (rawModes: unknown, fallbackMode = "remote-agent", rawOptions: unknown = []) =>
|
||
resolveExecutionModeOptions(rawOptions, rawModes, fallbackMode)
|
||
.map((item) => item.label)
|
||
.join(" / ");
|
||
|
||
const defaultExecutionModeLabel = (record: GenericRecord, fallbackMode = "remote-agent") =>
|
||
String(record.default_execution_mode_label || "").trim() ||
|
||
executionModeLabel(String(record.default_execution_mode || fallbackMode).trim() || fallbackMode);
|
||
|
||
const executionModeSummaryText = (record: GenericRecord, fallbackMode = "remote-agent") => {
|
||
const defaultMode = String(record.default_execution_mode || fallbackMode).trim() || fallbackMode;
|
||
const supportedText = executionModesText(record.execution_modes || [], defaultMode, record.execution_mode_options || []);
|
||
return `默认 ${defaultExecutionModeLabel(record, fallbackMode)} · 支持 ${supportedText || "-"}`;
|
||
};
|
||
|
||
const playbookRunExecutionModeLabel = (row: GenericRecord) =>
|
||
String(row.execution_mode_label || "").trim() || executionModeLabel(String(row.execution_mode || "").trim());
|
||
|
||
const collectExecutionRecommendationNodeSource = (): GenericRecord[] => {
|
||
if (nodes.value.length) {
|
||
return [...nodes.value];
|
||
}
|
||
return clusterNodes.value.map((item) => ({
|
||
node_code: item.node_code,
|
||
region: item.region,
|
||
role: item.role,
|
||
is_managed: false,
|
||
is_enabled: true,
|
||
is_agent_online: false,
|
||
has_ssh_access: false,
|
||
current_load: item.current_load,
|
||
status: item.status,
|
||
cluster_status: item.status,
|
||
metadata: {
|
||
imported_from_cluster_fallback: true,
|
||
last_heartbeat_at: item.last_heartbeat_at,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const currentOpsNodeCode = computed(() =>
|
||
String(opsLinkSnapshot.value.control_plane?.node_code || "").trim()
|
||
);
|
||
|
||
const recommendExecutionModeForNodes = (options: {
|
||
nodeCodes?: string[];
|
||
supportedModes?: string[];
|
||
defaultMode?: string;
|
||
}) => {
|
||
const normalizedNodeCodes = normalizeDriverNodeCodes(options.nodeCodes || []);
|
||
const normalizedSupportedModes = normalizeExecutionModes(options.supportedModes || [], options.defaultMode || "remote-agent");
|
||
const normalizedDefaultMode = normalizedSupportedModes.includes(String(options.defaultMode || "").trim())
|
||
? String(options.defaultMode || "").trim()
|
||
: normalizedSupportedModes[0];
|
||
if (normalizedSupportedModes.length === 1) {
|
||
const recommendedMode = normalizedSupportedModes[0];
|
||
return {
|
||
recommendedMode,
|
||
recommendedModeLabel: executionModeLabel(recommendedMode),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes: [] as GenericRecord[],
|
||
reason: `当前入口只支持 ${executionModeLabel(recommendedMode)}。`,
|
||
};
|
||
}
|
||
if (!normalizedNodeCodes.length) {
|
||
return {
|
||
recommendedMode: normalizedDefaultMode,
|
||
recommendedModeLabel: executionModeLabel(normalizedDefaultMode),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes: [] as GenericRecord[],
|
||
reason: `当前还没指定目标节点,先按 ${executionModeLabel(normalizedDefaultMode)} 作为默认执行路径。`,
|
||
};
|
||
}
|
||
if (
|
||
normalizedSupportedModes.includes("local-runtime") &&
|
||
currentOpsNodeCode.value &&
|
||
normalizedNodeCodes.every((nodeCode) => nodeCode === currentOpsNodeCode.value)
|
||
) {
|
||
return {
|
||
recommendedMode: "local-runtime",
|
||
recommendedModeLabel: executionModeLabel("local-runtime"),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes: [] as GenericRecord[],
|
||
reason: "目标节点就是当前控制面本机节点,建议直接走本机运行时。",
|
||
};
|
||
}
|
||
|
||
const sourceMap = new Map(
|
||
collectExecutionRecommendationNodeSource()
|
||
.map((item) => [String(item.node_code || "").trim(), item] as const)
|
||
.filter(([nodeCode]) => Boolean(nodeCode))
|
||
);
|
||
const resolvedNodes = normalizedNodeCodes
|
||
.map((nodeCode) => sourceMap.get(nodeCode))
|
||
.filter((item): item is GenericRecord => Boolean(item));
|
||
const remoteAgentReadyNodes = resolvedNodes.filter(
|
||
(item) => Boolean(item.is_managed) && item.is_enabled !== false && Boolean(item.is_agent_online)
|
||
);
|
||
const sshReadyNodes = resolvedNodes.filter(
|
||
(item) => Boolean(item.is_managed) && item.is_enabled !== false && Boolean(item.has_ssh_access)
|
||
);
|
||
const allResolved = resolvedNodes.length === normalizedNodeCodes.length;
|
||
|
||
if (normalizedSupportedModes.includes("remote-agent") && allResolved && remoteAgentReadyNodes.length === resolvedNodes.length) {
|
||
return {
|
||
recommendedMode: "remote-agent",
|
||
recommendedModeLabel: executionModeLabel("remote-agent"),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes,
|
||
reason: "目标节点都已满足 Agent 在线,建议直接走 remote-agent。",
|
||
};
|
||
}
|
||
if (normalizedSupportedModes.includes("ssh") && allResolved && sshReadyNodes.length === resolvedNodes.length) {
|
||
return {
|
||
recommendedMode: "ssh",
|
||
recommendedModeLabel: executionModeLabel("ssh"),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes,
|
||
reason: "目标节点都已满足 SSH 入口,建议切到 SSH。",
|
||
};
|
||
}
|
||
|
||
const missingNodeCount = normalizedNodeCodes.length - resolvedNodes.length;
|
||
const fallbackReason = missingNodeCount > 0
|
||
? `当前有 ${missingNodeCount} 台目标节点还没进入托管视角,先按 ${executionModeLabel(normalizedDefaultMode)} 保守处理。`
|
||
: `当前目标节点执行能力不一致,先按 ${executionModeLabel(normalizedDefaultMode)} 保守处理。`;
|
||
return {
|
||
recommendedMode: normalizedDefaultMode,
|
||
recommendedModeLabel: executionModeLabel(normalizedDefaultMode),
|
||
supportedModes: normalizedSupportedModes,
|
||
resolvedNodes,
|
||
reason: fallbackReason,
|
||
};
|
||
};
|
||
|
||
const recommendActionTemplateExecutionMode = (templateKey: string, nodeCodes: string[]) => {
|
||
const template = findActionTemplateByKey(templateKey);
|
||
return recommendExecutionModeForNodes({
|
||
nodeCodes,
|
||
supportedModes: normalizeExecutionModes(
|
||
template.execution_modes || [],
|
||
String(template.default_execution_mode || actionTemplateDefaults.value.execution_mode || "remote-agent")
|
||
),
|
||
defaultMode: String(template.default_execution_mode || actionTemplateDefaults.value.execution_mode || "remote-agent"),
|
||
});
|
||
};
|
||
|
||
const recommendPlaybookExecutionMode = (playbookKey: string, nodeCodes: string[]) => {
|
||
const playbook = findPlaybookByKey(playbookKey);
|
||
return recommendExecutionModeForNodes({
|
||
nodeCodes,
|
||
supportedModes: normalizeExecutionModes(
|
||
playbook.execution_modes || [],
|
||
String(playbook.default_execution_mode || "remote-agent")
|
||
),
|
||
defaultMode: String(playbook.default_execution_mode || "remote-agent"),
|
||
});
|
||
};
|
||
|
||
const chooseReleaseDeployTemplateForNode = (row?: GenericRecord) => {
|
||
const role = String(row?.role || row?.cluster_role || "").trim().toLowerCase();
|
||
if (role === "control") {
|
||
return "deploy.release.control";
|
||
}
|
||
if (role === "worker" || Boolean(row?.cluster_is_effective_worker) || Boolean(row?.is_effective_worker)) {
|
||
return "deploy.release.worker";
|
||
}
|
||
return "deploy.release.custom";
|
||
};
|
||
|
||
const openReleaseDeployTemplateDialog = (
|
||
release?: GenericRecord,
|
||
templateKey = "deploy.release.custom",
|
||
options: {
|
||
targetNodeCodes?: string[];
|
||
executionMode?: string;
|
||
} = {}
|
||
) => {
|
||
const { targetNodeCodes = [], executionMode = "" } = options;
|
||
const targetRelease =
|
||
release ||
|
||
releases.value.find((item) => Number(item.id) === Number(selectedReleaseId.value || 0)) ||
|
||
{};
|
||
if (!targetRelease?.id && !targetRelease?.release_version) {
|
||
ElMessage.warning("请先选择 Release");
|
||
return;
|
||
}
|
||
const template = actionTemplates.value.find((item) => String(item.key || "").trim() === String(templateKey || "").trim());
|
||
if (!template) {
|
||
ElMessage.warning("发布模板尚未加载,请先刷新页面");
|
||
return;
|
||
}
|
||
if (!String(targetRelease?.artifact_url || "").trim()) {
|
||
ElMessage.warning("当前 Release 还没有 artifact_url,已打开模板,请补齐发布包地址后再创建任务");
|
||
}
|
||
selectedReleaseId.value = Number(targetRelease?.id || selectedReleaseId.value || 0) || selectedReleaseId.value;
|
||
applyActionTemplate(template, {
|
||
preserveTargets: false,
|
||
preservePayload: false,
|
||
prefillPayload: buildReleaseDeployTemplatePayload(targetRelease),
|
||
});
|
||
const normalizedTargetNodeCodes = normalizeDriverNodeCodes(targetNodeCodes);
|
||
actionTemplateForm.value.target_node_codes = normalizedTargetNodeCodes.length
|
||
? normalizedTargetNodeCodes
|
||
: buildReleaseDeployTemplatePrefillTargetNodeCodes(String(template.key || ""));
|
||
const resolvedExecutionMode =
|
||
String(executionMode || "").trim() ||
|
||
String(defaultRolloutExecution.value.recommended_mode || "").trim();
|
||
if (resolvedExecutionMode) {
|
||
actionTemplateForm.value.execution_mode = resolvedExecutionMode;
|
||
actionTemplateExecutionModeTouched.value = true;
|
||
} else {
|
||
syncActionTemplateExecutionModeRecommendation({ force: true });
|
||
}
|
||
actionTemplateVisible.value = true;
|
||
};
|
||
|
||
const handleReleaseDeployTemplateCommand = (templateKey: string, release?: GenericRecord) => {
|
||
openReleaseDeployTemplateDialog(release, templateKey || "deploy.release.custom");
|
||
};
|
||
|
||
const openNodeScopedActionTemplateDialog = (templateKey: string, payload: GenericRecord = {}) => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
openPrefilledActionTemplateDialog(templateKey, [agentTargetNodeCode.value], payload);
|
||
};
|
||
|
||
const openNodeScopedReleaseDeployDialog = () => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可发布的目标节点");
|
||
return;
|
||
}
|
||
const release = preferredNodeOpsRelease.value || {};
|
||
const releaseId = Number(release.id || 0);
|
||
if (!releaseId) {
|
||
ElMessage.warning("当前还没有可用 Release,请先创建 Release");
|
||
openReleaseDialog();
|
||
return;
|
||
}
|
||
openReleaseDeployTemplateDialog(
|
||
release,
|
||
chooseReleaseDeployTemplateForNode(agentTargetNode.value),
|
||
{
|
||
targetNodeCodes: [agentTargetNodeCode.value],
|
||
}
|
||
);
|
||
};
|
||
|
||
const openNodeScopedPlaybookDialog = (playbookKey: string) => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可操作的目标节点");
|
||
return;
|
||
}
|
||
const playbook = playbooks.value.find((item) => String(item.key || "").trim() === String(playbookKey || "").trim()) || {};
|
||
if (!playbook?.key) {
|
||
ElMessage.warning("当前 playbook 尚未加载,请先刷新页面");
|
||
return;
|
||
}
|
||
openPlaybookDialog(playbook, { targetNodeCodes: [agentTargetNodeCode.value] });
|
||
};
|
||
|
||
const openNodeScopedDiagnosticsDialog = () => {
|
||
openNodeScopedActionTemplateDialog("diagnostics.collect", { lines: 200 });
|
||
};
|
||
|
||
const findBulkDiagnosticsCard = (scopeKey: string) =>
|
||
bulkDiagnosticsCards.value.find((item) => item.key === scopeKey);
|
||
|
||
const openBulkActionTemplateDialog = (
|
||
scopeKey: string,
|
||
templateKey: string,
|
||
payload: GenericRecord = {}
|
||
) => {
|
||
const card = findBulkDiagnosticsCard(scopeKey);
|
||
if (!card) {
|
||
ElMessage.warning("当前快捷动作未准备好,请稍后再试");
|
||
return;
|
||
}
|
||
if (!card.nodeCodes.length) {
|
||
ElMessage.warning("当前没有符合条件的节点可用于批量巡检");
|
||
return;
|
||
}
|
||
const normalizedPayload =
|
||
templateKey === "logs.collect"
|
||
? {
|
||
...buildWorkerLogCollectPayload(Number(payload?.lines || 120)),
|
||
...(payload || {}),
|
||
}
|
||
: payload;
|
||
openPrefilledActionTemplateDialog(templateKey, card.nodeCodes, normalizedPayload);
|
||
if (card.excludedCount > 0) {
|
||
ElMessage.info(`本次已跳过 ${card.excludedCount} 台未纳管、未启用或 Agent 未在线的执行节点`);
|
||
}
|
||
};
|
||
|
||
const runStandardInspectionSequenceForNodes = async (
|
||
targetNodeCodes: string[],
|
||
options: {
|
||
requestedBy?: string;
|
||
successLabel?: string;
|
||
} = {}
|
||
) => {
|
||
const normalizedNodeCodes = Array.from(
|
||
new Set((targetNodeCodes || []).map((item) => String(item || "").trim()).filter(Boolean))
|
||
);
|
||
if (!normalizedNodeCodes.length) {
|
||
throw new Error("当前没有符合条件的节点可用于标准巡检");
|
||
}
|
||
const { requestedBy = "web-ui/inspection-sequence", successLabel = `${normalizedNodeCodes.length} 台节点` } = options;
|
||
const recommendation = recommendPlaybookExecutionMode("inspection.standard", normalizedNodeCodes);
|
||
const response = await executePlaybookRequest({
|
||
playbookKey: "inspection.standard",
|
||
targetNodeCodes: normalizedNodeCodes,
|
||
executionMode: recommendation.recommendedMode,
|
||
autoApprove: true,
|
||
requestedBy,
|
||
});
|
||
const createdJobs = (response.data?.results || []).reduce(
|
||
(sum: number, item: GenericRecord) => sum + Number(item?.data?.summary?.created_count || 0),
|
||
0
|
||
);
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
refreshInspectionPanel(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
loadNodes(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
ElMessage.success(response.message || `已为${successLabel}创建标准巡检,共 ${createdJobs} 条 ops job`);
|
||
};
|
||
|
||
const runBulkInspectionSequence = async (scopeKey: string) => {
|
||
const card = findBulkDiagnosticsCard(scopeKey);
|
||
if (!card) {
|
||
ElMessage.warning("当前快捷动作未准备好,请稍后再试");
|
||
return;
|
||
}
|
||
if (!card.nodeCodes.length) {
|
||
ElMessage.warning("当前没有符合条件的节点可用于标准巡检");
|
||
return;
|
||
}
|
||
loading.value.fleetActionKey = `sequence:${scopeKey}`;
|
||
try {
|
||
await runStandardInspectionSequenceForNodes(card.nodeCodes, {
|
||
requestedBy: `web-ui/fleet-inspection/${scopeKey}`,
|
||
successLabel: `${card.title}${card.excludedCount > 0 ? `(已跳过 ${card.excludedCount} 台未接管节点)` : ""}`
|
||
});
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建标准巡检任务失败");
|
||
} finally {
|
||
loading.value.fleetActionKey = "";
|
||
}
|
||
};
|
||
|
||
const runNodeScopedInspectionSequence = async () => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可巡检的目标节点");
|
||
return;
|
||
}
|
||
if (!agentOnboardingReadyForOps.value) {
|
||
ElMessage.warning("请先等待节点完成接管,再执行标准巡检");
|
||
return;
|
||
}
|
||
loading.value.nodeOpsSequence = true;
|
||
try {
|
||
await runStandardInspectionSequenceForNodes([agentTargetNodeCode.value], {
|
||
requestedBy: "web-ui/node-inspection-sequence",
|
||
successLabel: `节点 ${agentTargetNodeCode.value}`
|
||
});
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "创建标准巡检任务失败");
|
||
} finally {
|
||
loading.value.nodeOpsSequence = false;
|
||
}
|
||
};
|
||
|
||
const openDriverActionContractPreview = async (
|
||
card: GenericRecord,
|
||
options: {
|
||
sourceLabel?: string;
|
||
} = {}
|
||
) => {
|
||
const sourceLabel = String(options.sourceLabel || "ops-center").trim() || "ops-center";
|
||
if (!hasDriverActionContractPreview(card)) {
|
||
ElMessage.warning("当前动作还没有可展示的契约预览");
|
||
return;
|
||
}
|
||
loading.value.driverActionContractPreview = true;
|
||
try {
|
||
const response = await opsApi.resolveDriverAction(
|
||
buildResolvedDriverActionRequest(card, {
|
||
sourceLabel,
|
||
requestedBy: `web-ui/${sourceLabel}-preview`,
|
||
})
|
||
);
|
||
const preview = normalizeResolvedDriverActionContractPreview(response?.data || {}, sourceLabel);
|
||
if (preview && Object.keys(preview).length) {
|
||
activeDriverActionContractPreview.value = preview;
|
||
driverActionContractVisible.value = true;
|
||
return;
|
||
}
|
||
throw new Error("empty preview payload");
|
||
} catch (error: any) {
|
||
try {
|
||
const response = await opsApi.previewDriverAction(buildDriverActionContractPreviewRequest(card, sourceLabel));
|
||
activeDriverActionContractPreview.value = response?.data || buildDriverActionContractPreview(card, sourceLabel);
|
||
driverActionContractVisible.value = true;
|
||
ElMessage.warning(error?.message || "后端解析失败,已退回基础契约预览");
|
||
} catch (previewError: any) {
|
||
activeDriverActionContractPreview.value = buildDriverActionContractPreview(card, sourceLabel);
|
||
driverActionContractVisible.value = true;
|
||
ElMessage.warning(previewError?.message || error?.message || "后端预览失败,已使用本地兜底预览");
|
||
}
|
||
} finally {
|
||
loading.value.driverActionContractPreview = false;
|
||
}
|
||
};
|
||
const openCodexActionContractPreview = async (card: GenericRecord) => {
|
||
if (!String(card?.key || "").trim()) {
|
||
ElMessage.warning("当前动作缺少 entry_key,无法生成契约预览");
|
||
return;
|
||
}
|
||
loading.value.driverActionContractPreview = true;
|
||
try {
|
||
const response = await opsApi.resolveCodexAction(
|
||
buildCodexActionRequest(card, {
|
||
sourceLabel: "web-ui/codex-brief-preview",
|
||
requestedBy: "web-ui/codex-brief-preview",
|
||
})
|
||
);
|
||
activeDriverActionContractPreview.value = normalizeCodexActionContractPreview(response?.data || {}, "codex-brief");
|
||
driverActionContractVisible.value = true;
|
||
} catch (error: any) {
|
||
activeDriverActionContractPreview.value = buildDriverActionContractPreview(card, "codex-brief");
|
||
driverActionContractVisible.value = true;
|
||
ElMessage.warning(error?.message || "Codex 契约预览失败,已使用本地兜底预览");
|
||
} finally {
|
||
loading.value.driverActionContractPreview = false;
|
||
}
|
||
};
|
||
|
||
const openOverviewRecommendationContractPreview = async () => {
|
||
if (!recommendationPreviewable.value) {
|
||
ElMessage.warning("当前建议还没有可展示的契约预览");
|
||
return;
|
||
}
|
||
await openDriverActionContractPreview(buildOverviewRecommendationDriverEntry(), { sourceLabel: "overview-recommendation" });
|
||
};
|
||
|
||
const copyDriverActionContractRequest = async (section: "primary" | "secondary") => {
|
||
const preview = activeDriverActionContractPreview.value || {};
|
||
const payload = section === "secondary" ? preview.secondary?.request_payload : preview.primary?.request_payload;
|
||
await copyText(formatJsonBlock(payload || {}), `${section === "secondary" ? "次动作" : "主动作"}请求已复制`);
|
||
};
|
||
|
||
const runOverviewRecommendation = async (
|
||
options: {
|
||
secondary?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { secondary = false } = options;
|
||
if (!String(
|
||
secondary ? recommendation.value.secondary_action_code || "" : recommendation.value.primary_action_code || ""
|
||
).trim()) {
|
||
return;
|
||
}
|
||
loading.value.driverActionKey = secondary
|
||
? "overview-recommendation-secondary"
|
||
: "overview-recommendation-primary";
|
||
try {
|
||
await runResolvedDriverActionEntry(buildOverviewRecommendationDriverEntry(), {
|
||
secondary,
|
||
sourceLabel: "overview-recommendation",
|
||
requestedBy: "web-ui/overview-recommendation",
|
||
});
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const runDriverRecommendation = async (
|
||
card: Record<string, any>,
|
||
options: {
|
||
secondary?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { secondary = false } = options;
|
||
const action = secondary ? card?.secondaryAction : card?.primaryAction;
|
||
if (typeof action !== "function") {
|
||
return;
|
||
}
|
||
const actionKey = `${card?.key || "driver"}${secondary ? ":secondary" : ""}`;
|
||
loading.value.driverActionKey = actionKey;
|
||
try {
|
||
await action();
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const getResolvedDriverActionFallback = (
|
||
card: GenericRecord,
|
||
secondary: boolean,
|
||
payload: GenericRecord = {}
|
||
) => {
|
||
const preview = (payload?.preview || {}) as GenericRecord;
|
||
const gate = (payload?.gate || {}) as GenericRecord;
|
||
const section = ((secondary ? preview?.secondary : preview?.primary) || {}) as GenericRecord;
|
||
return {
|
||
preview,
|
||
gate,
|
||
section,
|
||
actionCode: String(
|
||
gate?.action_code ||
|
||
section?.action_code ||
|
||
(secondary ? card?.secondaryActionCode || card?.secondary_action_code : card?.primaryActionCode || card?.primary_action_code) ||
|
||
""
|
||
).trim(),
|
||
nodeCodes: normalizeDriverNodeCodes(
|
||
gate?.node_codes ||
|
||
section?.node_codes ||
|
||
(secondary
|
||
? card?.secondaryNodeCodes || card?.secondary_node_codes
|
||
: card?.primaryNodeCodes || card?.primary_node_codes) ||
|
||
[]
|
||
),
|
||
actionPayload: {
|
||
...(section?.request_payload?.action_payload || {}),
|
||
...(secondary
|
||
? card?.secondaryActionPayload || card?.secondary_action_payload || {}
|
||
: card?.primaryActionPayload || card?.primary_action_payload || {}),
|
||
},
|
||
};
|
||
};
|
||
|
||
const runResolvedDriverActionEntry = async (
|
||
card: GenericRecord,
|
||
options: {
|
||
secondary?: boolean;
|
||
sourceLabel?: string;
|
||
requestedBy?: string;
|
||
} = {}
|
||
) => {
|
||
const secondary = Boolean(options.secondary);
|
||
const sourceLabel = String(options.sourceLabel || "ops-center").trim() || "ops-center";
|
||
const requestedBy = String(options.requestedBy || "web-ui/ops-center").trim() || "web-ui/ops-center";
|
||
let response: GenericRecord | null = null;
|
||
try {
|
||
response = await opsApi.executeResolvedDriverAction(
|
||
buildResolvedDriverActionRequest(card, {
|
||
secondary,
|
||
sourceLabel,
|
||
requestedBy,
|
||
})
|
||
);
|
||
} catch (error: any) {
|
||
const fallback = getResolvedDriverActionFallback(card, secondary, (error?.data || {}) as GenericRecord);
|
||
const decision = String(fallback.gate?.decision || "").trim();
|
||
if (decision === "confirmation_required") {
|
||
activeDriverActionContractPreview.value = normalizeResolvedDriverActionContractPreview(error?.data || {}, sourceLabel);
|
||
driverActionContractVisible.value = true;
|
||
try {
|
||
await ElMessageBox.confirm(driverActionGatePromptText(fallback.gate), "确认执行驾驶动作", {
|
||
type: "warning",
|
||
confirmButtonText: "确认执行",
|
||
cancelButtonText: "先看契约",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
response = await opsApi.executeResolvedDriverAction(
|
||
buildResolvedDriverActionRequest(card, {
|
||
secondary,
|
||
confirm: true,
|
||
sourceLabel: `${sourceLabel}-confirmed`,
|
||
requestedBy: `${requestedBy}-confirmed`,
|
||
})
|
||
);
|
||
} else if (decision === "open_ui") {
|
||
if (fallback.actionCode) {
|
||
await runLocalDriverActionCode(fallback.actionCode, fallback.nodeCodes, fallback.actionPayload);
|
||
} else {
|
||
activeDriverActionContractPreview.value = normalizeResolvedDriverActionContractPreview(error?.data || {}, sourceLabel);
|
||
driverActionContractVisible.value = true;
|
||
}
|
||
return;
|
||
} else if (decision) {
|
||
activeDriverActionContractPreview.value = normalizeResolvedDriverActionContractPreview(error?.data || {}, sourceLabel);
|
||
driverActionContractVisible.value = true;
|
||
ElMessage.warning(error?.message || "当前动作暂时不能执行");
|
||
return;
|
||
} else if (String(card?.executorKind || card?.executor_kind || "").trim() === "runbook_sequence") {
|
||
const sequenceKey = String(card?.sequenceKey || card?.sequence_key || "").trim();
|
||
if (sequenceKey) {
|
||
const legacyResponse = await opsApi.executeRunbookSequence(sequenceKey, {
|
||
secondary,
|
||
requested_by: requestedBy,
|
||
});
|
||
const actionCode = String(legacyResponse?.data?.driver_action_code || "").trim();
|
||
const nodeCodes = normalizeDriverNodeCodes(legacyResponse?.data?.driver_node_codes || []);
|
||
const actionPayload = { ...(legacyResponse?.data?.driver_action_payload || {}) };
|
||
if (await handleDriverActionResponse(legacyResponse, actionCode, nodeCodes, actionPayload)) {
|
||
return;
|
||
}
|
||
if (actionCode) {
|
||
await runLocalDriverActionCode(actionCode, nodeCodes, actionPayload);
|
||
}
|
||
return;
|
||
}
|
||
} else if (fallback.actionCode) {
|
||
await runDriverActionCode(fallback.actionCode, fallback.nodeCodes, fallback.actionPayload);
|
||
return;
|
||
} else {
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const fallback = getResolvedDriverActionFallback(card, secondary, (response?.data || {}) as GenericRecord);
|
||
const executionResult = (response?.data?.execution_result || {}) as GenericRecord;
|
||
const actionCode = String(
|
||
fallback.gate?.action_code || executionResult?.action_code || executionResult?.driver_action_code || fallback.actionCode || ""
|
||
).trim();
|
||
const nodeCodes = normalizeDriverNodeCodes(
|
||
executionResult?.node_codes || fallback.gate?.node_codes || fallback.section?.node_codes || fallback.nodeCodes || []
|
||
);
|
||
const actionPayload = { ...fallback.actionPayload };
|
||
if (
|
||
await handleDriverActionResponse(
|
||
{
|
||
...response,
|
||
data: executionResult,
|
||
message: String(response?.message || "").trim(),
|
||
},
|
||
actionCode,
|
||
nodeCodes,
|
||
actionPayload
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
if (actionCode) {
|
||
await runLocalDriverActionCode(actionCode, nodeCodes, actionPayload);
|
||
}
|
||
};
|
||
|
||
const runDriverFeedEntry = async (
|
||
card: Record<string, any>,
|
||
options: {
|
||
secondary?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { secondary = false } = options;
|
||
const actionKey = `driver-feed-${card?.key || "entry"}-${secondary ? "secondary" : "primary"}`;
|
||
loading.value.driverActionKey = actionKey;
|
||
try {
|
||
if (!String(secondary ? card?.secondaryActionCode : card?.primaryActionCode).trim() && String(card?.executorKind || "").trim() !== "runbook_sequence") {
|
||
return;
|
||
}
|
||
await runResolvedDriverActionEntry(card, {
|
||
secondary,
|
||
sourceLabel: "driver-feed",
|
||
requestedBy: "web-ui/driver-feed",
|
||
});
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const runCodexBriefEntry = async (
|
||
card: Record<string, any>,
|
||
options: {
|
||
secondary?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { secondary = false } = options;
|
||
const actionKey = `codex-brief-${card?.key || "entry"}-${secondary ? "secondary" : "primary"}`;
|
||
loading.value.driverActionKey = actionKey;
|
||
try {
|
||
let response: GenericRecord | null = null;
|
||
try {
|
||
response = await opsApi.executeCodexAction(
|
||
buildCodexActionRequest(card, {
|
||
secondary,
|
||
sourceLabel: "web-ui/codex-brief",
|
||
requestedBy: "web-ui/codex-brief",
|
||
})
|
||
);
|
||
} catch (error: any) {
|
||
const gate = (error?.data?.gate || {}) as GenericRecord;
|
||
const decision = String(gate?.decision || "").trim();
|
||
const preview = (error?.data?.preview || {}) as GenericRecord;
|
||
const section = ((secondary ? preview?.secondary : preview?.primary) || {}) as GenericRecord;
|
||
const fallbackActionCode = String(
|
||
gate?.action_code || section?.action_code || (secondary ? card?.secondaryActionCode : card?.primaryActionCode) || ""
|
||
).trim();
|
||
const fallbackNodeCodes = normalizeDriverNodeCodes(
|
||
gate?.node_codes || section?.node_codes || (secondary ? card?.secondaryNodeCodes : card?.primaryNodeCodes) || []
|
||
);
|
||
const fallbackActionPayload = {
|
||
...(section?.request_payload?.action_payload || {}),
|
||
...(secondary ? card?.secondaryActionPayload : card?.primaryActionPayload),
|
||
};
|
||
if (decision === "confirmation_required") {
|
||
activeDriverActionContractPreview.value = normalizeCodexActionContractPreview(error?.data || {}, "codex-brief");
|
||
driverActionContractVisible.value = true;
|
||
try {
|
||
await ElMessageBox.confirm(driverActionGatePromptText(gate), "确认执行 Codex 动作", {
|
||
type: "warning",
|
||
confirmButtonText: "确认执行",
|
||
cancelButtonText: "先看契约",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
response = await opsApi.executeCodexAction(
|
||
buildCodexActionRequest(card, {
|
||
secondary,
|
||
confirm: true,
|
||
sourceLabel: "web-ui/codex-brief-confirmed",
|
||
requestedBy: "web-ui/codex-brief-confirmed",
|
||
})
|
||
);
|
||
} else if (decision === "open_ui") {
|
||
if (fallbackActionCode) {
|
||
await runLocalDriverActionCode(fallbackActionCode, fallbackNodeCodes, fallbackActionPayload);
|
||
} else {
|
||
activeDriverActionContractPreview.value = normalizeCodexActionContractPreview(error?.data || {}, "codex-brief");
|
||
driverActionContractVisible.value = true;
|
||
}
|
||
return;
|
||
} else {
|
||
activeDriverActionContractPreview.value = normalizeCodexActionContractPreview(error?.data || {}, "codex-brief");
|
||
driverActionContractVisible.value = true;
|
||
ElMessage.warning(error?.message || "当前动作暂时不能执行");
|
||
return;
|
||
}
|
||
}
|
||
const gate = (response?.data?.gate || {}) as GenericRecord;
|
||
const executionResult = (response?.data?.execution_result || {}) as GenericRecord;
|
||
const actionCode = String(gate?.action_code || executionResult?.action_code || executionResult?.driver_action_code || "").trim();
|
||
const preview = (response?.data?.preview || {}) as GenericRecord;
|
||
const section = ((secondary ? preview?.secondary : preview?.primary) || {}) as GenericRecord;
|
||
const nodeCodes = normalizeDriverNodeCodes(
|
||
executionResult?.node_codes ||
|
||
gate?.node_codes ||
|
||
section?.node_codes ||
|
||
(secondary ? card?.secondaryNodeCodes : card?.primaryNodeCodes) ||
|
||
[]
|
||
);
|
||
const actionPayload = {
|
||
...(section?.request_payload?.action_payload || {}),
|
||
...(secondary ? card?.secondaryActionPayload : card?.primaryActionPayload),
|
||
};
|
||
if (
|
||
await handleDriverActionResponse(
|
||
{
|
||
...response,
|
||
data: executionResult,
|
||
message: String(response?.message || "").trim(),
|
||
},
|
||
actionCode,
|
||
nodeCodes,
|
||
actionPayload
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
if (actionCode) {
|
||
await runLocalDriverActionCode(actionCode, nodeCodes, actionPayload);
|
||
}
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const runOpsLinkSnapshotAction = async (action: GenericRecord, index: number) => {
|
||
const actionCode = String(action?.action_code || "").trim();
|
||
if (!actionCode) {
|
||
return;
|
||
}
|
||
const nodeCodes = normalizeDriverNodeCodes(action?.node_codes || []);
|
||
const actionPayload = { ...(action?.payload || {}) };
|
||
const actionKey = `link-snapshot-${index}`;
|
||
loading.value.driverActionKey = actionKey;
|
||
try {
|
||
await runDriverActionCode(actionCode, nodeCodes, actionPayload);
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const copyOpsStackDiagnosisCommands = async () => {
|
||
await copyText(opsStackDiagnosisQuickCommandBlock.value, "总检快检命令已复制");
|
||
};
|
||
|
||
const copyGoLiveSummaryNextStepCommand = async () => {
|
||
await copyText(goLiveSummaryNextStepCommand.value, "收口命令已复制");
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已复制收口命令",
|
||
detail: "你可以直接在终端执行默认下一步对应的收口命令。",
|
||
});
|
||
};
|
||
|
||
const copyGoLiveSignoffCommand = async () => {
|
||
if (!goLiveSignoffPrimaryCommand.value) {
|
||
ElMessage.warning("当前还没有可复制的最终签收命令");
|
||
return;
|
||
}
|
||
await copyText(goLiveSignoffPrimaryCommand.value, "最终签收命令已复制");
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已复制最终签收命令",
|
||
detail: "你可以直接在终端执行 go-live-signoff 快检,复核最终签字结论。",
|
||
});
|
||
};
|
||
|
||
const copyGoLiveReviewCommand = async () => {
|
||
if (!goLiveReviewPrimaryCommand.value) {
|
||
ElMessage.warning("当前还没有可复制的正式复核命令");
|
||
return;
|
||
}
|
||
await copyText(goLiveReviewPrimaryCommand.value, "正式复核命令已复制");
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已复制正式复核命令",
|
||
detail: "你可以直接在终端执行 go-live-review,复核 bundle manifest 的正式结论。",
|
||
});
|
||
};
|
||
|
||
const copyDoctorDecisionCommand = async () => {
|
||
if (!doctorDecisionPrimaryCommand.value) {
|
||
ElMessage.warning("当前还没有可复制的主建议命令");
|
||
return;
|
||
}
|
||
await copyText(doctorDecisionPrimaryCommand.value, "总检主建议命令已复制");
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已复制总检主建议命令",
|
||
detail: "你可以直接在终端执行当前 doctor-decision 推荐的第一条处理命令。",
|
||
});
|
||
};
|
||
|
||
const copyOpsCockpitQuickCommands = async () => {
|
||
await copyText(opsStackDiagnosisQuickCommandBlock.value, "已复制总检快检命令");
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已复制快检命令",
|
||
detail: "你可以直接在终端粘贴执行这一组总检快检命令。",
|
||
});
|
||
};
|
||
|
||
const openOpsCockpitNextStepContractPreview = async () => {
|
||
if (!opsCockpitNextStepPreviewable.value) {
|
||
ElMessage.warning("当前默认下一步还没有可展示的契约预览");
|
||
setOpsCockpitActionFeedback({
|
||
type: "warning",
|
||
title: "当前默认下一步缺少契约预览",
|
||
detail: "这通常表示当前动作还没有收口成完整的后端契约或上下文仍不完整。",
|
||
});
|
||
return;
|
||
}
|
||
await openDriverActionContractPreview(buildOpsCockpitNextStepEntry(), {
|
||
sourceLabel: "ops-cockpit-next-step",
|
||
});
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已打开默认下一步契约预览",
|
||
detail: "你可以直接在抽屉里查看请求载荷、执行链和自动化等级。",
|
||
});
|
||
};
|
||
|
||
const runGoLiveSummaryNextStep = async () => {
|
||
const actionCode = String(goLiveSummary.value.next_step_action_code || "").trim();
|
||
if (!actionCode) {
|
||
ElMessage.warning("当前上线收口摘要还没有可直接执行的默认下一步");
|
||
return;
|
||
}
|
||
loading.value.driverActionKey = "go-live-next-step";
|
||
try {
|
||
await runDriverActionCode(actionCode, [], {});
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const copyOpsStackDiagnosisNextCommand = async () => {
|
||
await copyText(opsStackDiagnosisNextCommand.value, "总检下一步命令已复制");
|
||
};
|
||
|
||
const copyOpsStackDiagnosisPrimaryCommand = async () => {
|
||
await copyText(String(opsStackDiagnosisNextActions.value.primary_command || "").trim(), "主动作命令已复制");
|
||
};
|
||
|
||
const copyOpsStackDiagnosisSecondaryCommand = async () => {
|
||
await copyText(String(opsStackDiagnosisNextActions.value.secondary_command || "").trim(), "复核命令已复制");
|
||
};
|
||
|
||
const copyOpsStackDiagnosisRecommendedCommand = async (command: string) => {
|
||
await copyText(String(command || "").trim(), "推荐命令已复制");
|
||
};
|
||
|
||
const copyOpsStackDiagnosisRuntimeApiRestartCommand = async () => {
|
||
await copyText(opsStackDiagnosisRuntimeApiRestartCommand.value, "API 重启命令已复制");
|
||
};
|
||
|
||
const scrollToOpsPanel = async (panelId: string) => {
|
||
const normalizedPanelId = String(panelId || "").trim();
|
||
if (!normalizedPanelId) {
|
||
return false;
|
||
}
|
||
await nextTick();
|
||
const element = document.getElementById(normalizedPanelId);
|
||
if (!element) {
|
||
return false;
|
||
}
|
||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
return true;
|
||
};
|
||
|
||
const focusOpsStackDiagnosisRef = async (focusRef: GenericRecord = {}, actionCode = "") => {
|
||
if (Object.keys(focusRef || {}).length) {
|
||
const applied = await applyDriverFocusRef(focusRef);
|
||
if (applied) {
|
||
return true;
|
||
}
|
||
}
|
||
const normalizedActionCode = String(actionCode || "").trim();
|
||
if (normalizedActionCode === "focus_execution_scene") {
|
||
return await focusExecutionScenePanel();
|
||
}
|
||
if (normalizedActionCode === "focus_release_hub") {
|
||
await runLocalDriverActionCode("focus_release_hub", [], { focus_ref: focusRef || {} });
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const focusOpsStackDiagnosisOperatorDecision = async () => {
|
||
const applied = await focusOpsStackDiagnosisRef(
|
||
(opsStackDiagnosisOperatorDecision.value.next_focus || {}) as GenericRecord,
|
||
String(opsStackDiagnosisOperatorDecision.value.reason_code || "").trim()
|
||
);
|
||
if (!applied) {
|
||
ElMessage.warning("当前主决策还没有可定位的现场焦点");
|
||
setOpsCockpitActionFeedback({
|
||
type: "warning",
|
||
title: "当前主决策缺少可定位焦点",
|
||
detail: "建议直接进入主处理面板或查看下方详情区继续下钻。",
|
||
});
|
||
return;
|
||
}
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已定位到主决策焦点",
|
||
detail: "当前页面已尽量跳到对应处理区域或现场焦点。",
|
||
});
|
||
};
|
||
|
||
const openOpsStackDiagnosisOperatorDecisionWorkspace = async () => {
|
||
const decision = opsStackDiagnosisOperatorDecision.value;
|
||
const nextActions = opsStackDiagnosisNextActions.value;
|
||
const primaryCommandKey = String(
|
||
decision.primary_command_key || nextActions.primary_command_key || ""
|
||
).trim();
|
||
const normalizedLane = String(decision.lane || "").trim();
|
||
const focusRef = (decision.next_focus || {}) as GenericRecord;
|
||
const targetNodeCode = String(focusRef?.node_code || "").trim();
|
||
const releaseFocusSection = String(focusRef?.section || "").trim();
|
||
|
||
if (
|
||
primaryCommandKey === "scene-node-log"
|
||
|| primaryCommandKey === "scene-log"
|
||
|| normalizedLane === "observability"
|
||
) {
|
||
const applied = await focusExecutionScenePanel({
|
||
openLogDrawer: true,
|
||
nodeCode: targetNodeCode,
|
||
});
|
||
if (applied) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已按主决策自动定位到优先处理区域。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (
|
||
primaryCommandKey === "managed-node-handover"
|
||
|| primaryCommandKey === "fix-managed-nodes"
|
||
|| normalizedLane === "node_handover"
|
||
) {
|
||
const targetNode = findOpsNodeByCode(targetNodeCode);
|
||
if (targetNode.node_code) {
|
||
startNodeHandoverFlow(targetNode);
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: `当前已打开节点 ${targetNode.node_code} 的接管处理流程。`,
|
||
});
|
||
return;
|
||
}
|
||
if (await scrollToOpsPanel("ops-managed-nodes-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到托管节点处理区,请继续完成接管或同步。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (
|
||
primaryCommandKey === "release-launchpad-worker"
|
||
|| primaryCommandKey === "release-launchpad-control"
|
||
|| primaryCommandKey === "focus-release-hub"
|
||
|| normalizedLane === "release"
|
||
) {
|
||
if (releaseFocusSection === "release_launchpad") {
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
const mode = String(focusRef?.mode || "worker").trim() === "control" ? "control" : "worker";
|
||
await openReleaseLaunchpadReview(mode);
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: `当前已打开 ${mode === "control" ? "控制面" : "执行面"} 发布审阅区。`,
|
||
});
|
||
return;
|
||
}
|
||
const applied = await focusOpsStackDiagnosisRef(focusRef, primaryCommandKey);
|
||
if (applied) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已按发布主线定位到对应处理区域。",
|
||
});
|
||
return;
|
||
}
|
||
if (await scrollToOpsPanel("ops-release-hub-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到 Release Hub 详情区,请继续检查发布门禁。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (primaryCommandKey === "job-detail" || primaryCommandKey === "job-events" || normalizedLane === "ops_jobs") {
|
||
const applied = await focusOpsStackDiagnosisRef(focusRef, primaryCommandKey);
|
||
if (applied) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已定位到运维任务相关区域。",
|
||
});
|
||
return;
|
||
}
|
||
if (await scrollToOpsPanel("ops-jobs-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到运维任务详情区。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
const focused = await focusOpsStackDiagnosisRef(focusRef, primaryCommandKey);
|
||
if (focused) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已按主决策定位到对应现场区域。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (normalizedLane === "managed_nodes" && await scrollToOpsPanel("ops-managed-nodes-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到托管节点区。",
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedLane === "release" && await scrollToOpsPanel("ops-release-hub-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到 Release Hub 区。",
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedLane === "ops_jobs" && await scrollToOpsPanel("ops-jobs-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到运维任务区。",
|
||
});
|
||
return;
|
||
}
|
||
if (normalizedLane === "observability" && await scrollToOpsPanel("ops-execution-scene-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入主处理面板",
|
||
detail: "当前已滚动到执行现场区。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
ElMessage.warning("当前主决策还没有可进入的处理面板");
|
||
setOpsCockpitActionFeedback({
|
||
type: "warning",
|
||
title: "当前主决策还没有可进入的处理面板",
|
||
detail: "建议查看下方总检下钻详情,继续从问题清单或推荐动作进入。",
|
||
});
|
||
};
|
||
|
||
const openGoLiveSignoffWorkspace = async () => {
|
||
const preferredSurface = goLiveSignoffPreferredSurfaceKey.value;
|
||
if (["blocked", "attention", "missing"].includes(goLiveSignoffReviewStatus.value)) {
|
||
await openGoLiveReviewWorkspace();
|
||
return;
|
||
}
|
||
|
||
if (["blocked", "attention", "missing"].includes(goLiveSignoffDoctorStatus.value)) {
|
||
await openDoctorDecisionWorkspace();
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "release-launchpad") {
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
const recommendedActionCode = String(
|
||
((goLiveSignoff.value.release_launchpad || {}) as GenericRecord).recommended_action_code || ""
|
||
).trim();
|
||
const mode = recommendedActionCode.includes("control") ? "control" : "worker";
|
||
await openReleaseLaunchpadReview(mode);
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入签收建议工作区",
|
||
detail: `当前已打开 ${mode === "control" ? "控制面" : "执行面"} Release Hub 审阅区。`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "codex-brief" && await scrollToOpsPanel("ops-codex-brief-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入签收建议工作区",
|
||
detail: "当前已滚动到自动驾驶判断区,可继续收口自动化与确认链路。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (await scrollToOpsPanel("ops-go-live-summary-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入签收建议工作区",
|
||
detail: "当前已滚动到上线收口详情区,可继续复核发布前总检结论。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
await openOpsStackDiagnosisOperatorDecisionWorkspace();
|
||
};
|
||
|
||
const openGoLiveReviewWorkspace = async () => {
|
||
if (await scrollToOpsPanel("ops-go-live-review-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入正式复核面板",
|
||
detail: "当前已滚动到 go-live-review 详情区,可继续核对 manifest、环境缺口和摘要对齐情况。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (await scrollToOpsPanel("ops-go-live-bundle-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入证据复核区域",
|
||
detail: "当前已滚动到交付证据详情区,可继续从 bundle 与 manifest 原始证据回看。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (goLiveReviewPrimaryContract.value?.key) {
|
||
await openOpsContractDetail(String(goLiveReviewPrimaryContract.value.key || "").trim());
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已打开正式复核契约",
|
||
detail: "当前页面未能滚动到复核区,已改为打开 go-live-review 契约详情。",
|
||
});
|
||
return;
|
||
}
|
||
};
|
||
|
||
const openDoctorDecisionWorkspace = async () => {
|
||
const preferredSurface = doctorDecisionPreferredSurfaceKey.value;
|
||
|
||
if (preferredSurface === "contracts") {
|
||
if (doctorDecisionPrimaryContract.value?.key) {
|
||
await openOpsContractDetail(String(doctorDecisionPrimaryContract.value.key || "").trim());
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已打开主决策契约详情,可继续核对 contract 与推荐命令。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (preferredSurface === "release-hub" || preferredSurface === "release-launchpad") {
|
||
await loadReleaseLaunchpad({ silent: true });
|
||
const nextActionCode = String(doctorDecisionNextActionCode.value || "").trim();
|
||
const mode = nextActionCode.includes("control") ? "control" : "worker";
|
||
await openReleaseLaunchpadReview(mode);
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入总检建议工作区",
|
||
detail: `当前已打开 ${mode === "control" ? "控制面" : "执行面"} Release Hub 审阅区。`,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "execution-scene") {
|
||
const problemNodeCode = doctorDecisionSceneLogProblemNodeCodes.value[0] || "";
|
||
const applied = await focusExecutionScenePanel({
|
||
openLogDrawer: Boolean(problemNodeCode),
|
||
nodeCode: problemNodeCode,
|
||
});
|
||
if (applied) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已定位到执行现场区,可继续查看节点现场样本。",
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (preferredSurface === "managed-nodes" && await scrollToOpsPanel("ops-managed-nodes-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已滚动到托管节点区,可继续做接管、补装或验收。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "codex-brief" && await scrollToOpsPanel("ops-codex-brief-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已滚动到自动驾驶判断区,可继续复核主决策与推荐动作。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "driver-feed" && await scrollToOpsPanel("ops-codex-brief-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已滚动到总检入口区,可继续查看 driver-feed / codex-brief / stack 摘要。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "go-live-summary" && await scrollToOpsPanel("ops-go-live-summary-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已滚动到上线收口详情区,可继续复核收口与发布前主线。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (preferredSurface === "stack-summary" && await scrollToOpsPanel("ops-doctor-decision-panel")) {
|
||
setOpsCockpitActionFeedback({
|
||
type: "info",
|
||
title: "已进入总检建议工作区",
|
||
detail: "当前已定位到总检主决策详情区,可继续向下钻取建议命令和证据摘要。",
|
||
});
|
||
return;
|
||
}
|
||
|
||
await openOpsStackDiagnosisOperatorDecisionWorkspace();
|
||
};
|
||
|
||
const focusOpsStackDiagnosisNextStep = async () => {
|
||
const applied = await focusOpsStackDiagnosisRef(
|
||
(opsStackDiagnosisNextStep.value.focus_ref || {}) as GenericRecord,
|
||
String(opsStackDiagnosisNextStep.value.action_code || "").trim()
|
||
);
|
||
if (!applied) {
|
||
ElMessage.warning("当前下一步还没有可定位的焦点区域");
|
||
setOpsCockpitActionFeedback({
|
||
type: "warning",
|
||
title: "当前下一步缺少可定位焦点",
|
||
detail: "你可以改用“进入主处理面板”或直接查看契约预览继续处理。",
|
||
});
|
||
return;
|
||
}
|
||
setOpsCockpitActionFeedback({
|
||
type: "success",
|
||
title: "已定位到默认下一步焦点",
|
||
detail: "当前页面已尽量跳到默认下一步对应的现场区域。",
|
||
});
|
||
};
|
||
|
||
const runOpsStackDiagnosisNextStep = async () => {
|
||
const actionCode = String(opsStackDiagnosisNextStep.value.action_code || "").trim();
|
||
if (!actionCode) {
|
||
ElMessage.warning("当前总检还没有可直接执行的默认下一步");
|
||
return;
|
||
}
|
||
loading.value.driverActionKey = "stack-diagnosis-next-step";
|
||
try {
|
||
await runDriverActionCode(actionCode, [], {});
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const runOpsStackDiagnosisAction = async (action: GenericRecord, index: number) => {
|
||
const actionCode = String(action?.action_code || "").trim();
|
||
if (!actionCode) {
|
||
return;
|
||
}
|
||
loading.value.driverActionKey = `stack-diagnosis-action-${index}`;
|
||
try {
|
||
await runDriverActionCode(actionCode, [], {});
|
||
} finally {
|
||
loading.value.driverActionKey = "";
|
||
}
|
||
};
|
||
|
||
const focusOpsStackDiagnosisAction = async (action: GenericRecord) => {
|
||
const applied = await focusOpsStackDiagnosisRef(
|
||
(action?.focus_ref || {}) as GenericRecord,
|
||
String(action?.action_code || "").trim()
|
||
);
|
||
if (!applied) {
|
||
ElMessage.warning("当前动作还没有可定位的现场焦点");
|
||
}
|
||
};
|
||
|
||
const openInspectionRowEvents = async (row: GenericRecord) => {
|
||
const preferredJob =
|
||
row?.job_map?.["diagnostics.collect"] ||
|
||
row?.job_map?.["logs.collect"] ||
|
||
row?.job_map?.["health.snapshot"] ||
|
||
row?.latest_job ||
|
||
{};
|
||
if (!preferredJob?.id) {
|
||
ElMessage.warning("当前节点还没有可查看的巡检任务事件");
|
||
return;
|
||
}
|
||
await openJobEvents(preferredJob);
|
||
};
|
||
|
||
const inspectionSuggestedActionVisible = (row: GenericRecord) => {
|
||
const actionCode = String(row?.recommended_action_code || "").trim();
|
||
return Boolean(actionCode) && actionCode !== "no_action";
|
||
};
|
||
|
||
const inspectionProblemLabel = (row: GenericRecord) => {
|
||
const direct = String(row?.problem_label || "").trim();
|
||
if (direct) {
|
||
return direct;
|
||
}
|
||
return inspectionProblemKindLabel(row?.problem_kind);
|
||
};
|
||
|
||
const inspectionProblemTagType = (row: GenericRecord) => {
|
||
const level = String(row?.problem_level || "").trim();
|
||
if (level === "error") return "danger";
|
||
if (level === "warning") return "warning";
|
||
if (level === "success") return "success";
|
||
const overallStatus = String(row?.overall_status || "").trim();
|
||
if (overallStatus === "attention") return "danger";
|
||
if (overallStatus === "running") return "warning";
|
||
if (overallStatus === "healthy") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const inspectionSceneLabel = (row: GenericRecord) => {
|
||
if (!isInspectionVisibleRow(row)) {
|
||
return "非执行面";
|
||
}
|
||
if (!isInspectionEligibleRow(row)) {
|
||
return String(row?.agent_state_label || "").trim() || (row?.is_managed ? "待接入" : "未接管");
|
||
}
|
||
if (isInspectionParticipatingRow(row)) {
|
||
return String(row?.participation_label || "").trim() || "参与检测";
|
||
}
|
||
return "在线待命";
|
||
};
|
||
|
||
const inspectionSceneNote = (row: GenericRecord) => {
|
||
if (!isInspectionVisibleRow(row)) {
|
||
return "当前节点不在有效执行面口径中";
|
||
}
|
||
if (!isInspectionEligibleRow(row)) {
|
||
return String(row?.agent_state_reason || "").trim() || "当前还不能稳定下发远控巡检、发布或诊断动作";
|
||
}
|
||
if (isInspectionParticipatingRow(row)) {
|
||
return "当前节点正在执行、已领任务或近窗存在检测吞吐";
|
||
}
|
||
return "当前节点在线可执行,但还未参与本轮检测";
|
||
};
|
||
|
||
const inspectionSceneTagType = (row: GenericRecord) => {
|
||
if (!isInspectionVisibleRow(row)) return "info";
|
||
if (!isInspectionEligibleRow(row)) return "danger";
|
||
if (isInspectionParticipatingRow(row)) return "warning";
|
||
return "success";
|
||
};
|
||
|
||
const inspectionSuggestedActionLabel = (row: GenericRecord) => {
|
||
const actionCode = String(row?.recommended_action_code || "").trim();
|
||
if (actionCode === "handover_first_gap") {
|
||
return "去接管";
|
||
}
|
||
if (actionCode === "view_first_gap") {
|
||
return "看节点";
|
||
}
|
||
if (actionCode === "focus_latest_job_events") {
|
||
return "查看事件";
|
||
}
|
||
if (actionCode === "open_worker_logs_participating") {
|
||
return "看现场日志";
|
||
}
|
||
if (actionCode === "open_worker_logs_standby") {
|
||
return "看待命日志";
|
||
}
|
||
if (actionCode === "run_standard_inspection") {
|
||
return "补巡检";
|
||
}
|
||
return String(row?.recommended_action || "").trim() || "建议处理";
|
||
};
|
||
|
||
const inspectionRerunAvailable = (row: GenericRecord) => isInspectionEligibleRow(row);
|
||
|
||
const runInspectionSuggestedAction = async (row: GenericRecord) => {
|
||
const actionCode = String(row?.recommended_action_code || "").trim();
|
||
const nodeCode = String(row?.node_code || "").trim();
|
||
if (!actionCode || actionCode === "no_action") {
|
||
ElMessage.info("当前没有额外建议动作");
|
||
return;
|
||
}
|
||
loading.value.fleetActionKey = `inspection-suggested:${nodeCode}`;
|
||
try {
|
||
const uiIntent = (row?.ui_intent || {}) as GenericRecord;
|
||
if (actionCode === "focus_latest_job_events") {
|
||
const applied = await applyDriverUiIntent(uiIntent);
|
||
if (!applied) {
|
||
await openInspectionRowEvents(row);
|
||
}
|
||
return;
|
||
}
|
||
await runDriverActionCode(actionCode, nodeCode ? [nodeCode] : [], { ui_intent: uiIntent });
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "执行建议动作失败");
|
||
} finally {
|
||
loading.value.fleetActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openPlaybookRunLatestJobEvents = async (row: GenericRecord) => {
|
||
const latestJob = row?.latest_job || {};
|
||
if (!latestJob?.id) {
|
||
ElMessage.warning("当前编排还没有可查看的任务事件");
|
||
return;
|
||
}
|
||
await openJobEvents(latestJob);
|
||
};
|
||
|
||
const openPlaybookRunStepEvents = async (step: GenericRecord) => {
|
||
const latestJob = step?.latest_job || {};
|
||
if (!latestJob?.id) {
|
||
ElMessage.warning("当前步骤还没有可查看的任务事件");
|
||
return;
|
||
}
|
||
await openJobEvents(latestJob);
|
||
};
|
||
|
||
const openPlaybookRunDetail = async (row: GenericRecord) => {
|
||
const runCode = String(row?.run_code || "").trim();
|
||
if (!runCode) {
|
||
ElMessage.warning("当前编排回执缺少 run_code,无法查看详情");
|
||
return;
|
||
}
|
||
activePlaybookRun.value = row ? { ...(row || {}) } : null;
|
||
await openPlaybookRunDetailByCode(runCode);
|
||
};
|
||
|
||
const rerunPlaybookRun = async (row: GenericRecord) => {
|
||
const runCode = String(row?.run_code || "").trim();
|
||
if (!runCode) {
|
||
ElMessage.warning("当前编排回执缺少 run_code,无法重跑");
|
||
return;
|
||
}
|
||
loading.value.playbookRunActionKey = `rerun:${runCode}`;
|
||
try {
|
||
const response = await opsApi.rerunPlaybookRun(runCode, { requested_by: "web-ui/playbook-run-rerun" });
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
loadNodes(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
if (playbookRunDetailVisible.value && String(activePlaybookRun.value?.run_code || "").trim() === runCode) {
|
||
await Promise.all([loadPlaybookRunDetail(runCode, { silent: true }), loadPlaybookRunEvents(runCode, { silent: true })]);
|
||
}
|
||
ElMessage.success(response.message || `编排 ${runCode} 已重跑`);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "重跑编排失败");
|
||
} finally {
|
||
loading.value.playbookRunActionKey = "";
|
||
}
|
||
};
|
||
|
||
const cancelPlaybookRun = async (row: GenericRecord) => {
|
||
const runCode = String(row?.run_code || "").trim();
|
||
if (!runCode) {
|
||
ElMessage.warning("当前编排回执缺少 run_code,无法取消");
|
||
return;
|
||
}
|
||
if (!canCancelPlaybookRun(String(row?.status || ""))) {
|
||
ElMessage.warning("当前编排已收口,无需取消");
|
||
return;
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(`确认取消编排 ${runCode} 吗?这会把本轮仍未收口的子任务统一标记为已取消。`, "取消编排", {
|
||
type: "warning",
|
||
confirmButtonText: "确认取消",
|
||
cancelButtonText: "再想想",
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
loading.value.playbookRunActionKey = `cancel:${runCode}`;
|
||
try {
|
||
const response = await opsApi.cancelPlaybookRun(runCode, {
|
||
cancelled_by: "web-ui/playbook-run-cancel",
|
||
reason: "cancelled from ops center",
|
||
});
|
||
await Promise.all([
|
||
loadJobs(),
|
||
loadOverview(),
|
||
loadPlaybookRuns(),
|
||
loadActivityStream(),
|
||
loadNodes(),
|
||
selectedReleaseId.value ? loadRollouts() : Promise.resolve(),
|
||
]);
|
||
if (playbookRunDetailVisible.value && String(activePlaybookRun.value?.run_code || "").trim() === runCode) {
|
||
await Promise.all([loadPlaybookRunDetail(runCode, { silent: true }), loadPlaybookRunEvents(runCode, { silent: true })]);
|
||
}
|
||
ElMessage.success(response.message || `编排 ${runCode} 已取消`);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "取消编排失败");
|
||
} finally {
|
||
loading.value.playbookRunActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openWorkerLogTemplateForNode = (nodeCode: string, lines = 120) => {
|
||
const normalizedNodeCode = String(nodeCode || "").trim();
|
||
if (!normalizedNodeCode) {
|
||
ElMessage.warning("当前节点编码无效,无法打开 Worker 日志模板");
|
||
return;
|
||
}
|
||
openPrefilledActionTemplateDialog("logs.collect", [normalizedNodeCode], buildWorkerLogCollectPayload(lines));
|
||
};
|
||
|
||
const openInspectionRowDiagnostics = (row: GenericRecord) => {
|
||
const nodeCode = String(row?.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前节点编码无效,无法打开诊断模板");
|
||
return;
|
||
}
|
||
openPrefilledActionTemplateDialog("diagnostics.collect", [nodeCode], { lines: 200 });
|
||
};
|
||
|
||
const rerunInspectionForRow = async (row: GenericRecord) => {
|
||
const nodeCode = String(row?.node_code || "").trim();
|
||
if (!nodeCode) {
|
||
ElMessage.warning("当前节点编码无效,无法重跑巡检");
|
||
return;
|
||
}
|
||
if (!inspectionRerunAvailable(row)) {
|
||
ElMessage.warning("当前节点还没进入 Agent 在线状态,请先完成接管/接入");
|
||
return;
|
||
}
|
||
loading.value.fleetActionKey = `row:${nodeCode}`;
|
||
try {
|
||
await runStandardInspectionSequenceForNodes([nodeCode], {
|
||
requestedBy: "web-ui/inspection-row-rerun",
|
||
successLabel: `节点 ${nodeCode}`
|
||
});
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "重跑巡检失败");
|
||
} finally {
|
||
loading.value.fleetActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openNodeScopedRolloutDialog = async () => {
|
||
if (!agentTargetNodeCode.value) {
|
||
ElMessage.warning("当前没有可发布的目标节点");
|
||
return;
|
||
}
|
||
const release = preferredNodeOpsRelease.value || {};
|
||
const releaseId = Number(release.id || 0);
|
||
if (!releaseId) {
|
||
ElMessage.warning("当前还没有可用 Release,请先创建 Release");
|
||
openReleaseDialog();
|
||
return;
|
||
}
|
||
selectedReleaseId.value = releaseId;
|
||
rolloutTargetReleaseId.value = releaseId;
|
||
resetRolloutForm();
|
||
rolloutPreview.value = {};
|
||
rolloutForm.value.node_codes_text = agentTargetNodeCode.value;
|
||
rolloutForm.value.region = "";
|
||
rolloutForm.value.role = "";
|
||
rolloutForm.value.only_enabled = true;
|
||
rolloutForm.value.only_online = true;
|
||
rolloutForm.value.only_effective_workers = false;
|
||
rolloutForm.value.first_batch_size = 1;
|
||
rolloutForm.value.batch_size = 1;
|
||
rolloutDialogVisible.value = true;
|
||
await requestRolloutPreview({ silent: true });
|
||
};
|
||
|
||
const normalizeRolloutPrefillNodeStatus = (row: GenericRecord) =>
|
||
String(row.cluster_status || row.status || "").trim().toLowerCase();
|
||
|
||
const normalizeRolloutPrefillNodeRole = (row: GenericRecord) =>
|
||
String(row.role || row.cluster_role || "").trim().toLowerCase();
|
||
|
||
const normalizeRolloutPrefillNodeRegion = (row: GenericRecord) =>
|
||
String(row.region || row.cluster_region || "").trim().toLowerCase();
|
||
|
||
const isRolloutPrefillOnlineNode = (row: GenericRecord) => ["online", "busy"].includes(normalizeRolloutPrefillNodeStatus(row));
|
||
|
||
const isRolloutPrefillEnabledNode = (row: GenericRecord) => row.is_enabled !== false;
|
||
|
||
const isRolloutPrefillRemoteReadyNode = (row: GenericRecord) =>
|
||
Boolean(row.is_managed) && row.is_enabled !== false && Boolean(row.is_agent_online);
|
||
|
||
const isRolloutPrefillSshReadyNode = (row: GenericRecord) =>
|
||
Boolean(row.is_managed) && row.is_enabled !== false && Boolean(row.has_ssh_access);
|
||
|
||
const isRolloutPrefillEffectiveWorkerNode = (row: GenericRecord) => {
|
||
if (typeof row.cluster_is_effective_worker === "boolean") {
|
||
return row.cluster_is_effective_worker;
|
||
}
|
||
if (typeof row.is_effective_worker === "boolean") {
|
||
return row.is_effective_worker;
|
||
}
|
||
return normalizeRolloutPrefillNodeRole(row) === "worker";
|
||
};
|
||
|
||
const collectRolloutPrefillNodeSource = (): GenericRecord[] => {
|
||
if (nodes.value.length) {
|
||
return [...nodes.value];
|
||
}
|
||
return clusterNodes.value.map((item) => ({
|
||
node_code: item.node_code,
|
||
region: item.region,
|
||
role: item.role,
|
||
is_enabled: true,
|
||
is_managed: false,
|
||
is_agent_online: false,
|
||
current_load: item.current_load,
|
||
status: item.status,
|
||
cluster_status: item.status,
|
||
cluster_is_effective_worker: item.is_effective_worker,
|
||
metadata: {
|
||
imported_from_cluster_fallback: true,
|
||
last_heartbeat_at: item.last_heartbeat_at,
|
||
},
|
||
}));
|
||
};
|
||
|
||
const pickRolloutPrefillDominantRegion = (rows: GenericRecord[]) => {
|
||
const counts = new Map<string, number>();
|
||
for (const row of rows) {
|
||
const region = normalizeRolloutPrefillNodeRegion(row);
|
||
if (!region) {
|
||
continue;
|
||
}
|
||
counts.set(region, Number(counts.get(region) || 0) + 1);
|
||
}
|
||
return Array.from(counts.entries()).sort((left, right) => {
|
||
if (right[1] !== left[1]) {
|
||
return right[1] - left[1];
|
||
}
|
||
return left[0].localeCompare(right[0]);
|
||
})[0]?.[0] || "";
|
||
};
|
||
|
||
const buildRolloutRolePreset = (mode: "worker" | "control") => {
|
||
if (mode === "worker") {
|
||
return {
|
||
restart_services_text: "domaincheck-worker",
|
||
health_check_urls_text: "",
|
||
health_check_services_text: "domaincheck-worker",
|
||
};
|
||
}
|
||
return {
|
||
restart_services_text: "domaincheck-api\ndomaincheck-worker\ndomaincheck-sync-agent",
|
||
health_check_urls_text: "http://127.0.0.1:8100/health",
|
||
health_check_services_text: "domaincheck-api\ndomaincheck-worker\ndomaincheck-sync-agent",
|
||
};
|
||
};
|
||
|
||
const sortRolloutPrefillCandidates = (rows: GenericRecord[]) =>
|
||
[...rows].sort((left, right) => {
|
||
const leftAccessScore = isRolloutPrefillRemoteReadyNode(left) ? 0 : (isRolloutPrefillSshReadyNode(left) ? 1 : 2);
|
||
const rightAccessScore = isRolloutPrefillRemoteReadyNode(right) ? 0 : (isRolloutPrefillSshReadyNode(right) ? 1 : 2);
|
||
if (leftAccessScore !== rightAccessScore) {
|
||
return leftAccessScore - rightAccessScore;
|
||
}
|
||
const leftStatusScore = normalizeRolloutPrefillNodeStatus(left) === "online" ? 0 : 1;
|
||
const rightStatusScore = normalizeRolloutPrefillNodeStatus(right) === "online" ? 0 : 1;
|
||
if (leftStatusScore !== rightStatusScore) {
|
||
return leftStatusScore - rightStatusScore;
|
||
}
|
||
const leftLoad = Number(left.current_load || 0);
|
||
const rightLoad = Number(right.current_load || 0);
|
||
if (leftLoad !== rightLoad) {
|
||
return leftLoad - rightLoad;
|
||
}
|
||
return String(left.node_code || "").localeCompare(String(right.node_code || ""));
|
||
});
|
||
|
||
const buildSmartRolloutTargetPlan = (mode: "auto" | "worker" | "control" = "auto") => {
|
||
const sourceNodes = collectRolloutPrefillNodeSource();
|
||
const onlineEnabledNodes = sourceNodes.filter((row) => isRolloutPrefillEnabledNode(row) && isRolloutPrefillOnlineNode(row));
|
||
if (!onlineEnabledNodes.length) {
|
||
return {
|
||
available: false,
|
||
reason: "当前没有在线且启用的节点可用于 Rollout 预填",
|
||
targetMode: "auto",
|
||
targetNodes: [],
|
||
};
|
||
}
|
||
|
||
const effectiveWorkerNodes = onlineEnabledNodes.filter((row) => isRolloutPrefillEffectiveWorkerNode(row));
|
||
const dominantRegion = pickRolloutPrefillDominantRegion(effectiveWorkerNodes.length ? effectiveWorkerNodes : onlineEnabledNodes);
|
||
const normalizedMode: "worker" | "control" =
|
||
mode === "auto"
|
||
? effectiveWorkerNodes.some((row) => normalizeRolloutPrefillNodeRole(row) === "worker")
|
||
? "worker"
|
||
: "control"
|
||
: mode;
|
||
|
||
let candidates = onlineEnabledNodes.filter((row) => {
|
||
if (dominantRegion && normalizeRolloutPrefillNodeRegion(row) !== dominantRegion) {
|
||
return false;
|
||
}
|
||
const role = normalizeRolloutPrefillNodeRole(row);
|
||
if (normalizedMode === "worker") {
|
||
return role === "worker" && isRolloutPrefillEffectiveWorkerNode(row);
|
||
}
|
||
return role === "control";
|
||
});
|
||
|
||
if (!candidates.length) {
|
||
candidates = onlineEnabledNodes.filter((row) => {
|
||
const role = normalizeRolloutPrefillNodeRole(row);
|
||
if (normalizedMode === "worker") {
|
||
return role === "worker" && isRolloutPrefillEffectiveWorkerNode(row);
|
||
}
|
||
return role === "control";
|
||
});
|
||
}
|
||
|
||
candidates = sortRolloutPrefillCandidates(candidates);
|
||
if (!candidates.length) {
|
||
return {
|
||
available: false,
|
||
reason: normalizedMode === "worker" ? "当前没有可用于灰度的在线 Worker 节点" : "当前没有可用于发布的在线 Control 节点",
|
||
targetMode: normalizedMode,
|
||
targetNodes: [],
|
||
};
|
||
}
|
||
|
||
const targetNodeCodes = candidates
|
||
.map((row) => String(row.node_code || "").trim())
|
||
.filter(Boolean);
|
||
const batchSize = candidates.length <= 2 ? 1 : candidates.length <= 5 ? 2 : 3;
|
||
const services = buildRolloutRolePreset(normalizedMode);
|
||
const regionLabel = dominantRegion || normalizeRolloutPrefillNodeRegion(candidates[0]) || "当前区域";
|
||
const modeLabel = normalizedMode === "worker" ? "Worker 灰度" : "Control 发布";
|
||
const remoteReadyCandidates = candidates.filter((row) => isRolloutPrefillRemoteReadyNode(row));
|
||
const sshReadyCandidates = candidates.filter((row) => isRolloutPrefillSshReadyNode(row));
|
||
const executionMode =
|
||
remoteReadyCandidates.length === candidates.length
|
||
? "remote-agent"
|
||
: sshReadyCandidates.length === candidates.length
|
||
? "ssh"
|
||
: "remote-agent";
|
||
const executionModeLabel = executionMode === "ssh" ? "SSH" : "remote-agent";
|
||
|
||
return {
|
||
available: true,
|
||
targetMode: normalizedMode,
|
||
targetNodes: candidates,
|
||
nodeCodesText: targetNodeCodes.join("\n"),
|
||
executionMode,
|
||
firstBatchSize: 1,
|
||
batchSize,
|
||
services,
|
||
summary: `已按 ${modeLabel} 预填 ${targetNodeCodes.length} 台节点,执行路径采用 ${executionModeLabel};优先锁定 ${regionLabel};首批 1 台,后续每批 ${batchSize} 台。`,
|
||
};
|
||
};
|
||
|
||
const applySmartRolloutTargetPlan = async (
|
||
mode: "auto" | "worker" | "control" = "auto",
|
||
options: {
|
||
silent?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { silent = false } = options;
|
||
const plan = buildSmartRolloutTargetPlan(mode);
|
||
if (!plan.available) {
|
||
rolloutSmartPrefillHint.value = String(plan.reason || "");
|
||
if (!silent) {
|
||
ElMessage.warning(String(plan.reason || "当前没有可用于预填的目标节点"));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
rolloutForm.value.node_codes_text = String(plan.nodeCodesText || "");
|
||
rolloutForm.value.region = "";
|
||
rolloutForm.value.role = "";
|
||
rolloutForm.value.only_enabled = true;
|
||
rolloutForm.value.only_online = true;
|
||
rolloutForm.value.only_effective_workers = false;
|
||
rolloutForm.value.execution_mode = String(plan.executionMode || "remote-agent");
|
||
rolloutForm.value.first_batch_size = Number(plan.firstBatchSize || 1);
|
||
rolloutForm.value.batch_size = Number(plan.batchSize || 1);
|
||
rolloutForm.value.restart_services_text = String(plan.services?.restart_services_text || "");
|
||
rolloutForm.value.health_check_urls_text = String(plan.services?.health_check_urls_text || "");
|
||
rolloutForm.value.health_check_services_text = String(plan.services?.health_check_services_text || "");
|
||
rolloutSmartPrefillHint.value = String(plan.summary || "");
|
||
await requestRolloutPreview({ silent: true });
|
||
if (!silent) {
|
||
ElMessage.success(rolloutSmartPrefillHint.value || "已完成 Rollout 智能预填");
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const copyIssuedToken = async () => {
|
||
await copyText(String(lastIssuedToken.value.token || ""), "Token 已复制");
|
||
};
|
||
|
||
const copyAgentEnvContent = async () => {
|
||
await copyText(agentEnvContent.value, "Agent Env 已复制");
|
||
};
|
||
|
||
const copyAgentBootstrapSnippet = async () => {
|
||
await copyText(agentBootstrapSnippet.value, "Agent 接入片段已复制");
|
||
};
|
||
|
||
const copyAgentBootstrapScript = async () => {
|
||
await copyText(agentBootstrapScriptContent.value, "完整 bootstrap 脚本已复制");
|
||
};
|
||
|
||
const copyAgentBootstrapRunBlock = async () => {
|
||
await copyText(agentBootstrapRunBlock.value, "一键落地命令已复制");
|
||
};
|
||
|
||
const copyOpsEntrypointHost = async () => {
|
||
await copyText(opsRunbookEntrypointHost.value, "控制面地址已复制");
|
||
};
|
||
|
||
const copyOpsApiBaseUrl = async () => {
|
||
await copyText(opsRunbookApiBaseUrl.value, "API Base 已复制");
|
||
};
|
||
|
||
const copyOpsFastChecks = async () => {
|
||
await copyText(opsRunbookFastCheckCommands.value.join("\n"), "快检命令已复制");
|
||
};
|
||
|
||
const copyOpsContractDiscoveryUrls = async () => {
|
||
await copyText(opsContractDiscoveryUrls.value.join("\n"), "协议发现入口已复制");
|
||
};
|
||
|
||
const copySelectedOpsContractDetailEndpoint = async () => {
|
||
if (!selectedOpsContractKey.value) {
|
||
ElMessage.warning("当前还没有选中的 contract");
|
||
return;
|
||
}
|
||
if (!selectedOpsContractDetailEndpoint.value || selectedOpsContractDetailEndpoint.value === "-") {
|
||
ElMessage.warning("当前 contract 还没有可复制的详情入口");
|
||
return;
|
||
}
|
||
await copyText(selectedOpsContractDetailEndpoint.value, "contract 详情入口已复制");
|
||
};
|
||
|
||
const copyReleaseLaunchpadCommand = async () => {
|
||
if (!releaseLaunchpadRecommendedCommand.value) {
|
||
ElMessage.warning("当前没有推荐命令可复制");
|
||
return;
|
||
}
|
||
await copyText(releaseLaunchpadRecommendedCommand.value, "发布驾驶舱命令已复制");
|
||
};
|
||
|
||
const copyActiveJobPayload = async () => {
|
||
await copyText(activeJobPayloadText.value, "任务载荷已复制");
|
||
};
|
||
|
||
const copyActiveJobResult = async () => {
|
||
await copyText(activeJobResultText.value, "任务结果已复制");
|
||
};
|
||
|
||
const copyActiveJobMetadata = async () => {
|
||
await copyText(activeJobMetadataText.value, "任务元数据已复制");
|
||
};
|
||
|
||
const copyActiveJobPolicy = async () => {
|
||
await copyText(activeJobPolicyText.value, "策略快照已复制");
|
||
};
|
||
|
||
const copyActiveJobBootstrapToken = async () => {
|
||
await copyText(String(activeJobBootstrapResult.value.token || ""), "Bootstrap Token 已复制");
|
||
};
|
||
|
||
const copyActiveJobBootstrapEnv = async () => {
|
||
await copyText(String(activeJobBootstrapPlan.value.env_content || ""), "Bootstrap Env 已复制");
|
||
};
|
||
|
||
const copyActiveJobBootstrapCommandBlock = async () => {
|
||
await copyText(String(activeJobBootstrapPlan.value.command_block || ""), "Bootstrap 接入片段已复制");
|
||
};
|
||
|
||
const copyActiveJobBootstrapScript = async () => {
|
||
await copyText(String(activeJobBootstrapPlan.value.bootstrap_script_content || ""), "Bootstrap 脚本已复制");
|
||
};
|
||
|
||
const copyActiveJobBootstrapRunBlock = async () => {
|
||
await copyText(String(activeJobBootstrapPlan.value.bootstrap_run_script_block || ""), "Bootstrap 一键落地命令已复制");
|
||
};
|
||
|
||
const openReleaseLaunchpadReview = async (mode: "worker" | "control") => {
|
||
const release =
|
||
(Number(releaseLaunchpadLatestRelease.value.id || 0) > 0 ? releaseLaunchpadLatestRelease.value : latestRelease.value) || {};
|
||
const releaseId = Number(release?.id || 0);
|
||
if (releaseId > 0) {
|
||
await openRolloutDialog(release);
|
||
await applySmartRolloutTargetPlan(mode, { silent: true });
|
||
return;
|
||
}
|
||
await openReleaseDialog();
|
||
await importLatestReleasePackage();
|
||
ElMessage.info(`已打开 Release 面板,请继续检查${smartRolloutModeLabel(mode)}。`);
|
||
};
|
||
|
||
const runReleaseLaunchpadAction = async () => {
|
||
const actionCode = releaseLaunchpadRecommendedActionCode.value;
|
||
if (!actionCode) {
|
||
return;
|
||
}
|
||
loading.value.releaseLaunchpadActionKey = actionCode;
|
||
try {
|
||
await runDriverActionCode(actionCode, [], {
|
||
channel: String(releaseLaunchpad.value?.channel || "stable").trim() || "stable",
|
||
control_plane_base_url: String(releaseLaunchpad.value?.control_plane_base_url || "").trim(),
|
||
});
|
||
} finally {
|
||
loading.value.releaseLaunchpadActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openReleaseDialog = async () => {
|
||
resetReleaseForm();
|
||
releaseDialogVisible.value = true;
|
||
await loadLatestReleasePackageMetadata({ silent: true });
|
||
};
|
||
|
||
const importLatestReleasePackage = async () => {
|
||
loading.value.releasePackageImport = true;
|
||
try {
|
||
let packageMetadata = latestReleasePackage.value;
|
||
if (!packageMetadata?.available) {
|
||
packageMetadata = await loadLatestReleasePackageMetadata({ silent: true });
|
||
}
|
||
if (!packageMetadata?.available) {
|
||
ElMessage.warning(String(packageMetadata?.reason || "当前没有可导入的发布包"));
|
||
return;
|
||
}
|
||
|
||
const packageSource = {
|
||
...(packageMetadata.metadata || {}),
|
||
download_path: String(packageMetadata.download_path || "").trim(),
|
||
sha256_download_path: String(packageMetadata.sha256_download_path || "").trim(),
|
||
download_url: latestReleasePackageDownloadUrl.value,
|
||
sha256_download_url: latestReleasePackageSha256Url.value,
|
||
};
|
||
|
||
releaseForm.value = {
|
||
...releaseForm.value,
|
||
release_version: String(packageMetadata.release_version_suggestion || packageMetadata.package_name || "").trim(),
|
||
commit_sha: String(packageMetadata.commit_sha || "").trim(),
|
||
artifact_url: latestReleasePackageDownloadUrl.value,
|
||
checksum: String(packageMetadata.sha256 || "").trim(),
|
||
notes: String(packageMetadata.notes_suggestion || "").trim(),
|
||
metadata: {
|
||
...(releaseForm.value.metadata || {}),
|
||
package_source: packageSource,
|
||
},
|
||
};
|
||
ElMessage.success(`已导入发布包 ${packageMetadata.package_name || "-"}`);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "导入最新发布包失败");
|
||
} finally {
|
||
loading.value.releasePackageImport = false;
|
||
}
|
||
};
|
||
|
||
const finalizeReleaseCreated = async (
|
||
response: GenericRecord,
|
||
successFallback: string,
|
||
options: {
|
||
openRolloutAfter?: boolean;
|
||
showSuccessToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { openRolloutAfter = false, showSuccessToast = true } = options;
|
||
const createdRelease = response.data?.release || {};
|
||
const releaseId = Number(createdRelease.id || 0);
|
||
releaseDialogVisible.value = false;
|
||
resetReleaseForm();
|
||
await Promise.all([loadOverview(), loadReleases(), loadReleaseLaunchpad({ silent: true })]);
|
||
if (releaseId) {
|
||
selectedReleaseId.value = releaseId;
|
||
await loadRollouts(releaseId);
|
||
if (openRolloutAfter) {
|
||
await openRolloutDialog(createdRelease);
|
||
}
|
||
}
|
||
if (showSuccessToast) {
|
||
ElMessage.success(response.message || successFallback);
|
||
}
|
||
return createdRelease;
|
||
};
|
||
|
||
const createReleaseFromLatestPackage = async (
|
||
options: {
|
||
openRolloutAfter?: boolean;
|
||
showSuccessToast?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { openRolloutAfter = false, showSuccessToast = true } = options;
|
||
if (!latestReleasePackageReleaseActionAllowed.value) {
|
||
ElMessage.warning(
|
||
latestReleasePackageReleaseActionBlockMessage.value || "当前最新发布包尚未完成最终签收,暂不允许创建 Release。"
|
||
);
|
||
return null;
|
||
}
|
||
if (openRolloutAfter) {
|
||
loading.value.releaseCreateAndRollout = true;
|
||
} else {
|
||
loading.value.releaseCreate = true;
|
||
}
|
||
try {
|
||
const response = await opsApi.createReleaseFromLatestPackage({
|
||
release_version: releaseForm.value.release_version.trim(),
|
||
channel: releaseForm.value.channel,
|
||
status: releaseForm.value.status,
|
||
commit_sha: releaseForm.value.commit_sha.trim(),
|
||
checksum: releaseForm.value.checksum.trim(),
|
||
notes: releaseForm.value.notes.trim(),
|
||
metadata: releaseForm.value.metadata || {},
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
created_by: "web-ui/package-import",
|
||
});
|
||
return await finalizeReleaseCreated(
|
||
response,
|
||
openRolloutAfter ? "已从最新发布包创建 Release,并进入 Rollout" : "已从最新发布包创建 Release",
|
||
{ openRolloutAfter, showSuccessToast }
|
||
);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || (openRolloutAfter ? "从最新发布包创建 Release 并进入 Rollout 失败" : "从最新发布包创建 Release 失败"));
|
||
return null;
|
||
} finally {
|
||
if (openRolloutAfter) {
|
||
loading.value.releaseCreateAndRollout = false;
|
||
} else {
|
||
loading.value.releaseCreate = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const createReleaseFromLatestPackageAndOpenRollout = async () => {
|
||
await createReleaseFromLatestPackage({ openRolloutAfter: true });
|
||
};
|
||
|
||
const createRelease = async () => {
|
||
if (!releaseForm.value.release_version.trim()) {
|
||
ElMessage.warning("请填写 release_version");
|
||
return;
|
||
}
|
||
if (!releaseForm.value.artifact_url.trim()) {
|
||
ElMessage.warning("请填写 artifact_url");
|
||
return;
|
||
}
|
||
loading.value.releaseCreate = true;
|
||
try {
|
||
const response = await opsApi.createRelease({
|
||
release_version: releaseForm.value.release_version.trim(),
|
||
channel: releaseForm.value.channel,
|
||
status: releaseForm.value.status,
|
||
commit_sha: releaseForm.value.commit_sha.trim(),
|
||
artifact_url: releaseForm.value.artifact_url.trim(),
|
||
checksum: releaseForm.value.checksum.trim(),
|
||
notes: releaseForm.value.notes,
|
||
metadata: releaseForm.value.metadata || {},
|
||
created_by: "web-ui"
|
||
});
|
||
await finalizeReleaseCreated(response, "Release 创建成功");
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "Release 创建失败");
|
||
} finally {
|
||
loading.value.releaseCreate = false;
|
||
}
|
||
};
|
||
|
||
const activateRelease = async (row: GenericRecord) => {
|
||
loading.value.releaseActionKey = `activate-${row.id}`;
|
||
try {
|
||
const response = await opsApi.activateRelease(Number(row.id));
|
||
ElMessage.success(response.message || "Release 已激活");
|
||
await Promise.all([loadOverview(), loadReleases(), loadReleaseLaunchpad({ silent: true })]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "激活失败");
|
||
} finally {
|
||
loading.value.releaseActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openRolloutDialog = async (row?: GenericRecord) => {
|
||
const targetId = Number(row?.id || selectedReleaseId.value || 0);
|
||
if (!targetId) {
|
||
ElMessage.warning("请先选择一个 Release");
|
||
return;
|
||
}
|
||
const gateReleaseId = Number(releaseGateRelease.value.id || 0);
|
||
const gateMatchesTarget = gateReleaseId > 0 && gateReleaseId === targetId;
|
||
resetRolloutForm();
|
||
rolloutPreview.value = {};
|
||
selectedReleaseId.value = targetId;
|
||
rolloutTargetReleaseId.value = targetId;
|
||
let smartPrefillApplied = false;
|
||
if (gateMatchesTarget && releaseGateTargetNodeCodes.value.length) {
|
||
rolloutForm.value.node_codes_text = releaseGateTargetNodeCodes.value.join("\n");
|
||
rolloutSmartPrefillHint.value = `已沿用 Release 门禁建议的 ${releaseGateTargetNodeCodes.value.length} 台目标节点。`;
|
||
rolloutPreview.value = {
|
||
release: releaseGateRelease.value || {},
|
||
release_gate: releaseGate.value || {},
|
||
operational_readiness: (releaseGate.value || {}).operational_readiness || {}
|
||
};
|
||
} else {
|
||
smartPrefillApplied = await applySmartRolloutTargetPlan("auto", { silent: true });
|
||
}
|
||
rolloutDialogVisible.value = true;
|
||
if (gateMatchesTarget) {
|
||
const gateStatus = String(releaseGate.value.status || "").trim();
|
||
if (["missing_release", "release_not_ready", "artifact_missing", "blocked"].includes(gateStatus)) {
|
||
ElMessage.warning(String(releaseGate.value.summary || "当前默认 Rollout 门禁存在阻断项,请先处理后再推进。"));
|
||
} else if (gateStatus === "attention") {
|
||
ElMessage.warning("当前默认 Rollout 门禁有待关注事项,请先查看预检详情。");
|
||
}
|
||
}
|
||
const hasPreviewSelector =
|
||
splitTextList(rolloutForm.value.node_codes_text).length > 0 ||
|
||
Boolean(rolloutForm.value.region) ||
|
||
Boolean(rolloutForm.value.role);
|
||
if (hasPreviewSelector && !smartPrefillApplied) {
|
||
await requestRolloutPreview({ silent: true });
|
||
}
|
||
};
|
||
|
||
const smartRolloutModeLabel = (mode: "worker" | "control") => (mode === "control" ? "Control 发布" : "Worker 灰度");
|
||
|
||
const applyExplicitRolloutPlanToForm = (plan: GenericRecord | null | undefined) => {
|
||
const normalizedPlan = plan || {};
|
||
const selector = (normalizedPlan.target_selector || {}) as GenericRecord;
|
||
const policy = (normalizedPlan.policy || {}) as GenericRecord;
|
||
const deployPayload = (policy.deploy_payload || {}) as GenericRecord;
|
||
rolloutForm.value.node_codes_text = ((selector.node_codes || []) as unknown[])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
rolloutForm.value.region = String(selector.region || "").trim();
|
||
rolloutForm.value.role = String(selector.role || "").trim();
|
||
rolloutForm.value.only_enabled = selector.only_enabled !== false;
|
||
rolloutForm.value.only_online = selector.only_online !== false;
|
||
rolloutForm.value.only_effective_workers = Boolean(selector.only_effective_workers);
|
||
rolloutForm.value.execution_mode = String(policy.execution_mode || "remote-agent").trim() || "remote-agent";
|
||
rolloutForm.value.first_batch_size = Math.max(1, Number(policy.first_batch_size || 1));
|
||
rolloutForm.value.batch_size = Math.max(1, Number(policy.batch_size || rolloutForm.value.first_batch_size || 1));
|
||
rolloutForm.value.auto_start = policy.auto_start !== false;
|
||
rolloutForm.value.auto_dispatch = Boolean(policy.auto_dispatch);
|
||
rolloutForm.value.auto_advance = policy.auto_advance !== false;
|
||
rolloutForm.value.auto_approve = Boolean(policy.auto_approve);
|
||
rolloutForm.value.continue_on_failure = Boolean(policy.continue_on_failure);
|
||
rolloutForm.value.continue_on_partial = Boolean(policy.continue_on_partial);
|
||
rolloutForm.value.restart_services_text = ((deployPayload.restart_services || []) as unknown[])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
rolloutForm.value.health_check_urls_text = ((deployPayload.health_check_urls || []) as unknown[])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
rolloutForm.value.health_check_services_text = ((deployPayload.health_check_services || []) as unknown[])
|
||
.map((item) => String(item || "").trim())
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
rolloutForm.value.health_check_timeout_seconds = Math.max(2, Number(deployPayload.health_check_timeout_seconds || 10));
|
||
rolloutForm.value.health_check_retries = Math.max(0, Number(deployPayload.health_check_retries || 2));
|
||
rolloutForm.value.health_check_interval_seconds = Math.max(1, Number(deployPayload.health_check_interval_seconds || 2));
|
||
rolloutForm.value.rollback_on_failure = deployPayload.rollback_on_failure !== false;
|
||
rolloutForm.value.switch_current = deployPayload.switch_current !== false;
|
||
rolloutSmartPrefillHint.value = String(normalizedPlan.summary || normalizedPlan.reason || "").trim();
|
||
};
|
||
|
||
const syncReleaseStateAfterBackendFlow = async (release: GenericRecord | null | undefined) => {
|
||
const releaseId = Number(release?.id || 0);
|
||
releaseDialogVisible.value = false;
|
||
resetReleaseForm();
|
||
await Promise.all([loadOverview(), loadReleases(), loadReleaseLaunchpad({ silent: true })]);
|
||
if (releaseId > 0) {
|
||
selectedReleaseId.value = releaseId;
|
||
await loadRollouts(releaseId);
|
||
}
|
||
};
|
||
|
||
const openRolloutDialogFromServerPlan = async (
|
||
release: GenericRecord | null | undefined,
|
||
plan: GenericRecord | null | undefined,
|
||
preview: GenericRecord | null | undefined = null
|
||
) => {
|
||
const releaseId = Number(release?.id || 0);
|
||
if (!releaseId) {
|
||
return;
|
||
}
|
||
resetRolloutForm();
|
||
rolloutPreview.value = {};
|
||
selectedReleaseId.value = releaseId;
|
||
rolloutTargetReleaseId.value = releaseId;
|
||
if (plan && Object.keys(plan).length && Boolean(plan.available)) {
|
||
applyExplicitRolloutPlanToForm(plan);
|
||
rolloutDialogVisible.value = true;
|
||
if (preview && Object.keys(preview).length) {
|
||
rolloutPreview.value = preview;
|
||
} else {
|
||
await requestRolloutPreview({ silent: true });
|
||
}
|
||
return;
|
||
}
|
||
await openRolloutDialog(release || undefined);
|
||
if (plan && Object.keys(plan).length) {
|
||
rolloutSmartPrefillHint.value =
|
||
String(plan.summary || plan.reason || "").trim() || rolloutSmartPrefillHint.value;
|
||
}
|
||
};
|
||
|
||
const resolveSmartRolloutModeFromAction = (actionCode: string, payload: GenericRecord = {}, fallback = "worker") => {
|
||
const explicitMode = String(payload?.mode || "").trim().toLowerCase();
|
||
if (explicitMode === "control") return "control";
|
||
if (explicitMode === "worker") return "worker";
|
||
if (String(actionCode || "").includes("control")) return "control";
|
||
if (String(actionCode || "").includes("worker")) return "worker";
|
||
return fallback === "control" ? "control" : "worker";
|
||
};
|
||
|
||
const buildSmartRolloutSuccessMessage = (modeLabel: string, releaseCreated: boolean, fallbackMessage = "") =>
|
||
fallbackMessage || (releaseCreated ? `已基于最新发布包创建 Release,并生成${modeLabel}。` : `已为当前 Release 生成${modeLabel}。`);
|
||
|
||
const buildSmartRolloutPrefillInfoMessage = (modeLabel: string, releaseCreated: boolean) =>
|
||
releaseCreated
|
||
? `Release 已创建,已按${modeLabel}回填 Rollout 参数;你可以继续检查后手动创建。`
|
||
: `已按${modeLabel}回填当前 Release 的 Rollout 参数;你可以继续检查后手动创建。`;
|
||
|
||
const buildSmartRolloutFallbackWarningMessage = (modeLabel: string, releaseCreated: boolean, fallbackMessage = "") =>
|
||
fallbackMessage ||
|
||
(releaseCreated
|
||
? `Release 已创建,但当前无法直接完成${modeLabel};已为你展开 Rollout 窗口继续处理。`
|
||
: `当前无法直接完成${modeLabel};已为你展开 Rollout 窗口继续处理。`);
|
||
|
||
const handleDriverSmartReleaseRolloutResult = async (
|
||
actionCode: string,
|
||
nodeCodes: string[] = [],
|
||
actionPayload: GenericRecord = {},
|
||
response: GenericRecord = {}
|
||
) => {
|
||
const result = (response?.data?.smart_release_result || {}) as GenericRecord;
|
||
const release = (result.release || {}) as GenericRecord;
|
||
const smartPlan = (result.smart_rollout || {}) as GenericRecord;
|
||
const preview = (result.preview || {}) as GenericRecord;
|
||
const resolvedMode = resolveSmartRolloutModeFromAction(actionCode, actionPayload, String(smartPlan.mode || "worker"));
|
||
const modeLabel = smartRolloutModeLabel(resolvedMode as "worker" | "control");
|
||
const releaseCreated = Boolean(result.release_created);
|
||
|
||
await syncReleaseStateAfterBackendFlow(release);
|
||
|
||
if (result.rollout_created) {
|
||
rolloutDialogVisible.value = false;
|
||
rolloutPreview.value = {};
|
||
rolloutSmartPrefillHint.value = "";
|
||
await Promise.all([loadJobs(), refreshInspectionPanel(), loadPlaybookRuns(), loadActivityStream(), loadNodes()]);
|
||
ElMessage.success(buildSmartRolloutSuccessMessage(modeLabel, releaseCreated, String(response.message || "").trim()));
|
||
return true;
|
||
}
|
||
|
||
if (result.requires_confirmation && !Boolean(actionPayload?.confirm_risky)) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
buildRolloutGateDialogText(
|
||
preview.release_gate || {},
|
||
[
|
||
...((preview.warnings || []) as string[]),
|
||
preview.approval_required ? "本次智能 Rollout 创建后仍需要审批。" : "",
|
||
].filter(Boolean)
|
||
),
|
||
`${modeLabel}确认`,
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "继续创建",
|
||
cancelButtonText: "返回检查",
|
||
}
|
||
);
|
||
await runDriverActionCode(actionCode, nodeCodes, {
|
||
...actionPayload,
|
||
confirm_risky: true,
|
||
});
|
||
return true;
|
||
} catch {
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.info(buildSmartRolloutPrefillInfoMessage(modeLabel, releaseCreated));
|
||
return true;
|
||
}
|
||
}
|
||
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.warning(buildSmartRolloutFallbackWarningMessage(modeLabel, releaseCreated, String(response.message || "").trim()));
|
||
return true;
|
||
};
|
||
|
||
const createReleaseFromLatestPackageAndCreateSmartRollout = async (mode: "worker" | "control") => {
|
||
const actionKey = mode;
|
||
const modeLabel = smartRolloutModeLabel(mode);
|
||
if (!latestReleasePackageReleaseActionAllowed.value) {
|
||
ElMessage.warning(
|
||
latestReleasePackageReleaseActionBlockMessage.value ||
|
||
`当前最新发布包尚未完成最终签收,暂不允许直接发起${modeLabel}。`
|
||
);
|
||
return;
|
||
}
|
||
loading.value.releasePackageSmartRolloutKey = actionKey;
|
||
try {
|
||
let confirmRisky = false;
|
||
while (true) {
|
||
const response = await opsApi.createReleaseFromLatestPackageAndSmartRollout({
|
||
mode,
|
||
confirm_risky: confirmRisky,
|
||
release_version: releaseForm.value.release_version.trim(),
|
||
channel: releaseForm.value.channel,
|
||
status: releaseForm.value.status,
|
||
commit_sha: releaseForm.value.commit_sha.trim(),
|
||
checksum: releaseForm.value.checksum.trim(),
|
||
notes: releaseForm.value.notes.trim(),
|
||
metadata: releaseForm.value.metadata || {},
|
||
control_plane_base_url: controlPlaneBaseUrl.value,
|
||
created_by: "web-ui/package-import",
|
||
rollout_created_by: "web-ui/smart-rollout",
|
||
});
|
||
const data = (response?.data || {}) as GenericRecord;
|
||
const release = (data.release || {}) as GenericRecord;
|
||
const smartPlan = (data.smart_rollout || {}) as GenericRecord;
|
||
const preview = (data.preview || {}) as GenericRecord;
|
||
await syncReleaseStateAfterBackendFlow(release);
|
||
|
||
if (data.rollout_created) {
|
||
rolloutDialogVisible.value = false;
|
||
rolloutPreview.value = {};
|
||
rolloutSmartPrefillHint.value = "";
|
||
ElMessage.success(response.message || `已基于最新发布包创建 Release,并生成${modeLabel}。`);
|
||
return;
|
||
}
|
||
|
||
if (data.requires_confirmation && !confirmRisky) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
buildRolloutGateDialogText(
|
||
preview.release_gate || {},
|
||
[
|
||
...((preview.warnings || []) as string[]),
|
||
preview.approval_required ? "本次智能 Rollout 创建后仍需要审批。" : "",
|
||
].filter(Boolean)
|
||
),
|
||
`${modeLabel}确认`,
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "继续创建",
|
||
cancelButtonText: "返回检查",
|
||
}
|
||
);
|
||
confirmRisky = true;
|
||
continue;
|
||
} catch {
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.info(`Release 已创建,已按${modeLabel}回填 Rollout 参数;你可以继续检查后手动创建。`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.warning(
|
||
response.message ||
|
||
`Release 已创建,但当前无法直接完成${modeLabel};已为你展开 Rollout 窗口继续处理。`
|
||
);
|
||
return;
|
||
}
|
||
} finally {
|
||
loading.value.releasePackageSmartRolloutKey = "";
|
||
}
|
||
};
|
||
|
||
const createSmartRolloutForRelease = async (mode: "worker" | "control", releaseInput?: GenericRecord) => {
|
||
const releaseId = Number(releaseInput?.id || selectedReleaseId.value || 0);
|
||
if (!releaseId) {
|
||
ElMessage.warning("请先选择目标 Release");
|
||
return;
|
||
}
|
||
const modeLabel = smartRolloutModeLabel(mode);
|
||
loading.value.releaseSmartRolloutKey = `release-${releaseId}`;
|
||
try {
|
||
let confirmRisky = false;
|
||
while (true) {
|
||
const response = await opsApi.createSmartRolloutForRelease(releaseId, {
|
||
mode,
|
||
confirm_risky: confirmRisky,
|
||
created_by: "web-ui/release-smart-rollout",
|
||
rollout_created_by: "web-ui/release-smart-rollout",
|
||
});
|
||
const data = (response?.data || {}) as GenericRecord;
|
||
const release = (data.release || releaseInput || findReleaseById(releaseId)) as GenericRecord;
|
||
const smartPlan = (data.smart_rollout || {}) as GenericRecord;
|
||
const preview = (data.preview || {}) as GenericRecord;
|
||
await syncReleaseStateAfterBackendFlow(release);
|
||
|
||
if (data.rollout_created) {
|
||
rolloutDialogVisible.value = false;
|
||
rolloutPreview.value = {};
|
||
rolloutSmartPrefillHint.value = "";
|
||
await Promise.all([loadJobs(), refreshInspectionPanel(), loadPlaybookRuns(), loadActivityStream(), loadNodes()]);
|
||
ElMessage.success(buildSmartRolloutSuccessMessage(modeLabel, false, String(response.message || "").trim()));
|
||
return;
|
||
}
|
||
|
||
if (data.requires_confirmation && !confirmRisky) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
buildRolloutGateDialogText(
|
||
preview.release_gate || {},
|
||
[
|
||
...((preview.warnings || []) as string[]),
|
||
preview.approval_required ? "本次智能 Rollout 创建后仍需要审批。" : "",
|
||
].filter(Boolean)
|
||
),
|
||
`${modeLabel}确认`,
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "继续创建",
|
||
cancelButtonText: "返回检查",
|
||
}
|
||
);
|
||
confirmRisky = true;
|
||
continue;
|
||
} catch {
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.info(buildSmartRolloutPrefillInfoMessage(modeLabel, false));
|
||
return;
|
||
}
|
||
}
|
||
|
||
await openRolloutDialogFromServerPlan(release, smartPlan, preview);
|
||
ElMessage.warning(buildSmartRolloutFallbackWarningMessage(modeLabel, false, String(response.message || "").trim()));
|
||
return;
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || `${modeLabel}创建失败`);
|
||
} finally {
|
||
loading.value.releaseSmartRolloutKey = "";
|
||
}
|
||
};
|
||
|
||
const handleReleaseSmartRolloutCommand = async (command: string, releaseInput?: GenericRecord) => {
|
||
const normalizedCommand = String(command || "").trim().toLowerCase();
|
||
const mode = normalizedCommand === "control" ? "control" : "worker";
|
||
await createSmartRolloutForRelease(mode, releaseInput);
|
||
};
|
||
|
||
const createRollout = async () => {
|
||
const targetReleaseId = Number(rolloutTargetReleaseId.value || 0);
|
||
if (!targetReleaseId) {
|
||
ElMessage.warning("请先选择目标 Release");
|
||
return false;
|
||
}
|
||
const nodeCodes = splitTextList(rolloutForm.value.node_codes_text);
|
||
if (!nodeCodes.length && !rolloutForm.value.region && !rolloutForm.value.role) {
|
||
ElMessage.warning("请至少填写目标节点,或选择地域/角色过滤条件");
|
||
return false;
|
||
}
|
||
const preview = await requestRolloutPreview({ silent: true });
|
||
if (!preview) {
|
||
ElMessage.warning("请先完成 rollout 预检");
|
||
return false;
|
||
}
|
||
if (preview.blocked) {
|
||
await ElMessageBox.alert(
|
||
buildRolloutGateDialogText(preview.release_gate || {}, preview.blocking_reasons || []),
|
||
"Rollout 预检未通过",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "我知道了"
|
||
}
|
||
);
|
||
return false;
|
||
}
|
||
if ((preview.warnings || []).length || preview.approval_required) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
buildRolloutGateDialogText(
|
||
preview.release_gate || {},
|
||
[
|
||
...(preview.warnings || []),
|
||
preview.approval_required ? "本次 Rollout 创建后仍需要审批。" : ""
|
||
].filter(Boolean)
|
||
),
|
||
"创建前确认",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "继续创建",
|
||
cancelButtonText: "返回检查"
|
||
}
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
loading.value.rolloutCreate = true;
|
||
try {
|
||
const response = await opsApi.createRollout(targetReleaseId, {
|
||
created_by: "web-ui",
|
||
target_selector: buildRolloutTargetSelector(),
|
||
policy: buildRolloutPolicy()
|
||
});
|
||
rolloutDialogVisible.value = false;
|
||
selectedReleaseId.value = targetReleaseId;
|
||
await Promise.all([
|
||
loadOverview(),
|
||
loadJobs(),
|
||
loadActivityStream(),
|
||
loadRollouts(targetReleaseId),
|
||
loadReleaseLaunchpad({ silent: true }),
|
||
]);
|
||
resetRolloutForm();
|
||
rolloutPreview.value = {};
|
||
ElMessage.success(response.message || "Rollout 创建成功");
|
||
return true;
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "Rollout 创建失败");
|
||
return false;
|
||
} finally {
|
||
loading.value.rolloutCreate = false;
|
||
}
|
||
};
|
||
|
||
const approveJob = async (row: GenericRecord) => {
|
||
loading.value.jobActionKey = `approve-${row.id}`;
|
||
try {
|
||
const response = await opsApi.approveJob(Number(row.id), { approved_by: "web-ui" });
|
||
ElMessage.success(response.message || "审批成功");
|
||
await Promise.all([loadJobs(), loadOverview(), loadActivityStream(), selectedReleaseId.value ? loadRollouts() : Promise.resolve()]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "审批失败");
|
||
} finally {
|
||
loading.value.jobActionKey = "";
|
||
}
|
||
};
|
||
|
||
const dispatchJob = async (row: GenericRecord) => {
|
||
loading.value.jobActionKey = `dispatch-${row.id}`;
|
||
try {
|
||
const response = await opsApi.dispatchJob(Number(row.id));
|
||
ElMessage.success(response.message || "派发成功");
|
||
await Promise.all([loadJobs(), loadOverview(), loadActivityStream(), selectedReleaseId.value ? loadRollouts() : Promise.resolve()]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "派发失败");
|
||
} finally {
|
||
loading.value.jobActionKey = "";
|
||
}
|
||
};
|
||
|
||
const cancelJob = async (row: GenericRecord) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确认取消任务 ${row.job_code || row.id} 吗?`, "取消任务", {
|
||
type: "warning",
|
||
confirmButtonText: "确认取消",
|
||
cancelButtonText: "关闭"
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
loading.value.jobActionKey = `cancel-${row.id}`;
|
||
try {
|
||
const response = await opsApi.cancelJob(Number(row.id), {
|
||
cancelled_by: "web-ui",
|
||
reason: "operator cancelled from ops center"
|
||
});
|
||
ElMessage.success(response.message || "取消成功");
|
||
await Promise.all([loadJobs(), loadOverview(), loadActivityStream(), selectedReleaseId.value ? loadRollouts() : Promise.resolve()]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "取消失败");
|
||
} finally {
|
||
loading.value.jobActionKey = "";
|
||
}
|
||
};
|
||
|
||
const openJobEvents = async (row: GenericRecord) => {
|
||
const normalizedJobId = Number(row?.id || 0);
|
||
if (!normalizedJobId) {
|
||
ElMessage.warning("当前缺少任务 ID,无法读取事件");
|
||
return;
|
||
}
|
||
await openJobDetail(row, { showDrawer: false, silent: true });
|
||
jobEventsVisible.value = true;
|
||
jobEvents.value = [];
|
||
jobEventsSummary.value = {};
|
||
try {
|
||
const response = await opsApi.jobEvents(normalizedJobId, { limit: 100 });
|
||
jobEvents.value = response.data?.events || [];
|
||
jobEventsSummary.value = response.data?.summary || {};
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "读取任务事件失败");
|
||
}
|
||
};
|
||
|
||
const openActivityItem = async (row: GenericRecord) => {
|
||
const intent = row?.ui_intent || {};
|
||
if (await applyDriverUiIntent(intent)) {
|
||
return;
|
||
}
|
||
const kind = String(row?.kind || "").trim();
|
||
if (kind === "playbook_run") {
|
||
await openPlaybookRunDetailByCode(String(row?.run_code || ""));
|
||
return;
|
||
}
|
||
if (kind === "rollout") {
|
||
await openRolloutJobsById(Number(row?.rollout_id || 0));
|
||
return;
|
||
}
|
||
await openJobDetail({
|
||
id: Number(row?.job_id || 0),
|
||
job_code: String(row?.job_code || ""),
|
||
action: String(row?.title || row?.action || ""),
|
||
target_node_code: "",
|
||
});
|
||
};
|
||
|
||
const openRolloutJobsById = async (rolloutId: number) => {
|
||
const normalizedRolloutId = Number(rolloutId || 0);
|
||
if (!normalizedRolloutId) {
|
||
ElMessage.warning("当前缺少 rollout_id,无法打开任务列表");
|
||
return;
|
||
}
|
||
try {
|
||
const response = await opsApi.rolloutDetail(normalizedRolloutId);
|
||
await openRolloutJobs(response.data || { id: normalizedRolloutId });
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "读取 rollout 详情失败");
|
||
}
|
||
};
|
||
|
||
const openRolloutJobs = async (row: GenericRecord) => {
|
||
activeRollout.value = row;
|
||
rolloutJobsVisible.value = true;
|
||
try {
|
||
const response = await opsApi.rolloutJobs(Number(row.id), { limit: 200 });
|
||
rolloutJobs.value = response.data?.jobs || [];
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "读取 rollout 任务失败");
|
||
}
|
||
};
|
||
|
||
const openJobDetail = async (
|
||
row: GenericRecord,
|
||
options: {
|
||
showDrawer?: boolean;
|
||
silent?: boolean;
|
||
} = {}
|
||
) => {
|
||
const { showDrawer = true, silent = false } = options;
|
||
const normalizedJobId = Number(row?.id || 0);
|
||
if (!normalizedJobId) {
|
||
if (!silent) {
|
||
ElMessage.warning("当前缺少任务 ID,无法读取详情");
|
||
}
|
||
return null;
|
||
}
|
||
if (showDrawer) {
|
||
jobDetailVisible.value = true;
|
||
}
|
||
activeJob.value = {
|
||
...(activeJob.value || {}),
|
||
...(row || {}),
|
||
};
|
||
return loadJobDetail(normalizedJobId, { silent });
|
||
};
|
||
|
||
const refreshActiveJobDetail = async () => {
|
||
const normalizedJobId = Number(activeJob.value?.id || 0);
|
||
if (!normalizedJobId) {
|
||
return;
|
||
}
|
||
await loadJobDetail(normalizedJobId);
|
||
};
|
||
|
||
const advanceRollout = async (row: GenericRecord) => {
|
||
const previewPayload = buildAdvanceRolloutPreviewPayload(row);
|
||
if (!previewPayload) {
|
||
ElMessage.info("当前 rollout 没有可推进的下一批目标");
|
||
return;
|
||
}
|
||
let preview: GenericRecord = {};
|
||
loading.value.rolloutPreview = true;
|
||
try {
|
||
const response = await opsApi.previewPolicy(previewPayload);
|
||
preview = response.data || {};
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "推进前预检失败");
|
||
return;
|
||
} finally {
|
||
loading.value.rolloutPreview = false;
|
||
}
|
||
if (preview.blocked) {
|
||
await ElMessageBox.alert(
|
||
buildRolloutGateDialogText(preview.release_gate || {}, preview.blocking_reasons || []),
|
||
"推进受阻",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "我知道了"
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
if ((preview.warnings || []).length || preview.approval_required) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
buildRolloutGateDialogText(
|
||
preview.release_gate || {},
|
||
[
|
||
...(preview.warnings || []),
|
||
preview.approval_required ? "推进后可能仍需要审批才能继续落地。" : ""
|
||
].filter(Boolean)
|
||
),
|
||
"推进前确认",
|
||
{
|
||
type: "warning",
|
||
confirmButtonText: "继续推进",
|
||
cancelButtonText: "返回检查"
|
||
}
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
}
|
||
loading.value.rolloutActionKey = `advance-${row.id}`;
|
||
try {
|
||
const response = await opsApi.advanceRollout(Number(row.id), { created_by: "web-ui" });
|
||
ElMessage.success(response.message || "已推进下一批");
|
||
await Promise.all([loadJobs(), loadOverview(), loadActivityStream(), loadRollouts()]);
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || "推进失败");
|
||
} finally {
|
||
loading.value.rolloutActionKey = "";
|
||
}
|
||
};
|
||
|
||
const jobStatusType = (status: string) => {
|
||
if (status === "success") return "success";
|
||
if (status === "failed" || status === "blocked" || status === "cancelled") return "danger";
|
||
if (status === "awaiting_approval" || status === "dispatching" || status === "running") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const inspectionJobLabel = (job: GenericRecord | null | undefined) => {
|
||
const status = String(job?.status || "").trim();
|
||
if (!status) return "未收口";
|
||
if (status === "success") return "成功";
|
||
if (status === "failed") return "失败";
|
||
if (status === "blocked") return "阻断";
|
||
if (status === "cancelled") return "已取消";
|
||
if (status === "awaiting_approval") return "待审批";
|
||
if (status === "dispatching") return "待派发";
|
||
if (status === "running") return "执行中";
|
||
if (status === "queued") return "排队中";
|
||
return status;
|
||
};
|
||
|
||
const inspectionJobTagType = (job: GenericRecord | null | undefined) => {
|
||
const status = String(job?.status || "").trim();
|
||
if (!status) return "info";
|
||
return jobStatusType(status);
|
||
};
|
||
|
||
const inspectionOverallLabel = (status: string) => {
|
||
if (status === "healthy") return "健康";
|
||
if (status === "running") return "收口中";
|
||
if (status === "attention") return "待关注";
|
||
return "未巡检";
|
||
};
|
||
|
||
const inspectionOverallTagType = (status: string) => {
|
||
if (status === "healthy") return "success";
|
||
if (status === "running") return "warning";
|
||
if (status === "attention") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const riskType = (riskLevel: string) => {
|
||
if (riskLevel === "critical") return "danger";
|
||
if (riskLevel === "high") return "warning";
|
||
if (riskLevel === "medium") return "";
|
||
return "info";
|
||
};
|
||
|
||
const releaseStatusType = (status: string) => {
|
||
if (status === "active") return "success";
|
||
if (status === "ready") return "warning";
|
||
if (status === "draft") return "info";
|
||
return "";
|
||
};
|
||
|
||
const rolloutStatusType = (status: string) => {
|
||
if (status === "completed") return "success";
|
||
if (status === "completed_with_issues" || status === "ready_for_next_batch" || status === "running" || status === "awaiting_approval") {
|
||
return "warning";
|
||
}
|
||
if (status === "failed" || status === "halted") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const capabilityType = (state: string) => {
|
||
if (state === "ready") return "success";
|
||
if (state === "partial" || state === "in_progress") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeAgentStateType = (state: string) => {
|
||
if (state === "online" || state === "online_busy") return "success";
|
||
if (state === "stale" || state === "runtime_only" || state === "pending_bootstrap") return "warning";
|
||
if (state === "token_expired" || state === "token_disabled") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueLabel = (row: GenericRecord) => {
|
||
const label = String(row?.delivery_queue_label || "").trim();
|
||
if (label) return label;
|
||
if (!Boolean(row?.is_agent_online)) return "未上报";
|
||
return "正常";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueType = (row: GenericRecord) => {
|
||
const state = String(row?.delivery_queue_state || "").trim();
|
||
if (state === "dead_letter") return "danger";
|
||
if (state === "retrying") return "warning";
|
||
if (state === "healthy") return "success";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueReason = (row: GenericRecord) => {
|
||
const reason = String(row?.delivery_queue_reason || "").trim();
|
||
if (reason) return reason;
|
||
if (!Boolean(row?.is_agent_online)) {
|
||
return "当前节点还没有可用的 Agent 在线心跳,因此控制面还拿不到回执队列摘要。";
|
||
}
|
||
return "当前没有待重试回执,也没有死信记录。";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueMeta = (row: GenericRecord) => {
|
||
const pendingCount = Number(row?.delivery_queue_pending_count || 0);
|
||
const deadLetterCount = Number(row?.delivery_queue_dead_letter_count || 0);
|
||
const oldestPendingAt = String(row?.delivery_queue_oldest_pending_at || "").trim();
|
||
const oldestDeadLetterAt = String(row?.delivery_queue_oldest_dead_letter_at || "").trim();
|
||
if (deadLetterCount > 0 && oldestDeadLetterAt) return `最早死信 ${oldestDeadLetterAt}`;
|
||
if (pendingCount > 0 && oldestPendingAt) return `最早积压 ${oldestPendingAt}`;
|
||
return "";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueRecordId = (row: GenericRecord) =>
|
||
String(row?.record_id || row?.client_request_id || row?.record_key || "").trim();
|
||
|
||
const managedNodeDeliveryQueueRecordStateLabel = (row: GenericRecord) => {
|
||
const state = String(row?.state || "").trim();
|
||
if (state === "pending") return "待重试";
|
||
if (state === "dead_letter") return "死信";
|
||
return state || "未知";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueRecordStateType = (row: GenericRecord) => {
|
||
const state = String(row?.state || "").trim();
|
||
if (state === "dead_letter") return "danger";
|
||
if (state === "pending") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeDeliveryQueueRecordTimeText = (row: GenericRecord) =>
|
||
String(row?.dead_letter_at || row?.created_at || "").trim() || "-";
|
||
|
||
const managedNodeManagedLabel = (row: GenericRecord) => {
|
||
if (!row.is_managed) return "未纳管";
|
||
return row.is_enabled ? "已启用" : "已停用";
|
||
};
|
||
|
||
const managedNodeManagedTagType = (row: GenericRecord) => {
|
||
if (!row.is_managed) return "warning";
|
||
return row.is_enabled ? "success" : "info";
|
||
};
|
||
|
||
const managedNodeSshEndpoint = (row: GenericRecord) =>
|
||
`${String(row?.ssh_user || "-").trim() || "-"}@${String(row?.ssh_host || "-").trim() || "-"}:${Number(row?.ssh_port || 22) || 22}`;
|
||
|
||
const managedNodeSshAccessType = (row: GenericRecord) => {
|
||
if (Boolean(row?.has_ssh_access)) return "success";
|
||
const label = String(row?.ssh_access_label || "").trim();
|
||
if (label.includes("待完善")) return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeClusterStateLabel = (row: GenericRecord) => {
|
||
const clusterStatus = String(row.cluster_status || "").trim();
|
||
const currentLoad = Number(row.cluster_current_load || 0);
|
||
if (clusterStatus === "busy") {
|
||
return `忙碌 / 负载 ${currentLoad}`;
|
||
}
|
||
if (row.cluster_detect_participating) {
|
||
return currentLoad > 0 ? `参与检测 / ${currentLoad}` : "参与检测";
|
||
}
|
||
if (clusterStatus === "online") {
|
||
return row.cluster_is_effective_worker ? "运行中" : "控制面在线";
|
||
}
|
||
if (clusterStatus) {
|
||
return clusterStatus;
|
||
}
|
||
return "未上报";
|
||
};
|
||
|
||
const managedNodeClusterStateType = (row: GenericRecord) => {
|
||
const clusterStatus = String(row.cluster_status || "").trim();
|
||
if (clusterStatus === "busy" || row.cluster_detect_participating) return "warning";
|
||
if (clusterStatus === "online") return "success";
|
||
if (clusterStatus === "offline") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeParticipationRow = (row: GenericRecord) =>
|
||
executionNodeStateMap.value[String(row?.node_code || "").trim()] || {};
|
||
|
||
const managedNodeParticipationLabel = (row: GenericRecord) => {
|
||
const participationRow = managedNodeParticipationRow(row);
|
||
if (String(participationRow?.participation_label || "").trim()) {
|
||
return String(participationRow.participation_label || "").trim();
|
||
}
|
||
const clusterStatus = String(row?.cluster_status || "").trim();
|
||
if (row?.cluster_detect_participating) {
|
||
return "参与检测";
|
||
}
|
||
if (Boolean(row?.cluster_is_effective_worker) && ["online", "busy"].includes(clusterStatus)) {
|
||
return "在线待命";
|
||
}
|
||
if (clusterStatus === "online" && String(row?.role || "").trim() === "control") {
|
||
return "控制面在线";
|
||
}
|
||
if (clusterStatus === "offline") {
|
||
return "节点离线";
|
||
}
|
||
return "未进入现场";
|
||
};
|
||
|
||
const managedNodeParticipationType = (row: GenericRecord) => {
|
||
const participationRow = managedNodeParticipationRow(row);
|
||
if (String(participationRow?.participation_state || "").trim()) {
|
||
return executionNodeStateTagType(participationRow);
|
||
}
|
||
const clusterStatus = String(row?.cluster_status || "").trim();
|
||
if (row?.cluster_detect_participating) return "warning";
|
||
if (Boolean(row?.cluster_is_effective_worker) && ["online", "busy"].includes(clusterStatus)) return "info";
|
||
if (clusterStatus === "online" && String(row?.role || "").trim() === "control") return "success";
|
||
if (clusterStatus === "offline") return "danger";
|
||
return "info";
|
||
};
|
||
|
||
const managedNodeParticipationReason = (row: GenericRecord) => {
|
||
const participationRow = managedNodeParticipationRow(row);
|
||
if (String(participationRow?.participation_reason || "").trim()) {
|
||
return String(participationRow.participation_reason || "").trim();
|
||
}
|
||
const clusterStatus = String(row?.cluster_status || "").trim();
|
||
if (row?.cluster_detect_participating) {
|
||
return "节点已被控制面标记为参与检测,但执行现场细分状态还没刷新到当前列表。";
|
||
}
|
||
if (Boolean(row?.cluster_is_effective_worker) && ["online", "busy"].includes(clusterStatus)) {
|
||
return "节点在线,但当前未观察到执行中、已领待跑或近窗吞吐,属于在线待命。";
|
||
}
|
||
if (clusterStatus === "online" && String(row?.role || "").trim() === "control") {
|
||
return "当前为控制面节点,本身不一定承担检测执行;是否兼跑由执行现场和运行态决定。";
|
||
}
|
||
if (clusterStatus === "offline") {
|
||
return "当前没有在线心跳,无法纳入执行现场。";
|
||
}
|
||
return "当前还没有形成可用的执行现场参与数据。";
|
||
};
|
||
|
||
const managedNodeHeartbeatText = (row: GenericRecord) =>
|
||
String(
|
||
row.last_seen_at ||
|
||
row.cluster_last_heartbeat_at ||
|
||
row.metadata?.last_seen_at ||
|
||
row.metadata?.last_heartbeat_at ||
|
||
"-"
|
||
);
|
||
|
||
const managedNodeLatestJobText = (row: GenericRecord) => {
|
||
const latestJob = row.latest_job || {};
|
||
if (latestJob.job_code) {
|
||
return `${latestJob.job_code} / ${latestJob.status || "-"}`;
|
||
}
|
||
if (row.cluster_detect_participating) {
|
||
return `节点在线,当前${managedNodeParticipationLabel(row)}`;
|
||
}
|
||
if (row.cluster_status === "online" || row.cluster_status === "busy") {
|
||
return `节点在线,${managedNodeParticipationLabel(row)}`;
|
||
}
|
||
return "-";
|
||
};
|
||
|
||
const agentOnboardingJobMatchesStep = (job: GenericRecord, step: GenericRecord) => {
|
||
if (String(job.target_node_code || "").trim() !== agentTargetNodeCode.value) {
|
||
return false;
|
||
}
|
||
if (String(job.action || "").trim() !== String(step.key || "").trim()) {
|
||
return false;
|
||
}
|
||
const payload = job.payload || {};
|
||
const expectedServiceName = String(step.payload?.service_name || "").trim();
|
||
if (!expectedServiceName) {
|
||
return true;
|
||
}
|
||
return String(payload.service_name || "").trim() === expectedServiceName;
|
||
};
|
||
|
||
const findAgentOnboardingStepJob = (step: GenericRecord) =>
|
||
agentOnboardingRecentJobs.value.find((job) => agentOnboardingJobMatchesStep(job, step)) || {};
|
||
|
||
const agentOnboardingStepStatusLabel = (step: GenericRecord) => {
|
||
const job = findAgentOnboardingStepJob(step);
|
||
if (job.job_code) {
|
||
return String(job.status || "unknown");
|
||
}
|
||
return agentOnboardingReadyForOps.value ? "待执行" : "等待接管";
|
||
};
|
||
|
||
const agentOnboardingStepStatusType = (step: GenericRecord) => {
|
||
const job = findAgentOnboardingStepJob(step);
|
||
if (job.job_code) {
|
||
return jobStatusType(String(job.status || ""));
|
||
}
|
||
return agentOnboardingReadyForOps.value ? "info" : "warning";
|
||
};
|
||
|
||
const eventLevelType = (level: string) => {
|
||
if (level === "error") return "danger";
|
||
if (level === "warning") return "warning";
|
||
return "info";
|
||
};
|
||
|
||
const canCancelJob = (status: string) => ["queued", "dispatching", "running", "awaiting_approval"].includes(status);
|
||
|
||
const canAdvanceRollout = (row: GenericRecord) => {
|
||
const summary = row.result_summary || {};
|
||
return Number(summary.remaining_targets || 0) > 0 && Number(summary.active_jobs || 0) === 0 && !summary.halted;
|
||
};
|
||
|
||
let agentOnboardingTimer: number | null = null;
|
||
|
||
const stopAgentOnboardingPolling = () => {
|
||
if (agentOnboardingTimer !== null && typeof window !== "undefined") {
|
||
window.clearInterval(agentOnboardingTimer);
|
||
agentOnboardingTimer = null;
|
||
}
|
||
};
|
||
|
||
const startAgentOnboardingPolling = () => {
|
||
stopAgentOnboardingPolling();
|
||
if (typeof window === "undefined" || !agentTokenVisible.value || !agentTargetNodeCode.value) {
|
||
return;
|
||
}
|
||
agentOnboardingTimer = window.setInterval(() => {
|
||
void refreshAgentOnboardingState({ silent: true });
|
||
}, 5000);
|
||
};
|
||
|
||
watch(
|
||
() => agentTokenVisible.value,
|
||
(visible) => {
|
||
if (visible) {
|
||
startAgentOnboardingPolling();
|
||
void refreshAgentOnboardingState({ silent: true });
|
||
return;
|
||
}
|
||
stopAgentOnboardingPolling();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => agentTargetNodeCode.value,
|
||
() => {
|
||
if (!agentTokenVisible.value) {
|
||
return;
|
||
}
|
||
startAgentOnboardingPolling();
|
||
void refreshAgentOnboardingState({ silent: true });
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => actionTemplatePreviewSignature.value,
|
||
() => {
|
||
actionTemplatePreview.value = {};
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => [actionTemplateForm.value.template_key, JSON.stringify(actionTemplateForm.value.target_node_codes || [])],
|
||
() => {
|
||
if (!actionTemplateForm.value.template_key) {
|
||
return;
|
||
}
|
||
syncActionTemplateExecutionModeRecommendation();
|
||
}
|
||
);
|
||
|
||
watch(
|
||
() => [playbookForm.value.playbook_key, JSON.stringify(playbookForm.value.target_node_codes || [])],
|
||
() => {
|
||
if (!playbookForm.value.playbook_key) {
|
||
return;
|
||
}
|
||
syncPlaybookExecutionModeRecommendation();
|
||
}
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
stopAgentOnboardingPolling();
|
||
if (focusedRunbookSequenceTimer) {
|
||
window.clearTimeout(focusedRunbookSequenceTimer);
|
||
focusedRunbookSequenceTimer = null;
|
||
}
|
||
});
|
||
|
||
onMounted(async () => {
|
||
await loadAll();
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.summary-card,
|
||
.mini-card {
|
||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||
border-radius: 16px;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||
padding: 16px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-height: 120px;
|
||
}
|
||
|
||
.summary-card strong,
|
||
.mini-card strong {
|
||
font-size: 22px;
|
||
line-height: 1.2;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 12px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: #64748b;
|
||
}
|
||
|
||
.summary-note {
|
||
color: #475569;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.summary-card__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.summary-card__actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.token-alert-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.token-preview {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.driver-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.driver-card {
|
||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.96));
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-height: 220px;
|
||
}
|
||
|
||
.driver-card--focused {
|
||
border-color: rgba(14, 116, 144, 0.48);
|
||
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.18);
|
||
}
|
||
|
||
.driver-card__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.driver-card__summary {
|
||
color: #334155;
|
||
line-height: 1.6;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.driver-card__meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.driver-card__preview {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.driver-card__actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.panel-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 18px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.template-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.template-card {
|
||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
background: rgba(248, 250, 252, 0.8);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-height: 168px;
|
||
}
|
||
|
||
.template-card__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.template-card__desc {
|
||
color: #475569;
|
||
line-height: 1.6;
|
||
flex: 1;
|
||
}
|
||
|
||
.template-card__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 12px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.template-card__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.fleet-shortcut-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.fleet-shortcut-card {
|
||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
background: rgba(248, 250, 252, 0.82);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-height: 206px;
|
||
}
|
||
|
||
.fleet-shortcut-card__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.fleet-shortcut-card__desc {
|
||
color: #475569;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.fleet-shortcut-card__meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.fleet-shortcut-card__targets {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.fleet-shortcut-card__targets-label {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.fleet-shortcut-card__targets code {
|
||
display: block;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
border-radius: 12px;
|
||
background: rgba(15, 23, 42, 0.06);
|
||
padding: 10px 12px;
|
||
color: #0f172a;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.fleet-shortcut-card__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.ops-scene-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.inspection-summary-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.panel-filters .el-select {
|
||
width: 180px;
|
||
}
|
||
|
||
.panel-filters .el-input {
|
||
max-width: 280px;
|
||
}
|
||
|
||
.issue-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.panel,
|
||
.subpanel {
|
||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||
border-radius: 18px;
|
||
padding: 18px;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
}
|
||
|
||
.panel--release {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.panel-header,
|
||
.subpanel-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.panel-header h4,
|
||
.subpanel-header h5 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
.panel-header p {
|
||
margin: 8px 0 0;
|
||
color: #64748b;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.panel-header-note,
|
||
.subpanel-header span {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.release-selector {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.release-top-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.release-grid {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.playbook-run-filter-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: flex-end;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.playbook-run-filter-item {
|
||
min-width: 180px;
|
||
flex: 1 1 180px;
|
||
}
|
||
|
||
.playbook-run-filter-item--query {
|
||
flex: 2 1 280px;
|
||
}
|
||
|
||
.playbook-run-focus {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.playbook-run-focus__summary {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.playbook-run-chip-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.table-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.node-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.node-cell span {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.watch-hint {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
align-self: center;
|
||
}
|
||
|
||
.activity-title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.activity-subtitle,
|
||
.activity-meta {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.activity-summary {
|
||
color: #334155;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.capability-list {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.capability-item {
|
||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||
border-radius: 16px;
|
||
padding: 14px 16px;
|
||
background: rgba(248, 250, 252, 0.72);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.capability-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
}
|
||
|
||
.capability-line {
|
||
color: #475569;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.drawer-meta {
|
||
display: flex;
|
||
gap: 18px;
|
||
flex-wrap: wrap;
|
||
color: #475569;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.dialog-form :deep(.el-form-item) {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.dialog-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0 14px;
|
||
}
|
||
|
||
.dialog-grid--release,
|
||
.dialog-grid--rollout {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.full-width {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.result-panel {
|
||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||
border-radius: 16px;
|
||
background: rgba(248, 250, 252, 0.72);
|
||
padding: 16px;
|
||
}
|
||
|
||
.result-panel__head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.code-block {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.code-title {
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.code-block pre {
|
||
margin: 0;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
background: #0f172a;
|
||
color: #e2e8f0;
|
||
overflow: auto;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.switch-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.preview-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.preview-grid--triple {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
|
||
.preview-grid--stack {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.preview-box {
|
||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||
border-radius: 14px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
padding: 14px;
|
||
}
|
||
|
||
.preview-box__title {
|
||
font-size: 13px;
|
||
color: #475569;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.preview-chip-list {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.preview-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.preview-list__item {
|
||
color: #334155;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.preview-list__item--danger {
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.preview-list__item--warning {
|
||
color: #b45309;
|
||
}
|
||
|
||
.preview-list--danger .preview-list__item {
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.preview-list--warning .preview-list__item {
|
||
color: #b45309;
|
||
}
|
||
|
||
.preview-empty {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.stack-diag-line {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.stack-diag-line + .stack-diag-line {
|
||
border-top: 1px dashed rgba(148, 163, 184, 0.24);
|
||
}
|
||
|
||
.stack-diag-line__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
color: #1e293b;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.stack-diag-line__meta {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.stack-command-block {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.log-preview-shell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-height: 520px;
|
||
overflow: auto;
|
||
padding: 4px 2px;
|
||
}
|
||
|
||
.log-preview-line {
|
||
font-family: "SFMono-Regular", "JetBrains Mono", "Consolas", monospace;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
background: rgba(15, 23, 42, 0.04);
|
||
color: #1e293b;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
|
||
.field-help {
|
||
margin-top: 6px;
|
||
color: #64748b;
|
||
line-height: 1.5;
|
||
font-size: 12px;
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.summary-grid,
|
||
.driver-grid,
|
||
.ops-scene-grid,
|
||
.inspection-summary-grid,
|
||
.release-top-grid,
|
||
.panel-grid,
|
||
.template-grid,
|
||
.fleet-shortcut-grid,
|
||
.preview-grid,
|
||
.capability-list,
|
||
.dialog-grid,
|
||
.dialog-grid--release,
|
||
.dialog-grid--rollout,
|
||
.switch-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|