feat(ui): surface rollback actions in the service catalog

Add retryRollback / releaseRollback / retryCloudDMRegistration API
bindings and wire the corresponding actions and status presentation
into the delivery task view.
This commit is contained in:
Hungerdream
2026-07-28 14:29:34 +08:00
parent be39f2a03c
commit d5bf42ad01
2 changed files with 184 additions and 10 deletions
+18
View File
@@ -79,6 +79,24 @@ export const deliveryApi = {
method: 'POST', method: 'POST',
}) })
}, },
async retryRollback(taskId: string): Promise<void> {
await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}/rollback/retry`, {
method: 'POST',
})
},
async releaseRollback(taskId: string): Promise<void> {
await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}/rollback/release`, {
method: 'POST',
})
},
async retryCloudDMRegistration(taskId: string): Promise<void> {
await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}/clouddm/retry`, {
method: 'POST',
})
},
} }
function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) { function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) {
+166 -10
View File
@@ -248,7 +248,7 @@
</section> </section>
<section v-else class="result-view"> <section v-else class="result-view">
<div class="result-head" :class="{ failed: deliveryFailed }"> <div class="result-head" :class="{ failed: deliveryFailed, warning: deliveryRegisterFailed }">
<div class="result-icon">{{ deliveryFailed ? '!' : '✓' }}</div> <div class="result-icon">{{ deliveryFailed ? '!' : '✓' }}</div>
<div> <div>
<h3>{{ resultTitle }}</h3> <h3>{{ resultTitle }}</h3>
@@ -256,6 +256,27 @@
</div> </div>
</div> </div>
<div v-if="canManageRollback" class="rollback-actions">
<div>
<strong>目标机清理未确认</strong>
<p>资源目前仍被保护性占用。可重试自动回退,或确认已完成目标机清理后释放。</p>
</div>
<div class="head-actions">
<el-button :loading="rollbackRetrying" :disabled="rollbackActionBusy" @click="retryRollback">重试回退</el-button>
<el-button type="warning" :loading="rollbackReleasing" :disabled="rollbackActionBusy" @click="releaseRollback">确认清理并释放资源</el-button>
</div>
</div>
<div v-if="canRetryCloudDM" class="rollback-actions registration-actions">
<div>
<strong>实例已交付,CloudDM 注册未完成</strong>
<p>数据库健康实例和资源账本已保留,仅需重试 CloudDM 注册。</p>
</div>
<div class="head-actions">
<el-button type="warning" :loading="cloudDMRetrying" @click="retryCloudDMRegistration">重试 CloudDM 注册</el-button>
</div>
</div>
<div class="result-grid"> <div class="result-grid">
<div class="result-item"> <div class="result-item">
<span>访问地址</span> <span>访问地址</span>
@@ -295,7 +316,7 @@
<td>{{ deliveryForm.version }}</td> <td>{{ deliveryForm.version }}</td>
<td>{{ currentModeLabel }}</td> <td>{{ currentModeLabel }}</td>
<td><code>{{ resultAddress }}</code></td> <td><code>{{ resultAddress }}</code></td>
<td><span class="tag" :class="deliveryFailed ? 'tag-red' : 'tag-green'">{{ deliveryFailed ? '已回退' : '正常' }}</span></td> <td><span class="tag" :class="deliveryFailed ? (deliveryRolledBack || deliveryAcknowledged || deliveryRegisterFailed ? 'tag-amber' : 'tag-red') : 'tag-green'">{{ deliveryRolledBack ? '已回退' : deliveryAcknowledged ? '已确认释放' : deliveryRegisterFailed ? '实例保留' : deliveryFailed ? '需处理' : '正常' }}</span></td>
<td><code>{{ activeService.registerTo }}</code></td> <td><code>{{ activeService.registerTo }}</code></td>
</tr> </tr>
</tbody> </tbody>
@@ -317,10 +338,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue' import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { deliveryApi, type DeliveryTarget, type TaskEvent } from '@/api/delivery' import { deliveryApi, type DeliveryTarget, type TaskEvent } from '@/api/delivery'
import { useAuthStore } from '@/stores/auth'
import { useBusinessLineStore } from '@/stores/businessLine' import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock' import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -373,6 +395,7 @@ interface DeliveryStep {
const { currentName } = useBusinessLineMockProfile() const { currentName } = useBusinessLineMockProfile()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore()
const businessLineStore = useBusinessLineStore() const businessLineStore = useBusinessLineStore()
const basicServices = ref<Service[]>([ const basicServices = ref<Service[]>([
@@ -536,6 +559,16 @@ const targetsLoading = ref(false)
const pollTimer = ref<number | undefined>() const pollTimer = ref<number | undefined>()
const seenEventIds = ref(new Set<number>()) const seenEventIds = ref(new Set<number>())
const deliveryLog = ref('[ready] 等待创建交付任务...') const deliveryLog = ref('[ready] 等待创建交付任务...')
const lastDeliveryStatus = ref('')
const deliveryRolledBack = computed(() => lastDeliveryStatus.value === 'rolled_back')
const deliveryAcknowledged = computed(() => lastDeliveryStatus.value === 'rollback_acknowledged')
const deliveryRegisterFailed = computed(() => lastDeliveryStatus.value === 'register_failed')
const rollbackRetrying = ref(false)
const rollbackReleasing = ref(false)
const cloudDMRetrying = ref(false)
const rollbackActionBusy = computed(() => rollbackRetrying.value || rollbackReleasing.value)
const canManageRollback = computed(() => authStore.isAdmin && lastDeliveryStatus.value === 'rollback_failed' && Boolean(deploymentId.value))
const canRetryCloudDM = computed(() => deliveryRegisterFailed.value && Boolean(deploymentId.value))
const deliveryForm = reactive({ const deliveryForm = reactive({
instanceName: `mysql-${currentName.value}-billing-02`, instanceName: `mysql-${currentName.value}-billing-02`,
@@ -595,22 +628,40 @@ const runnerPreview = computed(() => {
}) })
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port}`) const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port}`)
const resultTitle = computed(() => { const resultTitle = computed(() => {
if (deliveryFailed.value) return '交付失败,已自动回退' if (deliveryRolledBack.value) return '交付失败,已完成回退'
if (deliveryAcknowledged.value) return '交付失败,已确认清理'
if (deliveryRegisterFailed.value) return 'MySQL 已交付,CloudDM 注册失败'
if (lastDeliveryStatus.value === 'rollback_failed') return '交付失败,回退失败'
if (lastDeliveryStatus.value === 'canceled') return '交付任务已取消'
if (deliveryFailed.value) return '交付失败'
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付' return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
}) })
const resultSubtitle = computed(() => { const resultSubtitle = computed(() => {
if (deliveryFailed.value) return deliveryError.value || '交付失败 · 资源已释放 · 变更未交付' if (deliveryRolledBack.value) return deliveryError.value || '目标机实例文件、配置和资源记录已清理'
if (deliveryAcknowledged.value) return deliveryError.value || '管理员已确认目标机清理完成,资源预留已释放(不代表自动回退成功)'
if (deliveryRegisterFailed.value) return deliveryError.value || '实例已通过健康检查并完成资源入账,CloudDM 注册失败;实例保留,可重试注册'
if (lastDeliveryStatus.value === 'rollback_failed') return deliveryError.value || '自动回退失败 · 目标机可能存在残留 · 需要人工处理'
if (lastDeliveryStatus.value === 'canceled') return deliveryError.value || '任务在部署完成前被取消'
if (deliveryFailed.value) return deliveryError.value || '交付失败 · 未确认目标机已清理'
return activeServiceKey.value === 'mysql' ? '全部步骤执行成功 · 用时 06:42' : '全部步骤执行成功 · 用时 02:18' return activeServiceKey.value === 'mysql' ? '全部步骤执行成功 · 用时 06:42' : '全部步骤执行成功 · 用时 02:18'
}) })
const deliveryStateText = computed(() => { const deliveryStateText = computed(() => {
if (deliveryFailed.value) return '已回退' if (lastDeliveryStatus.value === 'rollback_failed') return '回退失败'
if (deliveryRolledBack.value) return '已回退'
if (deliveryAcknowledged.value) return '已确认释放'
if (deliveryRegisterFailed.value) return '注册失败(实例保留)'
if (lastDeliveryStatus.value === 'canceled') return '已取消'
if (deliveryFailed.value) return '交付失败'
if (deliveryDone.value) return '已交付' if (deliveryDone.value) return '已交付'
if (running.value) return '交付中' if (running.value) return '交付中'
if (precheckPassed.value) return '待执行' if (precheckPassed.value) return '待执行'
return '配置中' return '配置中'
}) })
const canCancelDeployment = computed(() => Boolean(deploymentId.value) && running.value && !deliveryDone.value && !deliveryFailed.value) const canCancelDeployment = computed(() => Boolean(deploymentId.value) && ['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling'].includes(lastDeliveryStatus.value) && !deliveryDone.value && !deliveryFailed.value)
const deliveryStateClass = computed(() => { const deliveryStateClass = computed(() => {
if (lastDeliveryStatus.value === 'rollback_failed') return 'tag-red'
if (deliveryRolledBack.value || deliveryAcknowledged.value) return 'tag-amber'
if (deliveryRegisterFailed.value) return 'tag-amber'
if (deliveryFailed.value) return 'tag-red' if (deliveryFailed.value) return 'tag-red'
if (deliveryDone.value) return 'tag-green' if (deliveryDone.value) return 'tag-green'
if (precheckPassed.value || running.value) return 'tag-amber' if (precheckPassed.value || running.value) return 'tag-amber'
@@ -684,6 +735,7 @@ function resetExecutionState() {
deliveryDone.value = false deliveryDone.value = false
deliveryFailed.value = false deliveryFailed.value = false
deliveryError.value = '' deliveryError.value = ''
lastDeliveryStatus.value = ''
canceling.value = false canceling.value = false
deploymentId.value = '' deploymentId.value = ''
deliveredHost.value = '' deliveredHost.value = ''
@@ -701,6 +753,7 @@ function defaultSteps(): DeliveryStep[] {
['应用标准参数与复制配置', '基础参数、GTID、主从关系'], ['应用标准参数与复制配置', '基础参数、GTID、主从关系'],
['数据库健康检查', '端口、读写、复制延迟、参数基线'], ['数据库健康检查', '端口、读写、复制延迟、参数基线'],
['注册资产与交付归档', 'CloudDM、CMDB、Prometheus'], ['注册资产与交付归档', 'CloudDM、CMDB、Prometheus'],
['失败回退与清理', '停止实例、清理目录、释放资源'],
] ]
: [ : [
['资源锁定与配置检查', '节点、端口、证书、Ingress 冲突'], ['资源锁定与配置检查', '节点、端口、证书、Ingress 冲突'],
@@ -832,8 +885,9 @@ function appendLog(message: unknown) {
} }
function applyDeliveryStatus(status: string, message: string) { function applyDeliveryStatus(status: string, message: string) {
lastDeliveryStatus.value = status
if (message) appendLog(message) if (message) appendLog(message)
if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling'].includes(status)) { if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling', 'rollback_pending', 'rolling_back'].includes(status)) {
running.value = true running.value = true
markStepRunning() markStepRunning()
return return
@@ -845,7 +899,14 @@ function applyDeliveryStatus(status: string, message: string) {
steps.value = steps.value.map((step) => ({ ...step, state: 'done' })) steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
return return
} }
if (['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)) { if (status === 'rolled_back') {
running.value = false
deliveryDone.value = false
deliveryFailed.value = true
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
return
}
if (['execution_failed', 'validation_failed', 'register_failed', 'rollback_failed', 'rollback_acknowledged', 'canceled'].includes(status)) {
running.value = false running.value = false
deliveryDone.value = false deliveryDone.value = false
deliveryFailed.value = true deliveryFailed.value = true
@@ -898,7 +959,66 @@ function normalizeDNSLabel(value: string) {
} }
function isTerminalDeliveryStatus(status: string) { function isTerminalDeliveryStatus(status: string) {
return ['finished', 'execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status) return ['finished', 'execution_failed', 'validation_failed', 'register_failed', 'canceled', 'rolled_back', 'rollback_failed', 'rollback_acknowledged'].includes(status)
}
async function retryRollback() {
if (!deploymentId.value) return
rollbackRetrying.value = true
try {
await deliveryApi.retryRollback(deploymentId.value)
appendLog('[rollback] manual retry requested by platform admin')
activeView.value = 'execution'
startTaskPolling(deploymentId.value)
ElMessage.success('已重新发起回退')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重试回退失败')
} finally {
rollbackRetrying.value = false
}
}
async function retryCloudDMRegistration() {
if (!deploymentId.value) return
cloudDMRetrying.value = true
try {
await deliveryApi.retryCloudDMRegistration(deploymentId.value)
appendLog('[clouddm] registration retry requested')
activeView.value = 'execution'
startTaskPolling(deploymentId.value)
ElMessage.success('已重新发起 CloudDM 注册')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '重试 CloudDM 注册失败')
} finally {
cloudDMRetrying.value = false
}
}
async function releaseRollback() {
if (!deploymentId.value) return
try {
await ElMessageBox.confirm('请确认已在目标机清理实例服务、配置、数据和日志目录。该操作会释放资源记录,但不会声称自动回退成功。', '确认释放资源', {
type: 'warning',
confirmButtonText: '确认释放',
cancelButtonText: '取消',
})
} catch {
return
}
rollbackReleasing.value = true
try {
await deliveryApi.releaseRollback(deploymentId.value)
appendLog('[rollback] resource release acknowledged by platform admin')
deliveryError.value = ''
applyDeliveryStatus('rollback_acknowledged', '')
stopPolling()
activeView.value = 'result'
ElMessage.success('已确认清理,资源已释放')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '释放资源失败')
} finally {
rollbackReleasing.value = false
}
} }
function markStepRunning() { function markStepRunning() {
@@ -1353,11 +1473,42 @@ h4 {
color: #FFFFFF; color: #FFFFFF;
} }
.result-head.warning .result-icon {
background: var(--tag-amber-bg);
color: var(--tag-amber-text);
border: 1px solid var(--tag-amber-border);
}
.result-head h3 { .result-head h3 {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 16px; font-size: 16px;
} }
.rollback-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 16px 0;
padding: 14px 16px;
border: 1px solid var(--tag-amber-border);
border-radius: 8px;
background: var(--tag-amber-bg);
}
.rollback-actions strong {
display: block;
margin-bottom: 4px;
color: var(--tag-amber-text);
font-size: 13px;
}
.rollback-actions p {
margin: 0;
color: var(--text-mid);
font-size: 12px;
}
.result-grid { .result-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1499,6 +1650,11 @@ code {
flex-direction: column; flex-direction: column;
} }
.rollback-actions {
align-items: stretch;
flex-direction: column;
}
.head-actions { .head-actions {
margin-left: 0; margin-left: 0;
} }