You've already forked woodpecker
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:
@@ -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!",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user