feat: switch base delivery to awx task logs
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user