diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index d63d76b0c..e0069db5d 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -236,7 +236,9 @@ "log_download": "Download", "log_delete": "Delete", "log_auto_scroll": "Enable automatic scrolling", - "log_auto_scroll_off": "Disable automatic scrolling" + "log_auto_scroll_off": "Disable automatic scrolling", + "expand_all": "Expand all", + "collapse_all": "Collapse all" }, "protected": { "awaits": "This pipeline is awaiting approval from a maintainer!", diff --git a/web/src/components/atomic/Icon.vue b/web/src/components/atomic/Icon.vue index 9c3d68cd8..8662c02cd 100644 --- a/web/src/components/atomic/Icon.vue +++ b/web/src/components/atomic/Icon.vue @@ -83,6 +83,8 @@ + + @@ -187,6 +189,8 @@ import { mdiToolboxOutline, mdiTrashCanOutline, mdiTrayFull, + mdiUnfoldLessHorizontal, + mdiUnfoldMoreHorizontal, mdiWrenchCogOutline, } from '@mdi/js'; import { siForgejo, siGitea } from 'simple-icons'; @@ -267,6 +271,8 @@ export type IconNames = | 'forge' | 'fullscreen' | 'exit-fullscreen' + | 'expand-all' + | 'collapse-all' | 'folder' | 'folder-open' | 'file'; diff --git a/web/src/components/repo/pipeline/PipelineLog.vue b/web/src/components/repo/pipeline/PipelineLog.vue index c371e800e..0bb405ed8 100644 --- a/web/src/components/repo/pipeline/PipelineLog.vue +++ b/web/src/components/repo/pipeline/PipelineLog.vue @@ -53,50 +53,92 @@ :icon="autoScroll ? 'auto-scroll' : 'auto-scroll-off'" @click="autoScroll = !autoScroll" /> - +
-
- +
- {{ line.number }} - - - - - - {{ formatTime(line.time) }} - + + + +
+ +
@@ -130,23 +172,32 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRef, watch } fro import { useI18n } from 'vue-i18n'; import { useRoute } from 'vue-router'; +import Icon from '~/components/atomic/Icon.vue'; import IconButton from '~/components/atomic/IconButton.vue'; import PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue'; import useApiClient from '~/compositions/useApiClient'; import useConfig from '~/compositions/useConfig'; import { requiredInject } from '~/compositions/useInjectProvide'; import useNotifications from '~/compositions/useNotifications'; -import type { Pipeline, PipelineStep, PipelineWorkflow } from '~/lib/api/types'; +import type { Pipeline, PipelineConfig, PipelineStep, PipelineWorkflow } from '~/lib/api/types'; import { debounce } from '~/lib/utils'; interface LogLine { index: number; number: number; text?: string; + rawText?: string; time?: number; type: 'error' | 'warning' | null; } +interface LogBlock { + command: LogLine | null; + lines: LogLine[]; + id: number; + isActualCommand: boolean; +} + const props = defineProps<{ pipeline: Pipeline; stepId: number; @@ -162,6 +213,7 @@ const pipeline = toRef(props, 'pipeline'); const stepId = toRef(props, 'stepId'); const repo = requiredInject('repo'); const repoPermissions = requiredInject('repo-permissions'); +const pipelineConfigs = requiredInject('pipeline-configs'); const apiClient = useApiClient(); const route = useRoute(); @@ -191,6 +243,86 @@ const config = useConfig(); const maxLineCount = config.maxPipelineLogLineCount; // TODO(2653): implement lazy-loading support const hasPushPermission = computed(() => repoPermissions?.value?.push); +const collapsedCommands = ref(new Set()); + +const commandRegex = /^\s*-\s(.+)$/gm; +const specialCharsRegex = /[.*+?^${}()|[\]\\]/g; +const matrixVariableRegex = /\\\$(\\\{\w+\\\})/g; + +const knownCommandMatchers = computed(() => { + if (!pipelineConfigs.value) return []; + const patterns: RegExp[] = []; + pipelineConfigs.value.forEach((config: PipelineConfig) => { + const decoded = decode(config.data); + const matches = decoded.matchAll(commandRegex); + for (const match of matches) { + const rawCommand = match[1].trim(); + // Replace matrix variable ${VAR} with a wildcard match (non-greedy) + const patternString = rawCommand + .replace(specialCharsRegex, '\\$&') // escape all + .replace(matrixVariableRegex, '.*'); // match ${VAR} + + patterns.push(new RegExp(`^${patternString}$`)); + } + }); + return patterns; +}); + +const groupedLogs = computed(() => { + if (!log.value) return []; + + if (!pipelineConfigs.value || pipelineConfigs.value.length === 0) { + return [ + { + id: 0, + command: null, + lines: log.value, + isActualCommand: false, + }, + ]; + } + + const blocks: LogBlock[] = []; + let currentBlock: LogBlock | null = null; + + log.value.forEach((line) => { + const trimmedText = (line.rawText || '').trim(); + + let isCommand = false; + if (trimmedText.startsWith('+ ')) { + const cmdPart = trimmedText.slice(2).trim(); + isCommand = knownCommandMatchers.value.some((matcher) => matcher.test(cmdPart)); + } + + if (isCommand) { + currentBlock = { + command: line, + lines: [line], + id: line.number, + isActualCommand: true, + }; + blocks.push(currentBlock); + } else { + if (!currentBlock) { + currentBlock = { + command: { number: 0, text: 'Initialization', type: null, index: -1 } as LogLine, + lines: [], + id: 0, + isActualCommand: false, + }; + blocks.push(currentBlock); + } + currentBlock.lines.push(line); + } + }); + + return blocks; +}); + +const hasGroupedLogs = computed(() => { + return groupedLogs.value.find((g) => g.isActualCommand); +}); + const urlRegex = /https?:\/\/\S+/g; function isScrolledToBottom(): boolean { @@ -209,6 +341,28 @@ function formatTime(time?: number): string { return time === undefined ? '' : `${time}s`; } +function toggleGroup(id: number) { + if (collapsedCommands.value.has(id)) { + collapsedCommands.value.delete(id); + } else { + collapsedCommands.value.add(id); + } +} + +function expandAll() { + collapsedCommands.value.clear(); +} + +function collapseAll() { + const newSet = new Set(); + groupedLogs.value.forEach((group) => { + if (group.isActualCommand) { + newSet.add(group.id); + } + }); + collapsedCommands.value = newSet; +} + function processText(text: string): string { let txt = ansiUp.value.ansi_to_html(`${decode(text)}\n`); txt = txt.replace( @@ -219,10 +373,12 @@ function processText(text: string): string { } function writeLog(line: Partial) { + const rawText = decode(line.text ?? ''); logBuffer.value.push({ index: line.index ?? 0, number: (line.index ?? 0) + 1, text: processText(line.text ?? ''), + rawText, time: line.time ?? 0, type: null, // TODO: implement way to detect errors and warnings }); @@ -405,4 +561,38 @@ watch(step, async (newStep, oldStep) => { } } }); + +const expandLogGroupWithPageHash = (hash: string) => { + if (hash.startsWith('#L')) { + const lineNum = Number.parseInt(hash.substring(2)); + const parentGroup = groupedLogs.value.find((g) => lineNum === g.id || g.lines.some((l) => l.number === lineNum)); + if (parentGroup && collapsedCommands.value.has(parentGroup.id)) { + collapsedCommands.value.delete(parentGroup.id); + } + } +}; + +// When user click on a step, if the step has already finished running, show user the +// only the outline by collapse all log groups +watch(loadedLogs, async (isLoaded, wasLoaded) => { + // Only trigger when transitioning from unloaded to loaded state + if (isLoaded && !wasLoaded) { + const isFinished = step.value && !['running', 'pending', 'started'].includes(step.value.state); + if (isFinished) { + // Wait for groupedLogs computed property to update + await nextTick(); + collapseAll(); + expandLogGroupWithPageHash(route.hash); + } + } +}); + +// If route hash contain line that is in a collapsed log group, expand it +watch( + () => route.hash, + (newHash) => { + expandLogGroupWithPageHash(newHash); + }, + { immediate: true }, +);