feat: wire mysql delivery to awx callbacks
This commit is contained in:
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user