1
0
mirror of https://github.com/woodpecker-ci/woodpecker.git synced 2026-06-03 16:35:37 +02:00

Group logs by command in step logs UI and make them collapsible (#6398)

Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
Co-authored-by: Robert Kaussow <mail@thegeeklab.de>
This commit is contained in:
Lê Hoàng Phương
2026-05-11 20:02:01 +09:00
committed by GitHub
parent d9c25cd601
commit 23260bc7b9
3 changed files with 236 additions and 38 deletions
+3 -1
View File
@@ -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!",
+6
View File
@@ -83,6 +83,8 @@
<SvgIcon v-else-if="name === 'forge'" :bg-circle="bgCircle" :path="mdiCodeBraces" size="1.3rem" />
<SvgIcon v-else-if="name === 'fullscreen'" :bg-circle="bgCircle" :path="mdiFullscreen" size="1.3rem" />
<SvgIcon v-else-if="name === 'exit-fullscreen'" :bg-circle="bgCircle" :path="mdiFullscreenExit" size="1.3rem" />
<SvgIcon v-else-if="name === 'expand-all'" :bg-circle="bgCircle" :path="mdiUnfoldMoreHorizontal" size="1.3rem" />
<SvgIcon v-else-if="name === 'collapse-all'" :bg-circle="bgCircle" :path="mdiUnfoldLessHorizontal" size="1.3rem" />
<SvgIcon v-else-if="name === 'visibility-private'" :bg-circle="bgCircle" :path="mdiLockOutline" size="1.3rem" />
<SvgIcon v-else-if="name === 'visibility-internal'" :bg-circle="bgCircle" :path="mdiLockOpenOutline" size="1.3rem" />
@@ -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';
+227 -37
View File
@@ -53,50 +53,92 @@
:icon="autoScroll ? 'auto-scroll' : 'auto-scroll-off'"
@click="autoScroll = !autoScroll"
/>
<IconButton class="hover:bg-white/10! md:hidden!" icon="close" @click="$emit('update:step-id', null)" />
<template v-if="hasGroupedLogs">
<div class="border-wp-background-400 dark:border-wp-background-100 mx-1 h-5 border-l" />
<IconButton
:title="$t('repo.pipeline.actions.expand_all')"
class="hover:bg-white/10!"
icon="expand-all"
@click="expandAll"
/>
<IconButton
:title="$t('repo.pipeline.actions.collapse_all')"
class="hover:bg-white/10!"
icon="collapse-all"
@click="collapseAll"
/>
<IconButton class="hover:bg-white/10! md:hidden!" icon="close" @click="$emit('update:step-id', null)" />
</template>
</div>
</div>
<div
v-show="hasLogs && loadedLogs && (log?.length || 0) > 0"
ref="consoleElement"
class="grid w-full max-w-full grow auto-rows-min grid-cols-[min-content_minmax(0,1fr)_min-content] overflow-x-hidden overflow-y-auto p-4 text-xs md:text-sm"
class="grid w-full max-w-full grow scroll-pt-8 auto-rows-min grid-cols-[min-content_minmax(0,1fr)_min-content] overflow-x-hidden overflow-y-auto p-4 text-xs md:text-sm"
>
<div v-for="line in log" :key="line.index" class="contents font-mono">
<a
:id="`L${line.number}`"
:href="`#L${line.number}`"
class="text-wp-code-text-alt-100 pr-6 pl-2 text-right whitespace-nowrap select-none"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
underline: isSelected(line),
}"
<div v-for="group in groupedLogs" :key="group.id" class="contents">
<div
v-if="group.isActualCommand"
class="sticky -top-4 z-10 col-span-3 my-1 flex cursor-pointer items-center rounded-sm px-2 py-1 font-mono text-sm shadow-xs"
:class="[group.command && isSelected(group.command) ? 'bg-blue-900' : 'bg-wp-code-100']"
@click="toggleGroup(group.id)"
>
{{ line.number }}
</a>
<!-- eslint-disable vue/no-v-html -->
<span
class="wrap-break-words align-top whitespace-pre-wrap"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
}"
v-html="line.text"
/>
<!-- eslint-enable vue/no-v-html -->
<span
class="text-wp-code-text-alt-100 pr-1 text-right whitespace-nowrap select-none"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
}"
>
{{ formatTime(line.time) }}
</span>
<Icon
name="chevron-right"
class="mr-2 transition-transform"
:class="{
'rotate-90': !collapsedCommands.has(group.id),
invisible: group.lines.length === 0,
}"
/>
<!-- eslint-disable vue/no-v-html -->
<span
v-if="group.command"
:id="`L${group.command?.number}`"
class="flex-1 truncate"
v-html="group.command.text?.substring(2)"
/>
</div>
<template v-if="!collapsedCommands.has(group.id)">
<div v-for="line in group.lines" :key="line.index" class="contents font-mono">
<a
:id="`L${line.number}`"
:href="`#L${line.number}`"
class="text-wp-code-text-alt-100 pr-6 pl-2 text-right whitespace-nowrap select-none"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
underline: isSelected(line),
}"
>
{{ line.number }}
</a>
<!-- eslint-disable vue/no-v-html -->
<span
class="wrap-break-words align-top whitespace-pre-wrap"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
}"
v-html="line.text"
/>
<!-- eslint-enable vue/no-v-html -->
<span
class="text-wp-code-text-alt-100 pr-1 text-right whitespace-nowrap select-none"
:class="{
'bg-red-600/40 dark:bg-red-800/50': line.type === 'error',
'bg-yellow-600/40 dark:bg-yellow-800/50': line.type === 'warning',
'bg-blue-600/30': isSelected(line),
}"
>
{{ formatTime(line.time) }}
</span>
</div>
</template>
</div>
</div>
@@ -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<number>());
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<number>();
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<LogLine>) {
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 },
);
</script>