feat: switch base delivery to awx task logs
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export interface DeliveryTarget {
|
||||
id: number
|
||||
name: string
|
||||
target_type: string
|
||||
awx_inventory_id: number
|
||||
awx_template_id: number
|
||||
enabled: boolean
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
export interface CreateMySQLDeliveryPayload {
|
||||
business_line_id: number
|
||||
target_id: number
|
||||
namespace: string
|
||||
instance_name: string
|
||||
mysql_version: string
|
||||
cpu_milli: number
|
||||
memory_mi: number
|
||||
storage_gi: number
|
||||
}
|
||||
|
||||
export interface DeliveryTask {
|
||||
id: string
|
||||
business_line_id: number
|
||||
requested_by: number
|
||||
target_type: string
|
||||
target_id: number
|
||||
namespace: string
|
||||
instance_name: string
|
||||
target_host?: string
|
||||
target_host_ip?: string
|
||||
mysql_port?: number
|
||||
status: string
|
||||
error_message?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number
|
||||
task_id: string
|
||||
from_state: string
|
||||
to_state: string
|
||||
message: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const deliveryApi = {
|
||||
async listTargets(): Promise<DeliveryTarget[]> {
|
||||
const data = await authRequest('/auth/api/v1/delivery/targets?component=mysql')
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async createMySQL(payload: CreateMySQLDeliveryPayload): Promise<DeliveryTask> {
|
||||
const data = await authRequest('/auth/api/v1/delivery/mysql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey(payload),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return data.task
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> {
|
||||
const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`)
|
||||
return {
|
||||
task: data.task,
|
||||
events: Array.isArray(data.events) ? data.events : [],
|
||||
}
|
||||
},
|
||||
|
||||
async cancel(taskId: string): Promise<void> {
|
||||
await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) {
|
||||
return [
|
||||
'mysql',
|
||||
payload.business_line_id,
|
||||
payload.target_id,
|
||||
payload.namespace,
|
||||
payload.instance_name,
|
||||
Date.now(),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
async function authRequest(path: string, init: RequestInit = {}) {
|
||||
const token = getToken()
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseBody(text)
|
||||
if (!response.ok) {
|
||||
const message = data?.error || data?.message || text || `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
return data || {}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string) {
|
||||
if (!text.trim()) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { error: text }
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export interface DeploymentCreatePayload {
|
||||
component: string
|
||||
business_line_id: number
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface DeploymentCreateResult {
|
||||
deployment_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export const deploymentApi = {
|
||||
async create(payload: DeploymentCreatePayload): Promise<DeploymentCreateResult> {
|
||||
const data = await authRequest('/auth/api/v1/deployments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return {
|
||||
deployment_id: String(data.deployment_id || ''),
|
||||
status: String(data.status || ''),
|
||||
}
|
||||
},
|
||||
|
||||
async cancel(deploymentId: string): Promise<void> {
|
||||
await authRequest(`/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
},
|
||||
|
||||
eventsURL(deploymentId: string): string {
|
||||
const token = getToken()
|
||||
const params = token ? `?access_token=${encodeURIComponent(token)}` : ''
|
||||
return `/auth/api/v1/deployments/${encodeURIComponent(deploymentId)}/events${params}`
|
||||
},
|
||||
}
|
||||
|
||||
async function authRequest(path: string, init: RequestInit = {}) {
|
||||
const token = getToken()
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseBody(text)
|
||||
if (!response.ok) {
|
||||
const message = data?.error || data?.message || text || `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
return data || {}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string) {
|
||||
if (!text.trim()) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { error: text }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export interface TaskLogSummary {
|
||||
id: string
|
||||
source: 'awx' | 'wayne'
|
||||
service: string
|
||||
name: string
|
||||
runner: string
|
||||
status: string
|
||||
status_text: string
|
||||
status_class: string
|
||||
business_line_id: number
|
||||
reference_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface TaskLogLine {
|
||||
time: string
|
||||
message: string
|
||||
class: string
|
||||
}
|
||||
|
||||
export interface TaskLogListParams {
|
||||
source?: string
|
||||
businessLineId?: number
|
||||
}
|
||||
|
||||
export const taskLogApi = {
|
||||
async list(params: TaskLogListParams = {}): Promise<TaskLogSummary[]> {
|
||||
const query = new URLSearchParams()
|
||||
if (params.source && params.source !== 'all') {
|
||||
query.set('source', params.source)
|
||||
}
|
||||
if (params.businessLineId) {
|
||||
query.set('business_line_id', String(params.businessLineId))
|
||||
}
|
||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||
const data = await authRequest(`/auth/api/v1/task-logs${suffix}`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async get(id: string): Promise<{ task: TaskLogSummary; lines: TaskLogLine[] }> {
|
||||
const data = await authRequest(`/auth/api/v1/task-logs/${encodeURIComponent(id)}`)
|
||||
return {
|
||||
task: data.task,
|
||||
lines: Array.isArray(data.lines) ? data.lines : [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async function authRequest(path: string, init: RequestInit = {}) {
|
||||
const token = getToken()
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseBody(text)
|
||||
if (!response.ok) {
|
||||
const message = data?.error || data?.message || text || `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
return data || {}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string) {
|
||||
if (!text.trim()) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { error: text }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -3,52 +3,67 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>任务中心</h1>
|
||||
<p>所有 ansible-playbook / Wayne 发布任务的执行记录与实时日志</p>
|
||||
<p>AWX 交付任务与 Wayne 部署服务的执行记录</p>
|
||||
</div>
|
||||
<div class="task-filters">
|
||||
<el-select v-model="sourceFilter" size="small" class="filter-select" @change="loadTasks">
|
||||
<el-option label="全部来源" value="all" />
|
||||
<el-option label="AWX" value="awx" />
|
||||
<el-option label="Wayne" value="wayne" />
|
||||
</el-select>
|
||||
<el-button size="small" :loading="loadingTasks || loadingLogs" @click="refreshCurrent">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cols-2-equal">
|
||||
<div class="panel">
|
||||
<div class="task-layout">
|
||||
<div class="panel task-list-panel">
|
||||
<div class="panel-head">
|
||||
<h3>任务列表</h3>
|
||||
<span class="meta">{{ lastLoadedText }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr v-for="task in visibleTasks" :key="task.id" class="tr-hover" :class="{ active: task.id === selectedTask }" @click="selectedTask = task.id">
|
||||
<td :class="['status-text', task.statusClass]">● {{ task.status }}</td>
|
||||
<td class="strong">{{ task.name }}</td>
|
||||
<td class="mono text-xs text-dim">{{ task.playbook }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="task-list">
|
||||
<button
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="task-row"
|
||||
:class="{ active: task.id === selectedTaskId }"
|
||||
@click="selectTask(task.id)"
|
||||
>
|
||||
<span :class="['task-status', task.status_class]">● {{ task.status_text }}</span>
|
||||
<span class="task-main">
|
||||
<span class="task-name">{{ task.name }}</span>
|
||||
<span class="task-runner mono">{{ task.runner }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!loadingTasks && !tasks.length" class="empty-state">
|
||||
暂无任务记录
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span>共 {{ visibleTasks.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
<div class="pg-btns">
|
||||
<span class="pg-btn disabled">‹</span>
|
||||
<span class="pg-btn active">1</span>
|
||||
<span class="pg-btn">2</span>
|
||||
<span class="pg-btn">3</span>
|
||||
<span class="pg-sep">…</span>
|
||||
<span class="pg-btn">15</span>
|
||||
<span class="pg-btn">›</span>
|
||||
</div>
|
||||
<span>共 {{ tasks.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel task-log-panel">
|
||||
<div class="panel-head">
|
||||
<h3>实时日志 · {{ currentName }} RKE2 节点加入</h3>
|
||||
<span class="meta">WebSocket 流式输出</span>
|
||||
<h3>
|
||||
<span>任务日志</span>
|
||||
<span class="selected-name">{{ selectedTaskName }}</span>
|
||||
</h3>
|
||||
<span class="meta">{{ selectedTaskMeta }}</span>
|
||||
</div>
|
||||
<div class="panel-body log-stream">
|
||||
<div v-for="(log, index) in visibleLogs" :key="index" :class="['task-log-line', log.class]">
|
||||
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
|
||||
<span class="t">{{ log.time }}</span>{{ log.message }}
|
||||
</div>
|
||||
<div class="task-log-line">
|
||||
<span class="t">10:42:33</span>
|
||||
<span class="blink">▌</span> 等待节点 Ready...
|
||||
<div v-if="loadingLogs" class="task-log-line">
|
||||
<span class="t">...</span>加载中
|
||||
</div>
|
||||
<div v-if="!loadingLogs && !logs.length" class="empty-state">
|
||||
请选择任务查看日志
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,58 +72,253 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { taskLogApi, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
const businessLineStore = useBusinessLineStore()
|
||||
|
||||
const selectedTask = ref(1)
|
||||
const sourceFilter = ref('all')
|
||||
const selectedTaskId = ref('')
|
||||
const tasks = ref<TaskLogSummary[]>([])
|
||||
const logs = ref<TaskLogLine[]>([])
|
||||
const loadingTasks = ref(false)
|
||||
const loadingLogs = ref(false)
|
||||
const lastLoadedAt = ref<Date | null>(null)
|
||||
|
||||
const tasks = ref([
|
||||
{ id: 1, name: 'RKE2 节点加入 · bj-node-061', playbook: 'roles/rke2-node-join', status: '执行中', statusClass: 'warn' },
|
||||
{ id: 2, name: 'MySQL 主从部署 · kodo', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 3, name: 'Redis Cluster 部署 · linxi', playbook: 'roles/redis-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 4, name: 'openresty 网关部署', playbook: 'roles/openresty-deploy', status: '失败', statusClass: 'err' },
|
||||
{ id: 5, name: '业务线标签同步 · LDAP', playbook: 'internal/label-sync', status: '成功', statusClass: 'ok' },
|
||||
{ id: 6, name: 'MySQL 主从部署 · xinfra', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
|
||||
])
|
||||
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
|
||||
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
|
||||
const selectedTaskMeta = computed(() => selectedTask.value ? `${selectedTask.value.source.toUpperCase()} · ${selectedTask.value.runner}` : '接口日志')
|
||||
const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTime(lastLoadedAt.value)}` : '')
|
||||
|
||||
const logs = ref([
|
||||
{ time: '10:42:01', message: 'PLAY [rke2-node-join] **********************', class: '' },
|
||||
{ time: '10:42:02', message: 'TASK [初始化内核参数] ...', class: '' },
|
||||
{ time: '10:42:04', message: 'ok: [bj-node-061]', class: 'ok' },
|
||||
{ time: '10:42:05', message: 'TASK [安装 containerd] ...', class: '' },
|
||||
{ time: '10:42:18', message: 'ok: [bj-node-061]', class: 'ok' },
|
||||
{ time: '10:42:19', message: 'TASK [写入 RKE2 node-labels: business-line=kodo] ...', class: '' },
|
||||
{ time: '10:42:20', message: 'changed: [bj-node-061] => labels applied', class: 'tag-ok' },
|
||||
{ time: '10:42:21', message: 'TASK [加入集群 rke2-bj-prod-01] ...', class: '' },
|
||||
])
|
||||
async function loadTasks() {
|
||||
loadingTasks.value = true
|
||||
try {
|
||||
tasks.value = await taskLogApi.list({
|
||||
source: sourceFilter.value,
|
||||
businessLineId: businessLineStore.current?.id,
|
||||
})
|
||||
lastLoadedAt.value = new Date()
|
||||
if (!tasks.value.some((task) => task.id === selectedTaskId.value)) {
|
||||
selectedTaskId.value = tasks.value[0]?.id || ''
|
||||
}
|
||||
if (selectedTaskId.value) {
|
||||
await loadLogs(selectedTaskId.value)
|
||||
} else {
|
||||
logs.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
|
||||
} finally {
|
||||
loadingTasks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const visibleTasks = computed(() => tasks.value.map((task) => ({
|
||||
...task,
|
||||
name: task.name.replace(/ · (kodo|linxi|xinfra|las)|$/, ` · ${currentName.value}`),
|
||||
})))
|
||||
async function loadLogs(taskId: string) {
|
||||
loadingLogs.value = true
|
||||
try {
|
||||
const data = await taskLogApi.get(taskId)
|
||||
logs.value = data.lines
|
||||
lastLoadedAt.value = new Date()
|
||||
} catch (error) {
|
||||
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
|
||||
} finally {
|
||||
loadingLogs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const visibleLogs = computed(() => logs.value.map((log) => ({
|
||||
...log,
|
||||
message: log.message.replace(/business-line=(kodo|linxi|xinfra|las)/, `business-line=${currentName.value}`),
|
||||
})))
|
||||
function selectTask(taskId: string) {
|
||||
if (selectedTaskId.value === taskId) {
|
||||
return
|
||||
}
|
||||
selectedTaskId.value = taskId
|
||||
void loadLogs(taskId)
|
||||
}
|
||||
|
||||
function refreshCurrent() {
|
||||
void loadTasks()
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => businessLineStore.current?.id,
|
||||
() => {
|
||||
void loadTasks()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 公共样式已在 global.css 中定义 */
|
||||
|
||||
.log-stream {
|
||||
max-height: 340px;
|
||||
.task-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.task-list-panel,
|
||||
.task-log-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-list-panel .panel-body {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
max-height: calc(100vh - 245px);
|
||||
min-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation: blink 1s step-start infinite;
|
||||
.task-row {
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-row:hover,
|
||||
.task-row.active {
|
||||
background: var(--bg-panel-2);
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-status.ok {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
.task-status.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.task-status.err {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.task-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.task-name,
|
||||
.task-runner,
|
||||
.selected-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
color: var(--text-hi);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-runner {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.task-log-panel .panel-head {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.task-log-panel .panel-head h3 {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
max-height: calc(100vh - 210px);
|
||||
min-height: 520px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.task-layout {
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.task-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-list,
|
||||
.log-stream {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user