1010 lines
22 KiB
Vue
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>
|