feat: wire mysql delivery to awx callbacks

This commit is contained in:
mac
2026-07-28 14:45:17 +08:00
parent 5a260c443f
commit 8e09c30d21
13 changed files with 676 additions and 218 deletions
+1
View File
@@ -12,6 +12,7 @@ declare module 'vue' {
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
ElButton: typeof import('element-plus/es')['ElButton']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
+11
View File
@@ -10,6 +10,12 @@ export interface DeliveryTarget {
metadata?: string
}
export interface DeliveryMountPath {
path: string
available_gi: number
fstype?: string
}
export interface CreateMySQLDeliveryPayload {
business_line_id: number
target_id: number
@@ -83,6 +89,11 @@ export const deliveryApi = {
return Array.isArray(data.items) ? data.items : []
},
async listTargetMountPaths(targetId: number, host: string): Promise<DeliveryMountPath[]> {
const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`)
return Array.isArray(data.items) ? data.items : []
},
async createMySQL(payload: CreateMySQLDeliveryPayload): Promise<DeliveryTask> {
const data = await authRequest('/auth/api/v1/delivery/mysql', {
method: 'POST',
+172 -20
View File
@@ -102,6 +102,12 @@
<el-option v-for="target in deliveryTargets" :key="target.id" :label="target.name" :value="target.id" />
</el-select>
</label>
<label class="form-field">
部署主机
<el-select v-model="selectedTargetHost" placeholder="选择 AWX 主机" :disabled="!selectedTargetId || !targetHosts.length">
<el-option v-for="host in targetHosts" :key="host.name" :label="targetHostLabel(host)" :value="host.name" />
</el-select>
</label>
</div>
<div class="config-list">
@@ -124,12 +130,21 @@
<div class="config-label">数据盘</div>
<div class="subform-grid">
<label class="form-field">
挂载点
<el-select v-model="deliveryForm.dataDisk">
<el-option label="/data" value="/data" />
<el-option label="/disk1" value="/disk1" />
<el-option label="/mnt/vol-1" value="/mnt/vol-1" />
</el-select>
数据目录
<el-autocomplete
v-model="deliveryForm.dataDisk"
:fetch-suggestions="queryMountPathSuggestions"
placeholder="选择或输入数据目录"
:loading="mountPathsLoading"
clearable
>
<template #default="{ item }">
<div class="mount-path-option">
<span>{{ item.path }}</span>
<small>{{ mountPathMeta(item) }}</small>
</div>
</template>
</el-autocomplete>
</label>
<label class="form-field">
参数模板
@@ -245,7 +260,7 @@
</div>
<div class="head-actions">
<el-button :disabled="!deploymentId" :loading="taskRefreshing" :icon="Refresh" @click="refreshTaskSnapshot">刷新状态</el-button>
<el-button :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
<el-button v-if="canShowCancelDeployment" :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
</div>
</div>
@@ -336,7 +351,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { deliveryApi, type DeliveryTarget, type DeliveryTask, type TaskEvent } from '@/api/delivery'
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeliveryTask, type TaskEvent } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -404,7 +419,7 @@ const basicServices = ref<Service[]>([
status: '可交付',
template: 'mysql-delivery@v1.8.3',
runner: 'runner-02',
versions: ['MySQL 8.0'],
versions: ['MySQL 8.0', 'MySQL 8.4'],
modes: [
{ value: 'single', label: '单实例' },
],
@@ -543,11 +558,15 @@ const deliveryDone = ref(false)
const deliveryFailed = ref(false)
const canceling = ref(false)
const deploymentId = ref('')
const currentTaskStatus = ref('')
const deliveryError = ref('')
const deliveredHost = ref('')
const deliveredPort = ref<number>()
const deliveryTargets = ref<DeliveryTarget[]>([])
const selectedTargetId = ref<number>()
const selectedTargetHost = ref('')
const mountPathOptions = ref<DeliveryMountPath[]>([])
const mountPathsLoading = ref(false)
const targetsLoading = ref(false)
const taskRefreshing = ref(false)
const taskRestoring = ref(false)
@@ -582,6 +601,9 @@ const deliveryForm = reactive({
const steps = ref<DeliveryStep[]>([])
const activeService = computed(() => basicServices.value.find((service) => service.key === activeServiceKey.value && !service.disabled))
const selectedDeliveryTarget = computed(() => deliveryTargets.value.find((target) => target.id === selectedTargetId.value))
const selectedTargetMetadata = computed(() => parseTargetMetadata(selectedDeliveryTarget.value?.metadata))
const targetHosts = computed(() => selectedTargetMetadata.value.hosts)
const taskNo = computed(() => `CMP-20260721-${activeServiceKey.value === 'mysql' ? '0024' : '0023'}`)
const currentModeLabel = computed(() => activeService.value?.modes.find((mode) => mode.value === deliveryForm.mode)?.label || '-')
const topologySummary = computed(() => `${currentModeLabel.value} · ${deliveryForm.spec} · ${deliveryForm.disk}`)
@@ -623,21 +645,28 @@ const runnerPreview = computed(() => {
})
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port || 'auto'}`)
const resultTitle = computed(() => {
if (deliveryFailed.value) return '交付失败,已自动回退'
if (currentTaskStatus.value === 'rollback_failed') return '交付失败,回滚失败'
if (currentTaskStatus.value === 'rolled_back') return '交付失败,已回滚'
if (deliveryFailed.value) return '交付失败'
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
})
const resultSubtitle = computed(() => {
if (currentTaskStatus.value === 'rollback_failed') return deliveryError.value || '自动回滚失败 · 需要人工清理'
if (currentTaskStatus.value === 'rolled_back') return deliveryError.value || '资源已释放 · 变更未交付'
if (deliveryFailed.value) return deliveryError.value || '交付失败 · 资源已释放 · 变更未交付'
return activeServiceKey.value === 'mysql' ? '全部步骤执行成功 · 用时 06:42' : '全部步骤执行成功 · 用时 02:18'
})
const deliveryStateText = computed(() => {
if (deliveryFailed.value) return '已回退'
if (currentTaskStatus.value === 'rollback_failed') return '回滚失败'
if (isRollbackRunningStatus(currentTaskStatus.value)) return '回滚中'
if (deliveryFailed.value) return isRolledBackStatus(currentTaskStatus.value) ? '已回滚' : '失败'
if (deliveryDone.value) return '已交付'
if (running.value) return '交付中'
if (precheckPassed.value) return '待执行'
return '配置中'
})
const canCancelDeployment = computed(() => Boolean(deploymentId.value) && running.value && !deliveryDone.value && !deliveryFailed.value)
const canShowCancelDeployment = computed(() => Boolean(deploymentId.value) && isCancelableDeliveryStatus(currentTaskStatus.value))
const canCancelDeployment = computed(() => Boolean(deploymentId.value) && isCancelableDeliveryStatus(currentTaskStatus.value))
const deliveryStateClass = computed(() => {
if (deliveryFailed.value) return 'tag-red'
if (deliveryDone.value) return 'tag-green'
@@ -669,6 +698,16 @@ watch(
},
)
watch(selectedTargetId, () => {
if (selectedTargetHost.value && targetHosts.value.some((host) => host.name === selectedTargetHost.value)) return
selectedTargetHost.value = targetHosts.value[0]?.name || ''
void loadMountPaths()
})
watch(selectedTargetHost, () => {
void loadMountPaths()
})
hydrateServiceDefaults()
onMounted(async () => {
await loadDeliveryTargets()
@@ -743,6 +782,7 @@ function resetExecutionState() {
deliveryError.value = ''
canceling.value = false
deploymentId.value = ''
currentTaskStatus.value = ''
deliveredHost.value = ''
deliveredPort.value = undefined
seenEventIds.value = new Set()
@@ -806,6 +846,14 @@ async function createTask() {
ElMessage.warning('请先选择部署目标')
return
}
if (targetHosts.value.length && !selectedTargetHost.value) {
ElMessage.warning('请先选择部署主机')
return
}
if (!targetHosts.value.length) {
ElMessage.warning('当前部署目标没有可用主机')
return
}
const validationError = validateDeliveryForm()
if (validationError) {
ElMessage.warning(validationError)
@@ -814,6 +862,7 @@ async function createTask() {
running.value = true
deliveryDone.value = false
deliveryFailed.value = false
currentTaskStatus.value = 'pending'
activeView.value = 'execution'
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
try {
@@ -854,9 +903,6 @@ function validateDeliveryForm() {
if (storageGb < 20 || storageGb > 2000) {
return '数据盘容量必须在 20GB 到 2000GB 之间'
}
if (!['/data', '/disk1', '/mnt/vol-1'].includes(deliveryForm.dataDisk)) {
return '数据盘挂载点不在支持范围内'
}
if (!['default', 'high_performance', 'high_safety'].includes(deliveryForm.paramTemplate)) {
return '参数模板不在支持范围内'
}
@@ -928,14 +974,16 @@ async function restoreActiveTask() {
function hydrateTaskSnapshot(task: DeliveryTask) {
deploymentId.value = task.id
currentTaskStatus.value = task.status || currentTaskStatus.value
deliveredHost.value = task.target_host_ip || deliveredHost.value
deliveredPort.value = task.mysql_port || deliveredPort.value
selectedTargetId.value = task.target_id || selectedTargetId.value
selectedTargetHost.value = task.target_host || selectedTargetHost.value
deliveryForm.instanceName = task.instance_name || deliveryForm.instanceName
if (task.status) {
running.value = !isTerminalDeliveryStatus(task.status)
deliveryDone.value = task.status === 'finished'
deliveryFailed.value = ['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(task.status)
deliveryFailed.value = isFailedDeliveryStatus(task.status)
}
if (!deliveryLog.value || deliveryLog.value === '[ready] 等待创建交付任务...') {
deliveryLog.value = `[task] ${task.id} restored from ${task.status}`
@@ -1000,6 +1048,7 @@ function mysqlDeliveryPayload(businessLineId: number) {
cpu_milli: resources.cpuCores * 1000,
memory_mi: resources.memoryGb * 1024,
storage_gi: parseStorageGb(deliveryForm.disk),
target_host: selectedTargetHost.value,
param_template: deliveryForm.paramTemplate,
timezone: deliveryForm.timezone,
lower_case_table_names: deliveryForm.lowerCaseTableNames,
@@ -1090,8 +1139,9 @@ function appendLog(message: unknown) {
}
function applyDeliveryStatus(status: string, message: string) {
currentTaskStatus.value = status || currentTaskStatus.value
if (message) appendLog(message)
if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling'].includes(status)) {
if (isActiveDeliveryStatus(status)) {
running.value = true
markStepRunning()
return
@@ -1103,12 +1153,16 @@ function applyDeliveryStatus(status: string, message: string) {
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
return
}
if (['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)) {
if (isFailedDeliveryStatus(status)) {
running.value = false
deliveryDone.value = false
deliveryFailed.value = true
deliveryError.value = message || deliveryError.value
markCurrentStepFailed()
if (status === 'rolled_back') {
steps.value = steps.value.map((step) => step.state === 'failed' ? step : { ...step, state: step.state === 'pending' ? 'done' : step.state })
} else {
markCurrentStepFailed()
}
}
}
@@ -1117,6 +1171,8 @@ async function loadDeliveryTargets() {
try {
deliveryTargets.value = await deliveryApi.listTargets()
selectedTargetId.value = deliveryTargets.value[0]?.id
selectedTargetHost.value = targetHosts.value[0]?.name || ''
await loadMountPaths()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败')
} finally {
@@ -1124,6 +1180,22 @@ async function loadDeliveryTargets() {
}
}
async function loadMountPaths() {
if (!selectedTargetId.value || !selectedTargetHost.value) {
mountPathOptions.value = defaultMountPathOptions()
return
}
mountPathsLoading.value = true
try {
const items = await deliveryApi.listTargetMountPaths(selectedTargetId.value, selectedTargetHost.value)
mountPathOptions.value = items.length ? items : defaultMountPathOptions()
} catch {
mountPathOptions.value = defaultMountPathOptions()
} finally {
mountPathsLoading.value = false
}
}
function parseSpec(spec: string) {
const cpu = Number(spec.match(/(\d+)\s*C/i)?.[1] || 1)
const memory = Number(spec.match(/\/\s*(\d+)\s*G/i)?.[1] || 1)
@@ -1144,6 +1216,50 @@ function mysqlVersionValue(version: string) {
return matched.split('.').slice(0, 2).join('.')
}
interface TargetMetadata {
hosts?: TargetHost[]
}
interface TargetHost {
name: string
ip?: string
}
function parseTargetMetadata(raw?: string): { hosts: TargetHost[] } {
if (!raw) return { hosts: [] }
try {
const metadata = JSON.parse(raw) as TargetMetadata
return {
hosts: Array.isArray(metadata.hosts) ? metadata.hosts.filter((host) => Boolean(host.name)) : [],
}
} catch {
return { hosts: [] }
}
}
function targetHostLabel(host: TargetHost) {
return host.ip ? `${host.name} · ${host.ip}` : host.name
}
function mountPathMeta(item: DeliveryMountPath) {
const parts = []
if (item.available_gi > 0) parts.push(`可用 ${item.available_gi}GiB`)
if (item.fstype) parts.push(item.fstype)
return parts.join(' · ')
}
function queryMountPathSuggestions(query: string, callback: (items: Array<DeliveryMountPath & { value: string }>) => void) {
const keyword = query.trim().toLowerCase()
const items = mountPathOptions.value
.filter((item) => !keyword || item.path.toLowerCase().includes(keyword))
.map((item) => ({ ...item, value: item.path }))
callback(items)
}
function defaultMountPathOptions(): DeliveryMountPath[] {
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
}
function mysqlTopologyValue(mode: string) {
if (mode === 'single') return 'standalone'
if (mode === 'replica') return 'primary_replica'
@@ -1187,7 +1303,31 @@ function normalizeDNSLabel(value: string) {
}
function isTerminalDeliveryStatus(status: string) {
return ['finished', 'execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)
return ['finished', ...failedDeliveryStatuses].includes(status)
}
const activeDeliveryStatuses = ['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling', 'rollback_pending', 'rolling_back']
const failedDeliveryStatuses = ['execution_failed', 'validation_failed', 'register_failed', 'canceled', 'rollback_failed', 'rolled_back']
const cancelableDeliveryStatuses = ['pending', 'dispatching', 'running']
function isActiveDeliveryStatus(status: string) {
return activeDeliveryStatuses.includes(status)
}
function isFailedDeliveryStatus(status: string) {
return failedDeliveryStatuses.includes(status)
}
function isCancelableDeliveryStatus(status: string) {
return cancelableDeliveryStatuses.includes(status)
}
function isRollbackRunningStatus(status: string) {
return ['rollback_pending', 'rolling_back'].includes(status)
}
function isRolledBackStatus(status: string) {
return status === 'rolled_back'
}
function markStepRunning() {
@@ -1450,6 +1590,18 @@ h4 {
font-size: 11.5px;
}
.mount-path-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mount-path-option small {
color: var(--text-muted);
font-size: 11px;
}
.config-list {
border-top: 1px solid var(--line-soft);
}