feat(frontend): drive delivery steps by backend stage events

- Map precheck/install/configure/healthcheck/register stage events to
  workbench step states instead of guess-based progression, and prefer
  callback-enabled AWX templates so stage events actually flow
- Replay task events silently on workbench restore to rebuild step
  states without duplicating logs; only reset seen events for new tasks
- Unify instance name generation with a random 6-char suffix (replacing
  the hardcoded -billing-02) and cap the business-line segment so the
  63-char DNS label limit never truncates the suffix
This commit is contained in:
Hungerdream
2026-07-30 15:02:52 +08:00
parent 07a661f7d1
commit 8b1ed20145
+83 -29
View File
@@ -818,7 +818,7 @@ const canManageRollback = computed(() => authStore.isAdmin && lastDeliveryStatus
const canRetryCloudDM = computed(() => deliveryRegisterFailed.value && Boolean(deploymentId.value))
const deliveryForm = reactive({
instanceName: `mysql-${currentName.value}-billing-02`,
instanceName: generateInstanceName(activeServiceKey.value, currentName.value),
version: 'MySQL 8.0',
mode: 'single',
spec: '1C / 2G',
@@ -853,6 +853,13 @@ const collationMap: Record<string, string[]> = {
}
const steps = ref<DeliveryStep[]>([])
const deliveryStageStepIndex: Record<string, number> = {
precheck: 0,
install: 1,
configure: 2,
healthcheck: 3,
register: 4,
}
const activeService = computed(() => basicServices.value.find((service) => service.key === activeServiceKey.value && !service.disabled))
const selectedTarget = computed(() => deliveryTargets.value.find((target) => target.id === selectedTargetId.value))
@@ -968,7 +975,7 @@ const deliveryStateClass = computed(() => {
})
watch(currentName, (name) => {
deliveryForm.instanceName = `${activeServiceKey.value === 'mysql' ? 'mysql' : 'nginx'}-${name}-billing-02`
deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, name)
})
watch(selectedTargetId, () => {
@@ -1120,8 +1127,11 @@ async function restoreWorkbench() {
restoringWorkbench = true
Object.assign(deliveryForm, snapshot.form)
if (!snapshot.deploymentId) {
deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, currentName.value)
}
deliveryForm.rootPassword = generateRootPassword()
precheckPassed.value = snapshot.precheckPassed
precheckPassed.value = snapshot.deploymentId ? snapshot.precheckPassed : false
running.value = snapshot.running
deliveryDone.value = snapshot.deliveryDone
deliveryFailed.value = snapshot.deliveryFailed
@@ -1145,7 +1155,8 @@ async function restoreWorkbench() {
lastDeliveryStatus.value = data.task.status
credentialAvailable.value = data.task.credential_available
deliveryError.value = data.task.error_message || deliveryError.value
seenEventIds.value = new Set(data.events.map((event) => event.id))
seenEventIds.value = new Set()
applyTaskEvents(data.events, false)
applyDeliveryStatus(data.task.status, data.task.error_message || '')
if (isTerminalDeliveryStatus(data.task.status)) {
activeView.value = 'result'
@@ -1173,8 +1184,7 @@ async function restoreWorkbench() {
function hydrateServiceDefaults() {
const service = activeService.value
if (!service) return
const prefix = service.key === 'mysql' ? 'mysql' : 'nginx'
deliveryForm.instanceName = `${prefix}-${currentName.value}-billing-02`
deliveryForm.instanceName = generateInstanceName(service.key, currentName.value)
deliveryForm.version = service.versions[0] || ''
deliveryForm.mode = service.modes[1]?.value || service.modes[0]?.value || 'single'
deliveryForm.spec = service.key === 'mysql'
@@ -1235,6 +1245,7 @@ function resetExecutionState() {
deliveredHost.value = ''
deliveredPort.value = undefined
deliveryLog.value = '[ready] 等待创建交付任务...'
seenEventIds.value = new Set()
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
}
@@ -1357,7 +1368,7 @@ async function createTask() {
upsertDeliveryHistory(task.id, task.status, task.error_message || '', task.created_at)
deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}`
applyDeliveryStatus(task.status, '')
startTaskPolling(task.id)
startTaskPolling(task.id, true)
persistWorkbench()
ElMessage.success('交付任务已创建')
} catch (error) {
@@ -1430,9 +1441,9 @@ function hostLabel(host: TargetHostOption) {
return host.ip ? `${host.name} · ${host.ip}` : host.name
}
function startTaskPolling(id: string) {
function startTaskPolling(id: string, resetEvents = false) {
stopPolling()
seenEventIds.value = new Set()
if (resetEvents) seenEventIds.value = new Set()
pollTask(id)
pollTimer.value = window.setInterval(() => pollTask(id), 3000)
}
@@ -1461,14 +1472,30 @@ async function pollTask(id: string) {
}
}
function applyTaskEvents(events: TaskEvent[]) {
function applyTaskEvents(events: TaskEvent[], writeLogs = true) {
events.forEach((event) => {
if (seenEventIds.value.has(event.id)) return
seenEventIds.value.add(event.id)
appendLog(`[${event.to_state}] ${event.message}`)
applyStageEvent(event)
if (writeLogs) appendLog(`[${event.to_state}] ${event.message}`)
})
}
function applyStageEvent(event: TaskEvent) {
const stage = String(event.stage || '').toLowerCase()
const eventStatus = String(event.event_status || '').toLowerCase()
const stepIndex = deliveryStageStepIndex[stage]
if (stepIndex === undefined) return
const stateMap: Record<string, StepState> = {
running: 'running',
success: 'done',
failed: 'failed',
}
const state = stateMap[eventStatus]
if (!state) return
setDeliveryStep(stepIndex, state)
}
function appendLog(message: unknown) {
if (!message) return
deliveryLog.value += `\n${String(message)}`
@@ -1487,23 +1514,41 @@ async function copyText(value: string, message: string) {
function applyDeliveryStatus(status: string, message: string) {
lastDeliveryStatus.value = status
if (message) appendLog(message)
if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling', 'rollback_pending', 'rolling_back'].includes(status)) {
if (['pending', 'validating', 'dispatching', 'running'].includes(status)) {
running.value = true
markStepRunning()
if (!steps.value.slice(0, 5).some((step) => step.state !== 'pending')) {
setDeliveryStep(0, 'running')
}
return
}
if (status === 'registering') {
running.value = true
setDeliveryStep(4, 'running')
return
}
if (status === 'canceling') {
running.value = true
return
}
if (['rollback_pending', 'rolling_back'].includes(status)) {
running.value = true
setDeliveryStep(5, 'running')
return
}
if (status === 'finished') {
running.value = false
deliveryDone.value = true
deliveryFailed.value = false
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
steps.value = steps.value.map((step, index) => (
index < 5 ? { ...step, state: 'done' } : step
))
return
}
if (status === 'rolled_back') {
running.value = false
deliveryDone.value = false
deliveryFailed.value = true
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
setDeliveryStep(5, 'done')
return
}
if (['execution_failed', 'validation_failed', 'register_failed', 'rollback_failed', 'rollback_acknowledged', 'canceled'].includes(status)) {
@@ -1511,7 +1556,11 @@ function applyDeliveryStatus(status: string, message: string) {
deliveryDone.value = false
deliveryFailed.value = true
deliveryError.value = message || deliveryError.value
markCurrentStepFailed()
if (status === 'validation_failed') setDeliveryStep(0, 'failed')
else if (status === 'register_failed') setDeliveryStep(4, 'failed')
else if (status === 'rollback_failed') setDeliveryStep(5, 'failed')
else if (status === 'rollback_acknowledged') setDeliveryStep(5, 'done')
else markCurrentStepFailed()
}
}
@@ -1519,7 +1568,8 @@ async function loadDeliveryTargets() {
targetsLoading.value = true
try {
deliveryTargets.value = await deliveryApi.listTargets()
const preferred = deliveryTargets.value.find((target) => !/callback|rollback/i.test(target.name))
const preferred = deliveryTargets.value.find((target) => /callback/i.test(target.name))
|| deliveryTargets.value.find((target) => !/rollback/i.test(target.name))
selectedTargetId.value = preferred?.id || deliveryTargets.value[0]?.id
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败')
@@ -1623,6 +1673,17 @@ function normalizeDNSLabel(value: string) {
return normalized || 'default'
}
function generateInstanceName(serviceKey: string, businessLineName: string) {
const prefix = serviceKey === 'mysql' ? 'mysql' : 'nginx'
// 限长业务线段,确保 63 字符截断不会吃掉随机后缀(prefix 5 + 连字符 2 + 后缀 6 = 13)
const businessLine = normalizeDNSLabel(businessLineName).slice(0, 50).replace(/-+$/g, '')
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
const values = new Uint32Array(6)
crypto.getRandomValues(values)
const suffix = Array.from(values, (value) => alphabet[value % alphabet.length]).join('')
return normalizeDNSLabel(`${prefix}-${businessLine}-${suffix}`)
}
function generateRootPassword() {
const groups = [
'ABCDEFGHJKLMNPQRSTUVWXYZ',
@@ -1666,13 +1727,7 @@ function regenerateRootPassword() {
}
function regenerateInstanceName() {
const prefix = activeServiceKey.value === 'mysql' ? 'mysql' : 'service'
const businessLine = normalizeDNSLabel(currentName.value)
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
const values = new Uint32Array(6)
crypto.getRandomValues(values)
const suffix = Array.from(values, (value) => alphabet[value % alphabet.length]).join('')
deliveryForm.instanceName = normalizeDNSLabel(`${prefix}-${businessLine}-${suffix}`)
deliveryForm.instanceName = generateInstanceName(activeServiceKey.value, currentName.value)
precheckPassed.value = false
ElMessage.success('已生成新的实例名称,请执行预检查确认可用')
}
@@ -1934,12 +1989,11 @@ async function releaseRollback() {
}
}
function markStepRunning() {
const index = steps.value.findIndex((step) => step.state === 'pending' || step.state === 'running')
if (index < 0) return
function setDeliveryStep(index: number, state: StepState) {
if (index < 0 || index >= steps.value.length) return
steps.value = steps.value.map((step, stepIndex) => {
if (stepIndex < index) return { ...step, state: 'done' }
if (stepIndex === index) return { ...step, state: 'running' }
if (stepIndex < index && step.state !== 'failed') return { ...step, state: 'done' }
if (stepIndex === index) return { ...step, state }
return step
})
}