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
+124
View File
@@ -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 }
}
}
-68
View File
@@ -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 }
}
}
+81
View File
@@ -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 }
}
}
+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
+273 -63
View File
@@ -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>