Files
devops-platform-web/src/components/PipelineStatusViewer.vue
T
2026-06-12 17:53:41 +08:00

1010 lines
22 KiB
Vue

<script setup lang="ts">
import { RefreshRight, Tickets } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { devopsApi } from '../services/devopsApi'
import type { JenkinsBuildLog, PipelineStep, ReleaseRun, StepStatus } from '../types/devops'
import StatusPill from './StatusPill.vue'
const props = defineProps<{
steps: PipelineStep[]
run?: ReleaseRun
}>()
type StatusMeta = {
label: string
short: string
className: string
description: string
}
type OutputMode = 'step' | 'jenkins'
const maxRenderedLogLines = 520
const selectedStepNodeId = ref('')
const terminalOutputEl = ref<HTMLElement>()
const outputMode = ref<OutputMode>('step')
const logLoading = ref(false)
const logText = ref('')
const foldedLogLines = ref(0)
const logError = ref('')
const logMeta = ref<JenkinsBuildLog | null>(null)
const autoTail = ref(true)
let logTimer: ReturnType<typeof window.setTimeout> | undefined
const statusMeta: Record<StepStatus, StatusMeta> = {
pending: {
label: '等待中',
short: '等',
className: 'pending',
description: '前置节点尚未完成,流程还没有执行到这里。',
},
queued: {
label: '排队中',
short: '排',
className: 'queued',
description: '任务已经提交,正在等待 Jenkins 或后端调度。',
},
running: {
label: '运行中',
short: '行',
className: 'running',
description: '当前节点正在执行,状态会随后端同步刷新。',
},
success: {
label: '成功',
short: '成',
className: 'success',
description: '节点已经完成且没有阻断后续流程。',
},
failed: {
label: '失败',
short: '败',
className: 'failed',
description: '节点执行失败,需要查看失败摘要和日志片段。',
},
skipped: {
label: '跳过',
short: '跳',
className: 'skipped',
description: '节点被后端判定为可跳过,通常不阻断发布。',
},
canceled: {
label: '取消',
short: '取',
className: 'canceled',
description: '流程被人工取消或由后端终止。',
},
}
const statusLegend = Object.values(statusMeta)
const orderedSteps = computed(() => props.steps)
const outputOptions = computed(() => [
{ label: '节点输出', value: 'step' },
{ label: 'Jenkins 日志', value: 'jenkins', disabled: !canRequestJenkinsLog.value },
])
const currentStep = computed(() => {
const active = orderedSteps.value.find((step) =>
['running', 'queued', 'failed'].includes(step.status),
)
if (active) {
return active
}
return (
orderedSteps.value
.slice()
.reverse()
.find((step) => ['success', 'skipped', 'canceled'].includes(step.status)) ??
orderedSteps.value[0]
)
})
const selectedStep = computed(() => {
if (!selectedStepNodeId.value) {
return currentStep.value
}
return findStep(selectedStepNodeId.value) ?? currentStep.value
})
const stepStats = computed(() => {
const total = orderedSteps.value.length
const done = orderedSteps.value.filter((step) =>
['success', 'failed', 'skipped', 'canceled'].includes(step.status),
).length
const running = orderedSteps.value.filter((step) => step.status === 'running').length
const failed = orderedSteps.value.filter((step) => step.status === 'failed').length
return { total, done, running, failed }
})
const progressPercent = computed(() => {
if (stepStats.value.total === 0) {
return 0
}
return Math.round((stepStats.value.done / stepStats.value.total) * 100)
})
const canRequestJenkinsLog = computed(() => Boolean(props.run?.id))
const hasJenkinsBuildNumber = computed(
() => props.run?.jenkinsBuildNumber !== undefined && props.run?.jenkinsBuildNumber !== null,
)
const isActiveRun = computed(
() => props.run?.status === 'queued' || props.run?.status === 'running',
)
const terminalTitle = computed(() => {
if (outputMode.value === 'jenkins') {
return hasJenkinsBuildNumber.value
? `Jenkins Build #${props.run?.jenkinsBuildNumber}`
: 'Jenkins 输出日志'
}
return selectedStep.value?.name || '等待后端步骤'
})
const terminalSubtitle = computed(() => {
if (outputMode.value === 'jenkins') {
if (logMeta.value) {
return `${logMeta.value.jobPath} · ${logMeta.value.start} -> ${logMeta.value.nextStart}`
}
const buildText = hasJenkinsBuildNumber.value ? `Build #${props.run?.jenkinsBuildNumber}` : ''
const queueText = props.run?.jenkinsQueueId ? `Queue ${props.run.jenkinsQueueId}` : ''
return [buildText, queueText].filter(Boolean).join(' · ') || '等待 Jenkins 日志来源'
}
if (!selectedStep.value) {
return '等待后端步骤'
}
return `${selectedStep.value.owner} · 节点 ${selectedStep.value.nodeId}`
})
const logRefreshStateText = computed(() => {
if (logLoading.value) {
return '刷新中'
}
if (outputMode.value === 'jenkins' && autoTail.value && isActiveRun.value) {
return '跟随中'
}
return '局部刷新'
})
const stepOutputText = computed(() => {
const step = selectedStep.value
if (!step) {
return ''
}
return [
`# ${step.name}`,
`owner=${step.owner}`,
`status=${statusMeta[step.status].label}`,
`nodeId=${step.nodeId}`,
`duration=${step.duration}`,
step.startedAt ? `startedAt=${step.startedAt}` : '',
step.finishedAt ? `finishedAt=${step.finishedAt}` : '',
'',
step.errorSummary ? `[error]\n${step.errorSummary}` : '',
step.message ? `[message]\n${step.message}` : '',
step.logExcerpt ? `[log]\n${step.logExcerpt}` : '',
!step.errorSummary && !step.message && !step.logExcerpt ? step.summary : '',
]
.filter((line) => line !== '')
.join('\n')
})
const terminalText = computed(() => {
if (outputMode.value !== 'jenkins') {
return stepOutputText.value
}
if (!foldedLogLines.value) {
return logText.value
}
return `... 已折叠前 ${foldedLogLines.value} 行日志,仅展示最近 ${maxRenderedLogLines} 行 ...\n${logText.value}`
})
const terminalEmptyTitle = computed(() => {
if (outputMode.value === 'jenkins') {
return canRequestJenkinsLog.value ? 'Jenkins 暂无日志内容' : '等待发布单'
}
return '暂无节点输出'
})
const terminalEmptyHint = computed(() => {
if (outputMode.value === 'jenkins') {
return canRequestJenkinsLog.value
? '正在通过后端读取 Jenkins progressive log;如果这里为空,需要后端返回对应 run 的构建日志。'
: '触发发布后,这里会跟随当前 run 读取 Jenkins 构建日志。'
}
return '点击右侧节点查看该节点的执行消息、失败摘要或日志片段。'
})
function findStep(nodeId: string) {
return props.steps.find((step) => step.nodeId === nodeId)
}
function statusClass(status: StepStatus): string {
return statusMeta[status]?.className ?? 'pending'
}
function isSelectedStep(step: PipelineStep): boolean {
return selectedStep.value?.nodeId === step.nodeId
}
function selectStep(step: PipelineStep) {
selectedStepNodeId.value = step.nodeId
if (step.owner === 'Jenkins') {
outputMode.value = 'jenkins'
void reloadJenkinsLog()
return
}
outputMode.value = 'step'
}
function clearLogTimer() {
if (logTimer !== undefined) {
window.clearTimeout(logTimer)
logTimer = undefined
}
}
function resetJenkinsLog() {
clearLogTimer()
logLoading.value = false
logText.value = ''
foldedLogLines.value = 0
logError.value = ''
logMeta.value = null
}
async function reloadJenkinsLog() {
await loadJenkinsLog(0, 'replace')
}
async function loadMoreJenkinsLog() {
await loadJenkinsLog(logMeta.value?.nextStart ?? 0, 'append')
}
async function loadJenkinsLog(start: number, mode: 'replace' | 'append' | 'poll') {
clearLogTimer()
if (!props.run) {
if (mode !== 'poll') {
ElMessage.warning('当前没有可读取的发布单')
}
return
}
const runId = props.run.id
logLoading.value = mode !== 'poll' || !logText.value
logError.value = ''
try {
const log = await devopsApi.getJenkinsLog(runId, start)
if (runId !== props.run?.id) {
return
}
logMeta.value = log
updateLogText(log.text, mode)
scrollLogToBottom()
} catch (error) {
logError.value = error instanceof Error ? error.message : 'Jenkins 日志读取失败'
if (mode !== 'poll') {
ElMessage.error(logError.value)
}
} finally {
if (runId === props.run?.id) {
logLoading.value = false
scheduleLogRefresh()
}
}
}
function updateLogText(nextText: string, mode: 'replace' | 'append' | 'poll') {
const mergedText = mode === 'replace' ? nextText : `${logText.value}${nextText}`
const lines = mergedText.split('\n')
if (mode === 'replace') {
foldedLogLines.value = 0
}
if (lines.length <= maxRenderedLogLines) {
logText.value = mergedText
return
}
const removed = lines.length - maxRenderedLogLines
foldedLogLines.value += removed
logText.value = lines.slice(removed).join('\n')
}
async function scrollLogToBottom() {
if (!autoTail.value || outputMode.value !== 'jenkins') {
return
}
await nextTick()
const element = terminalOutputEl.value
if (!element) {
return
}
element.scrollTo({
top: element.scrollHeight,
behavior: 'smooth',
})
}
function scheduleLogRefresh() {
clearLogTimer()
if (!autoTail.value || outputMode.value !== 'jenkins' || !canRequestJenkinsLog.value) {
return
}
if (!isActiveRun.value && !logMeta.value?.hasMore) {
return
}
logTimer = window.setTimeout(() => {
void loadJenkinsLog(logMeta.value?.nextStart ?? 0, logText.value ? 'poll' : 'replace')
}, 4500)
}
watch(
() => `${props.run?.id ?? 'none'}:${props.run?.jenkinsBuildNumber ?? 'none'}`,
() => {
resetJenkinsLog()
if (hasJenkinsBuildNumber.value) {
outputMode.value = 'jenkins'
void loadJenkinsLog(0, 'replace')
} else if (outputMode.value === 'jenkins') {
void loadJenkinsLog(0, 'replace')
} else if (selectedStep.value?.owner !== 'Jenkins') {
outputMode.value = 'step'
}
},
{ immediate: true },
)
watch(
() => props.run?.status,
() => {
scheduleLogRefresh()
},
)
watch(
() => `${props.run?.id ?? 'none'}:${currentStep.value?.id ?? 'none'}:${currentStep.value?.status ?? 'none'}`,
() => {
if (selectedStepNodeId.value || currentStep.value?.owner !== 'Jenkins') {
return
}
outputMode.value = 'jenkins'
if (!logText.value && !logLoading.value) {
void loadJenkinsLog(0, 'replace')
}
},
{ immediate: true },
)
watch(outputMode, () => {
if (outputMode.value === 'jenkins' && !logText.value && !logLoading.value) {
void loadJenkinsLog(0, 'replace')
}
})
watch(autoTail, () => {
scheduleLogRefresh()
})
onBeforeUnmount(() => {
clearLogTimer()
})
</script>
<template>
<div class="pipeline-viewer">
<section class="execution-output">
<div class="execution-toolbar">
<div>
<strong>{{ terminalTitle }}</strong>
<span>{{ terminalSubtitle }}</span>
</div>
<div class="execution-actions">
<ElSegmented v-model="outputMode" size="small" :options="outputOptions" />
<span
v-if="outputMode === 'jenkins'"
class="refresh-state"
:class="{ loading: logLoading }"
>
{{ logRefreshStateText }}
</span>
<ElSwitch
v-if="outputMode === 'jenkins'"
v-model="autoTail"
size="small"
inline-prompt
active-text="跟随"
inactive-text="暂停"
/>
<ElButton
v-if="outputMode === 'jenkins'"
size="small"
:icon="RefreshRight"
:disabled="!canRequestJenkinsLog"
:loading="logLoading"
@click="reloadJenkinsLog"
>
重读
</ElButton>
<ElButton
v-if="outputMode === 'jenkins' && logMeta?.hasMore"
size="small"
:loading="logLoading"
@click="loadMoreJenkinsLog"
>
继续加载
</ElButton>
</div>
</div>
<pre v-if="terminalText" ref="terminalOutputEl" class="terminal-output">{{ terminalText }}</pre>
<div v-else class="terminal-empty">
<ElIcon><Tickets /></ElIcon>
<strong>{{ terminalEmptyTitle }}</strong>
<span>{{ terminalEmptyHint }}</span>
</div>
<div v-if="logError && outputMode === 'jenkins'" class="log-error">{{ logError }}</div>
</section>
<aside class="pipeline-side">
<div class="flow-stats">
<div>
<small>流程进度</small>
<strong>{{ progressPercent }}%</strong>
</div>
<div>
<small>节点</small>
<strong>{{ stepStats.done }}/{{ stepStats.total }}</strong>
</div>
<div>
<small>运行</small>
<strong>{{ stepStats.running }}</strong>
</div>
<div>
<small>失败</small>
<strong>{{ stepStats.failed }}</strong>
</div>
<div class="progress-track">
<span :style="{ width: `${progressPercent}%` }" />
</div>
</div>
<div class="legend">
<span v-for="meta in statusLegend" :key="meta.className">
<i class="dot" :class="meta.className" />{{ meta.label }}
</span>
</div>
<div class="timeline-list">
<button
v-for="(step, index) in orderedSteps"
:key="step.id"
class="timeline-step"
:class="[statusClass(step.status), { active: isSelectedStep(step) }]"
type="button"
@click="selectStep(step)"
>
<i class="timeline-index">{{ index + 1 }}</i>
<span class="timeline-text">
<strong>{{ step.name }}</strong>
<small>{{ step.owner }} · {{ statusMeta[step.status].description }}</small>
</span>
<StatusPill :state="step.status" />
</button>
</div>
<div v-if="selectedStep" class="node-detail">
<div class="node-detail-head">
<span>
<strong>{{ selectedStep.name }}</strong>
<small>{{ selectedStep.owner }} · 节点 {{ selectedStep.nodeId }}</small>
</span>
<StatusPill :state="selectedStep.status" />
</div>
<p>{{ selectedStep.summary }}</p>
<dl>
<dt>耗时</dt>
<dd>{{ selectedStep.duration }}</dd>
<dt>开始</dt>
<dd>{{ selectedStep.startedAt || '-' }}</dd>
<dt>结束</dt>
<dd>{{ selectedStep.finishedAt || '-' }}</dd>
</dl>
<p v-if="selectedStep.message">
<span>执行消息</span>
{{ selectedStep.message }}
</p>
<p v-if="selectedStep.errorSummary">
<span>失败摘要</span>
{{ selectedStep.errorSummary }}
</p>
<pre v-if="selectedStep.logExcerpt">{{ selectedStep.logExcerpt }}</pre>
</div>
</aside>
</div>
</template>
<style scoped>
.pipeline-viewer {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 16px;
height: clamp(430px, 58vh, 560px);
min-height: 0;
align-items: start;
}
.execution-output {
display: flex;
min-width: 0;
height: 100%;
min-height: 0;
overflow: hidden;
flex-direction: column;
border: 1px solid #dfe6f0;
border-radius: 8px;
background: #0f172a;
contain: layout paint;
}
.execution-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 12px 14px;
border-bottom: 1px solid #1f2937;
background: #111827;
color: #dbeafe;
}
.execution-toolbar strong,
.execution-toolbar span {
display: block;
}
.execution-toolbar strong {
color: #f8fafc;
font-size: 14px;
}
.execution-toolbar > div > span {
margin-top: 4px;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
}
.execution-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.refresh-state {
display: inline-flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid rgb(148 163 184 / 30%);
border-radius: 999px;
background: rgb(15 23 42 / 55%);
color: #cbd5e1;
font-size: 12px;
white-space: nowrap;
}
.refresh-state::before {
width: 6px;
height: 6px;
border-radius: 999px;
background: #38bdf8;
content: '';
}
.refresh-state.loading::before {
animation: pulse 1s ease-in-out infinite;
background: #60a5fa;
}
.execution-actions :deep(.el-segmented) {
--el-segmented-bg-color: #1f2937;
--el-segmented-item-selected-bg-color: #334155;
--el-segmented-item-selected-color: #ffffff;
--el-segmented-item-hover-color: #ffffff;
color: #cbd5e1;
}
.terminal-output {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
overscroll-behavior: contain;
scroll-behavior: smooth;
background:
linear-gradient(rgb(255 255 255 / 3%) 1px, transparent 1px),
#0f172a;
background-size: 100% 24px;
color: #dbeafe;
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.terminal-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
padding: 28px;
color: #94a3b8;
text-align: center;
}
.terminal-empty .el-icon {
color: #38bdf8;
font-size: 28px;
}
.terminal-empty strong {
color: #e5edf8;
font-size: 14px;
}
.terminal-empty span {
max-width: 420px;
font-size: 13px;
line-height: 1.6;
}
.log-error {
padding: 10px 14px;
border-top: 1px solid #7f1d1d;
background: #fef2f2;
color: #991b1b;
font-size: 12px;
}
.pipeline-side {
position: sticky;
top: 86px;
display: flex;
height: 100%;
max-height: 100%;
min-width: 0;
overflow: auto;
overscroll-behavior: contain;
flex-direction: column;
gap: 12px;
padding-right: 2px;
contain: layout paint;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: #596578;
font-size: 12px;
}
.legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.dot.success,
.timeline-step.success .timeline-index {
background: #18a058;
}
.dot.running,
.timeline-step.running .timeline-index {
background: #1d5fd0;
}
.dot.queued,
.timeline-step.queued .timeline-index {
background: #b7791f;
}
.dot.failed,
.timeline-step.failed .timeline-index {
background: #d03050;
}
.dot.pending,
.dot.skipped,
.dot.canceled,
.timeline-step.pending .timeline-index,
.timeline-step.skipped .timeline-index,
.timeline-step.canceled .timeline-index {
background: #9aa4b2;
}
.flow-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 12px;
border: 1px solid #e3e8f2;
border-radius: 8px;
background: #ffffff;
}
.flow-stats small,
.flow-stats strong {
display: block;
}
.flow-stats small {
color: #748093;
font-size: 12px;
}
.flow-stats strong {
margin-top: 4px;
color: #172033;
font-size: 16px;
}
.progress-track {
grid-column: 1 / -1;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #e8eef7;
}
.progress-track span {
display: block;
height: 100%;
border-radius: inherit;
background: #1d5fd0;
}
.timeline-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-step {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e7edf5;
border-radius: 8px;
background: #ffffff;
color: inherit;
cursor: pointer;
text-align: left;
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.timeline-step.active {
border-color: #1d5fd0;
background: #f3f7ff;
box-shadow: 0 0 0 2px rgb(29 95 208 / 10%);
}
.timeline-step:hover {
transform: translateY(-1px);
}
.timeline-index {
display: inline-flex;
flex: 0 0 auto;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 999px;
color: #ffffff;
font-size: 12px;
font-style: normal;
font-weight: 700;
}
.timeline-text {
min-width: 0;
flex: 1 1 auto;
}
.timeline-step strong,
.timeline-step small {
display: block;
}
.timeline-step strong {
overflow: hidden;
color: #182033;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeline-step small {
margin-top: 3px;
color: #748093;
font-size: 12px;
line-height: 1.4;
}
.node-detail {
padding: 12px;
border: 1px solid #d9e5ff;
border-radius: 8px;
background: #f7faff;
}
.node-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.node-detail-head strong,
.node-detail-head small {
display: block;
}
.node-detail-head strong {
color: #172033;
font-size: 14px;
}
.node-detail-head small {
margin-top: 4px;
color: #748093;
font-size: 12px;
}
.node-detail p {
margin: 6px 0 0;
color: #596578;
font-size: 13px;
line-height: 1.6;
}
.node-detail p span {
display: inline-block;
margin-right: 8px;
color: #8a5a12;
font-weight: 600;
}
.node-detail dl {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
gap: 6px 10px;
margin: 10px 0 0;
padding: 10px;
border-radius: 6px;
background: #ffffff;
font-size: 12px;
}
.node-detail dt {
color: #7b8798;
}
.node-detail dd {
margin: 0;
overflow: hidden;
color: #172033;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-detail pre {
max-height: 160px;
margin: 10px 0 0;
padding: 10px;
overflow: auto;
border-radius: 6px;
background: #172033;
color: #eef4ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
@media (max-width: 960px) {
.pipeline-viewer {
grid-template-columns: 1fr;
height: auto;
}
.execution-toolbar {
flex-direction: column;
}
.execution-actions {
justify-content: flex-start;
}
.execution-output {
height: min(520px, 70vh);
}
.pipeline-side {
position: static;
height: auto;
max-height: none;
overflow: visible;
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.45;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
</style>