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:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user