feat: switch base delivery to awx task logs

This commit is contained in:
mac
2026-07-23 18:36:24 +08:00
parent c2a3bbbdc0
commit a934380c9c
20 changed files with 1638 additions and 1243 deletions
+146 -89
View File
@@ -94,6 +94,12 @@
<el-option v-for="version in activeService.versions" :key="version" :label="version" :value="version" />
</el-select>
</label>
<label class="form-field">
部署目标
<el-select v-model="selectedTargetId" placeholder="选择 AWX 部署目标" :loading="targetsLoading">
<el-option v-for="target in deliveryTargets" :key="target.id" :label="target.name" :value="target.id" />
</el-select>
</label>
</div>
<div class="config-list">
@@ -219,8 +225,8 @@
<section v-else-if="activeView === 'execution'" class="execution-view">
<div class="execution-head">
<div>
<h3>Ansible 自动交付</h3>
<p>{{ deploymentId || '创建任务后生成 deployment_id' }} · {{ activeService.runner }} · {{ activeService.template }}</p>
<h3>AWX 自动交付</h3>
<p>{{ deploymentId || '创建任务后生成 task_id' }} · {{ activeService.runner }} · {{ activeService.template }}</p>
</div>
<div class="head-actions">
<el-button :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
@@ -265,7 +271,7 @@
</div>
<div class="result-item">
<span>健康状态</span>
<strong>{{ deliveryFailed ? '健康检查失败 · 已回退' : activeService.healthText }}</strong>
<strong>{{ deliveryFailed ? resultSubtitle : activeService.healthText }}</strong>
</div>
</div>
@@ -310,11 +316,11 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, 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 { deploymentApi } from '@/api/deployment'
import { deliveryApi, type DeliveryTarget, type TaskEvent } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -376,20 +382,18 @@ const basicServices = ref<Service[]>([
icon: 'My',
bgColor: 'var(--logo-w-bg)',
iconColor: 'var(--tag-blue-text)',
description: '一主多从标准化交付,完成后自动注册进 CloudDM / open-cdm 数据源,统一审核 SQL 上线。',
description: '单实例标准化交付,完成后自动注册进 CloudDM / open-cdm 数据源,统一审核 SQL 上线。',
playbook: 'roles/mysql-deploy',
version: 'v8.0.36',
version: 'v8.0',
status: '可交付',
template: 'mysql-delivery@v1.8.3',
runner: 'runner-02',
versions: ['MySQL 8.0.36', 'MySQL 8.0.40', 'MySQL 5.7.44'],
versions: ['MySQL 8.0'],
modes: [
{ value: 'single', label: '单实例' },
{ value: 'replica', label: '一主一从' },
{ value: 'mgr', label: 'MGR 三节点' },
],
specs: ['4C / 16G', '8C / 32G', '16C / 64G'],
disks: ['200 GB', '500 GB', '1 TB', '2 TB'],
specs: ['1C / 1G', '2C / 2G', '4C / 4G'],
disks: ['10 GB', '20 GB', '50 GB', '100 GB'],
charsets: ['utf8mb4', 'utf8'],
defaultPort: 3306,
defaultPaths: { install: '/opt/mysql', data: '/data/mysql', log: '/data/mysql-log' },
@@ -407,7 +411,7 @@ const basicServices = ref<Service[]>([
description: '容器化部署在 K8s 内,作为业务边缘网关,支持灰度路由配置。',
playbook: 'roles/openresty-deploy',
version: 'v1.25',
status: '可交付',
status: '待接入 AWX 模板',
template: 'nginx-delivery@v1.3.0',
runner: 'runner-02',
versions: ['OpenResty 1.25.3', 'OpenResty 1.21.4'],
@@ -424,6 +428,9 @@ const basicServices = ref<Service[]>([
configId: 'CFG-NGX-02841',
assetId: 'CMP-NGX-1042',
healthText: 'HTTP 200 · 23 ms',
statusColor: 'var(--text-dim)',
disabled: true,
opacity: 0.75,
},
{
key: 'pgsql',
@@ -508,7 +515,7 @@ const basicServices = ref<Service[]>([
const flowTabs = [
{ key: 'config' as FlowView, title: '01 参数配置', subtitle: '模板与资源规划' },
{ key: 'execution' as FlowView, title: '02 自动交付', subtitle: 'Ansible + 健康探测' },
{ key: 'execution' as FlowView, title: '02 自动交付', subtitle: 'AWX + 健康探测' },
{ key: 'result' as FlowView, title: '03 交付结果', subtitle: '资产与访问地址' },
]
@@ -520,15 +527,22 @@ const deliveryDone = ref(false)
const deliveryFailed = ref(false)
const canceling = ref(false)
const deploymentId = ref('')
const eventSource = ref<EventSource | null>(null)
const deliveryError = ref('')
const deliveredHost = ref('')
const deliveredPort = ref<number>()
const deliveryTargets = ref<DeliveryTarget[]>([])
const selectedTargetId = ref<number>()
const targetsLoading = ref(false)
const pollTimer = ref<number | undefined>()
const seenEventIds = ref(new Set<number>())
const deliveryLog = ref('[ready] 等待创建交付任务...')
const deliveryForm = reactive({
instanceName: `mysql-${currentName.value}-billing-02`,
version: 'MySQL 8.0.36',
mode: 'replica',
spec: '8C / 32G',
disk: '500 GB',
version: 'MySQL 8.0',
mode: 'single',
spec: '2C / 2G',
disk: '20 GB',
port: 3306,
charset: 'utf8mb4',
pool: 'auto',
@@ -567,7 +581,7 @@ const topologyNodes = computed(() => {
const runnerPreview = computed(() => {
const playbook = activeService.value?.playbook || '-'
return [
'# ansible-runner 调用预览',
'# AWX 调用预览',
`playbook: ${playbook}`,
`tenant: ${currentName.value}`,
`component: ${activeService.value?.key || '-'}`,
@@ -579,13 +593,13 @@ const runnerPreview = computed(() => {
`register_to: ${activeService.value?.registerTo || '-'}`,
].join('\n')
})
const resultAddress = computed(() => `${topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveryForm.port}`)
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port}`)
const resultTitle = computed(() => {
if (deliveryFailed.value) return '交付失败,已自动回退'
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
})
const resultSubtitle = computed(() => {
if (deliveryFailed.value) return '健康检查未通过 · 资源已释放 · 变更未交付'
if (deliveryFailed.value) return deliveryError.value || '交付失败 · 资源已释放 · 变更未交付'
return activeServiceKey.value === 'mysql' ? '全部步骤执行成功 · 用时 06:42' : '全部步骤执行成功 · 用时 02:18'
})
const deliveryStateText = computed(() => {
@@ -620,6 +634,8 @@ watch(
)
hydrateServiceDefaults()
onMounted(loadDeliveryTargets)
onBeforeUnmount(stopPolling)
function handleCardClick(service: Service) {
if (service.disabled) {
@@ -662,13 +678,16 @@ function resetWorkbench() {
}
function resetExecutionState() {
closeEventSource()
stopPolling()
precheckPassed.value = false
running.value = false
deliveryDone.value = false
deliveryFailed.value = false
deliveryError.value = ''
canceling.value = false
deploymentId.value = ''
deliveredHost.value = ''
deliveredPort.value = undefined
deliveryLog.value = '[ready] 等待创建交付任务...'
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
}
@@ -716,20 +735,25 @@ async function createTask() {
return
}
if (!activeService.value) return
if (activeService.value.key !== 'mysql') {
ElMessage.warning(`${activeService.value.name} 的 AWX 交付模板尚未接入`)
return
}
if (!selectedTargetId.value) {
ElMessage.warning('请先选择部署目标')
return
}
running.value = true
deliveryDone.value = false
deliveryFailed.value = false
activeView.value = 'execution'
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
try {
const result = await deploymentApi.create({
component: activeService.value.key,
business_line_id: businessLineId,
params: deploymentParams(),
})
deploymentId.value = result.deployment_id
deliveryLog.value += `\n[task] ${result.deployment_id} created by ${currentName.value}`
connectDeploymentEvents(result.deployment_id)
const task = await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId))
deploymentId.value = task.id
deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}`
applyDeliveryStatus(task.status, '')
startTaskPolling(task.id)
ElMessage.success('交付任务已创建')
} catch (error) {
running.value = false
@@ -743,7 +767,7 @@ async function cancelDeployment() {
if (!deploymentId.value) return
canceling.value = true
try {
await deploymentApi.cancel(deploymentId.value)
await deliveryApi.cancel(deploymentId.value)
appendLog('[cancel] cancel requested')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '取消任务失败')
@@ -752,98 +776,131 @@ async function cancelDeployment() {
}
}
function deploymentParams() {
function mysqlDeliveryPayload(businessLineId: number) {
const resources = parseSpec(deliveryForm.spec)
return {
business_line: currentName.value,
instance_name: deliveryForm.instanceName,
version: deliveryForm.version,
mode: deliveryForm.mode,
mode_label: currentModeLabel.value,
spec: deliveryForm.spec,
disk: deliveryForm.disk,
port: deliveryForm.port,
charset: deliveryForm.charset,
pool: deliveryForm.pool,
install_path: deliveryForm.installPath,
data_path: deliveryForm.dataPath,
log_path: deliveryForm.logPath,
business_line_id: businessLineId,
target_id: selectedTargetId.value || 0,
namespace: normalizeDNSLabel(currentName.value),
instance_name: normalizeDNSLabel(deliveryForm.instanceName),
mysql_version: mysqlVersionValue(deliveryForm.version),
cpu_milli: resources.cpuMilli,
memory_mi: resources.memoryMi,
storage_gi: parseStorageGi(deliveryForm.disk),
}
}
function connectDeploymentEvents(id: string) {
closeEventSource()
const source = new EventSource(deploymentApi.eventsURL(id))
eventSource.value = source
source.addEventListener('log', (event) => {
const data = parseSSEData(event)
appendLog(data.message)
markStepRunning()
})
source.addEventListener('status', (event) => {
const data = parseSSEData(event)
applyDeploymentStatus(String(data.status || ''), data.message ? String(data.message) : '')
})
source.addEventListener('error', (event) => {
const data = parseSSEData(event)
if (data.message) appendLog(String(data.message))
applyDeploymentStatus(String(data.status || 'failed'), '')
})
source.addEventListener('result', (event) => {
const data = parseSSEData(event)
applyDeploymentStatus(String(data.status || 'success'), data.message ? String(data.message) : '')
})
source.addEventListener('done', (event) => {
const data = parseSSEData(event)
applyDeploymentStatus(String(data.status || ''), '')
closeEventSource()
activeView.value = 'result'
})
source.onerror = () => {
appendLog('[sse] connection interrupted')
}
function startTaskPolling(id: string) {
stopPolling()
seenEventIds.value = new Set()
pollTask(id)
pollTimer.value = window.setInterval(() => pollTask(id), 3000)
}
function closeEventSource() {
eventSource.value?.close()
eventSource.value = null
function stopPolling() {
if (pollTimer.value) window.clearInterval(pollTimer.value)
pollTimer.value = undefined
}
function parseSSEData(event: Event): Record<string, unknown> {
const message = event as MessageEvent<string>
async function pollTask(id: string) {
try {
return JSON.parse(message.data || '{}')
} catch {
return {}
const data = await deliveryApi.getTask(id)
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
deliveredPort.value = data.task.mysql_port || deliveredPort.value
applyTaskEvents(data.events)
applyDeliveryStatus(data.task.status, data.task.error_message || '')
if (isTerminalDeliveryStatus(data.task.status)) {
stopPolling()
activeView.value = 'result'
}
} catch (error) {
appendLog(`[poll] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
}
}
function applyTaskEvents(events: TaskEvent[]) {
events.forEach((event) => {
if (seenEventIds.value.has(event.id)) return
seenEventIds.value.add(event.id)
appendLog(`[${event.to_state}] ${event.message}`)
})
}
function appendLog(message: unknown) {
if (!message) return
deliveryLog.value += `\n${String(message)}`
}
function applyDeploymentStatus(status: string, message: string) {
function applyDeliveryStatus(status: string, message: string) {
if (message) appendLog(message)
if (status === 'running') {
if (['pending', 'validating', 'dispatching', 'running', 'registering', 'canceling'].includes(status)) {
running.value = true
markStepRunning()
return
}
if (status === 'success') {
if (status === 'finished') {
running.value = false
deliveryDone.value = true
deliveryFailed.value = false
steps.value = steps.value.map((step) => ({ ...step, state: 'done' }))
return
}
if (status === 'failed' || status === 'canceled') {
if (['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)) {
running.value = false
deliveryDone.value = false
deliveryFailed.value = true
deliveryError.value = message || deliveryError.value
markCurrentStepFailed()
}
}
async function loadDeliveryTargets() {
targetsLoading.value = true
try {
deliveryTargets.value = await deliveryApi.listTargets()
selectedTargetId.value = deliveryTargets.value[0]?.id
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败')
} finally {
targetsLoading.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)
return {
cpuMilli: cpu * 1000,
memoryMi: memory * 1024,
}
}
function parseStorageGi(disk: string) {
const value = Number(disk.match(/(\d+)/)?.[1] || 1)
if (/TB/i.test(disk)) return value * 1024
return value
}
function mysqlVersionValue(version: string) {
const matched = version.match(/\d+(?:\.\d+)+/)?.[0] || version || '8.0'
return matched.split('.').slice(0, 2).join('.')
}
function normalizeDNSLabel(value: string) {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-')
.slice(0, 63)
.replace(/-+$/g, '')
return normalized || 'default'
}
function isTerminalDeliveryStatus(status: string) {
return ['finished', 'execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(status)
}
function markStepRunning() {
const index = steps.value.findIndex((step) => step.state === 'pending' || step.state === 'running')
if (index < 0) return