mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2025-01-08 16:45:30 +00:00
Remove xterm and use ansi converter for logs (#1067)
* Steaming works without flickering * Text can be correctly copied * Show only selected step output when streaming * Improved exit code colors for better readability * Adds time display on right side When compiled assets/Build.js size was 355K, now it is 26K Fixes #1012 Fixes #998 Co-authored-by: Anbraten <anton@ju60.de>
This commit is contained in:
parent
98636a5493
commit
2f5e5b8e2c
4 changed files with 318 additions and 136 deletions
|
@ -20,7 +20,7 @@
|
|||
"@intlify/vite-plugin-vue-i18n": "^3.4.0",
|
||||
"@kyvg/vue3-notification": "2.3.4",
|
||||
"@meforma/vue-toaster": "1.2.2",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"ansi_up": "^5.1.0",
|
||||
"dayjs": "1.10.7",
|
||||
"floating-vue": "2.0.0-beta.5",
|
||||
"fuse.js": "6.4.6",
|
||||
|
@ -31,10 +31,7 @@
|
|||
"pinia": "2.0.0",
|
||||
"vue": "v3.2.20",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4.0.10",
|
||||
"xterm": "4.17.0",
|
||||
"xterm-addon-fit": "0.5.0",
|
||||
"xterm-addon-web-links": "0.5.1"
|
||||
"vue-router": "4.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "1.1.421",
|
||||
|
|
|
@ -23,8 +23,26 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div v-show="loadedLogs" class="w-full flex-grow p-2">
|
||||
<div id="terminal" class="w-full h-full" />
|
||||
<div
|
||||
v-show="loadedLogs"
|
||||
ref="consoleElement"
|
||||
class="
|
||||
w-full
|
||||
max-w-full
|
||||
grid grid-cols-[min-content,1fr,min-content]
|
||||
auto-rows-min
|
||||
flex-grow
|
||||
p-2
|
||||
gap-x-2
|
||||
overflow-x-hidden overflow-y-auto
|
||||
"
|
||||
>
|
||||
<div v-for="line in log" :key="line.index" class="contents font-mono">
|
||||
<span class="text-gray-500 whitespace-nowrap select-none text-right">{{ line.index + 1 }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="align-top text-color whitespace-pre-wrap break-words" v-html="line.text" />
|
||||
<span class="text-gray-500 whitespace-nowrap select-none text-right">{{ formatTime(line.time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-auto text-xl text-color">
|
||||
|
@ -36,8 +54,8 @@
|
|||
|
||||
<div
|
||||
v-if="proc?.end_time !== undefined"
|
||||
:class="proc.exit_code == 0 ? 'dark:text-lime-400 text-lime-600' : 'dark:text-red-400 text-red-600'"
|
||||
class="w-full bg-gray-400 dark:bg-dark-gray-800 text-md p-4"
|
||||
:class="proc.exit_code == 0 ? 'dark:text-lime-400 text-lime-700' : 'dark:text-red-400 text-red-600'"
|
||||
class="w-full bg-gray-200 dark:bg-dark-gray-800 text-md p-4"
|
||||
>
|
||||
{{ $t('repo.build.exit_code', { exitCode: proc.exit_code }) }}
|
||||
</div>
|
||||
|
@ -46,34 +64,26 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import 'xterm/css/xterm.css';
|
||||
import '~/style/console.css';
|
||||
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
inject,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
PropType,
|
||||
Ref,
|
||||
ref,
|
||||
toRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import AnsiUp from 'ansi_up';
|
||||
import { debounce } from 'lodash';
|
||||
import { computed, defineComponent, inject, nextTick, onMounted, PropType, Ref, ref, toRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { WebLinksAddon } from 'xterm-addon-web-links';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import Icon from '~/components/atomic/Icon.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useDarkMode } from '~/compositions/useDarkMode';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { Build, Repo } from '~/lib/api/types';
|
||||
import { findProc, isProcFinished, isProcRunning } from '~/utils/helpers';
|
||||
|
||||
type LogLine = {
|
||||
index: number;
|
||||
text: string;
|
||||
time?: number;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BuildLog',
|
||||
|
||||
|
@ -110,24 +120,78 @@ export default defineComponent({
|
|||
const procSlug = computed(() => `${repo?.value.owner} - ${repo?.value.name} - ${build.value.id} - ${procId.value}`);
|
||||
const proc = computed(() => build.value && findProc(build.value.procs || [], procId.value));
|
||||
const stream = ref<EventSource>();
|
||||
const term = ref(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
disableStdin: true,
|
||||
theme: {
|
||||
cursor: 'transparent',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fitAddon = ref(new FitAddon());
|
||||
const loadedLogs = ref(true);
|
||||
const autoScroll = ref(true); // TODO
|
||||
const log = ref<LogLine[]>();
|
||||
const consoleElement = ref<Element>();
|
||||
|
||||
const loadedLogs = computed(() => !!log.value);
|
||||
const autoScroll = ref(true); // TODO: allow enable / disable
|
||||
const showActions = ref(false);
|
||||
const downloadInProgress = ref(false);
|
||||
const ansiUp = ref(new AnsiUp());
|
||||
ansiUp.value.use_classes = true;
|
||||
const logBuffer = ref<LogLine[]>([]);
|
||||
|
||||
const maxLineCount = 500; // TODO: think about way to support lazy-loading more than last 300 logs (#776)
|
||||
|
||||
function formatTime(time?: number): string {
|
||||
return time === undefined ? '' : `${time}s`;
|
||||
}
|
||||
|
||||
function writeLog(line: LogLine) {
|
||||
logBuffer.value.push({
|
||||
index: line.index ?? 0,
|
||||
text: ansiUp.value.ansi_to_html(line.text),
|
||||
time: line.time ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (!consoleElement.value) {
|
||||
return;
|
||||
}
|
||||
consoleElement.value.scrollTop = consoleElement.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
const flushLogs = debounce((scroll: boolean) => {
|
||||
let buffer = logBuffer.value.slice(-maxLineCount);
|
||||
logBuffer.value = [];
|
||||
|
||||
if (buffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// append old logs lines
|
||||
if (buffer.length < maxLineCount && log.value) {
|
||||
buffer = [...log.value.slice(-(maxLineCount - buffer.length)), ...buffer];
|
||||
}
|
||||
|
||||
// deduplicate repeating times
|
||||
buffer = buffer.reduce(
|
||||
(acc, line) => ({
|
||||
lastTime: line.time ?? 0,
|
||||
lines: [
|
||||
...acc.lines,
|
||||
{
|
||||
...line,
|
||||
time: acc.lastTime === line.time ? undefined : line.time,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ lastTime: -1, lines: [] as LogLine[] },
|
||||
).lines;
|
||||
|
||||
log.value = buffer;
|
||||
|
||||
if (scroll && autoScroll.value) {
|
||||
scrollDown();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
async function download() {
|
||||
if (!repo?.value || !build.value || !proc.value) {
|
||||
throw new Error('The reposiotry, build or proc was undefined');
|
||||
throw new Error('The repository, build or proc was undefined');
|
||||
}
|
||||
let logs;
|
||||
try {
|
||||
|
@ -163,9 +227,10 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
loadedProcSlug.value = procSlug.value;
|
||||
loadedLogs.value = false;
|
||||
term.value.reset();
|
||||
term.value.write('\x1b[?25l');
|
||||
log.value = [];
|
||||
logBuffer.value = [];
|
||||
ansiUp.value = new AnsiUp();
|
||||
ansiUp.value.use_classes = true;
|
||||
|
||||
if (!repo) {
|
||||
throw new Error('Unexpected: "repo" should be provided at this place');
|
||||
|
@ -188,13 +253,8 @@ export default defineComponent({
|
|||
|
||||
if (isProcFinished(proc.value)) {
|
||||
const logs = await apiClient.getLogs(repo.value.owner, repo.value.name, build.value.number, proc.value.pid);
|
||||
term.value.write(
|
||||
logs
|
||||
.slice(Math.max(logs.length, 0) - 300, logs.length) // TODO: think about way to support lazy-loading more than last 300 logs (#776)
|
||||
.map((line) => `${(line.pos || 0).toString().padEnd(logs.length.toString().length)} ${line.out}`)
|
||||
.join(''),
|
||||
);
|
||||
loadedLogs.value = true;
|
||||
logs.forEach((line) => writeLog({ index: line.pos, text: line.out, time: line.time }));
|
||||
flushLogs(false);
|
||||
}
|
||||
|
||||
if (isProcRunning(proc.value)) {
|
||||
|
@ -205,42 +265,18 @@ export default defineComponent({
|
|||
repo.value.name,
|
||||
build.value.number,
|
||||
proc.value.ppid,
|
||||
(l) => {
|
||||
loadedLogs.value = true;
|
||||
term.value.write(l.out, () => {
|
||||
if (autoScroll.value) {
|
||||
term.value.scrollToBottom();
|
||||
}
|
||||
});
|
||||
(line) => {
|
||||
if (line?.proc !== proc.value?.name) {
|
||||
return;
|
||||
}
|
||||
writeLog({ index: line.pos, text: line.out, time: line.time });
|
||||
flushLogs(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
fitAddon.value.fit();
|
||||
}
|
||||
|
||||
const unmounted = ref(false);
|
||||
onMounted(async () => {
|
||||
term.value.loadAddon(fitAddon.value);
|
||||
term.value.loadAddon(new WebLinksAddon());
|
||||
|
||||
await nextTick(() => {
|
||||
if (unmounted.value) {
|
||||
// need to check if unmounted already because we are async here
|
||||
return;
|
||||
}
|
||||
const element = document.getElementById('terminal');
|
||||
if (element === null) {
|
||||
throw new Error('Unexpected: "terminal" should be provided at this place');
|
||||
}
|
||||
term.value.open(element);
|
||||
fitAddon.value.fit();
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
});
|
||||
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
|
@ -248,44 +284,15 @@ export default defineComponent({
|
|||
loadLogs();
|
||||
});
|
||||
|
||||
const { darkMode } = useDarkMode();
|
||||
watch(
|
||||
darkMode,
|
||||
() => {
|
||||
if (darkMode.value) {
|
||||
term.value.options = {
|
||||
theme: {
|
||||
background: '#303440', // dark-gray-700
|
||||
foreground: '#d3d3d3', // gray-...
|
||||
},
|
||||
};
|
||||
} else {
|
||||
term.value.options = {
|
||||
theme: {
|
||||
background: 'rgb(209,213,219)', // gray-300
|
||||
foreground: '#000',
|
||||
selection: '#000',
|
||||
},
|
||||
};
|
||||
watch(proc, (oldProc, newProc) => {
|
||||
if (oldProc && oldProc.name === newProc?.name && oldProc?.end_time !== newProc?.end_time) {
|
||||
if (autoScroll.value) {
|
||||
scrollDown();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted.value = true;
|
||||
if (stream.value) {
|
||||
stream.value.close();
|
||||
}
|
||||
const element = document.getElementById('terminal');
|
||||
if (element !== null) {
|
||||
// Clean up any custom DOM added in onMounted above
|
||||
element.innerHTML = '';
|
||||
}
|
||||
window.removeEventListener('resize', resize);
|
||||
});
|
||||
|
||||
return { proc, loadedLogs, showActions, download, downloadInProgress };
|
||||
return { consoleElement, proc, log, loadedLogs, formatTime, showActions, download, downloadInProgress };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
195
web/src/style/console.css
Normal file
195
web/src/style/console.css
Normal file
|
@ -0,0 +1,195 @@
|
|||
.ansi-black-fg {
|
||||
color: #374151;
|
||||
}
|
||||
.ansi-red-fg {
|
||||
color: #cc0000;
|
||||
}
|
||||
.ansi-green-fg {
|
||||
color: #4e9a06;
|
||||
}
|
||||
.ansi-yellow-fg {
|
||||
color: #c4a000;
|
||||
}
|
||||
.ansi-blue-fg {
|
||||
color: #729fcf;
|
||||
}
|
||||
.ansi-magenta-fg {
|
||||
color: #75507b;
|
||||
}
|
||||
.ansi-cyan-fg {
|
||||
color: #06989a;
|
||||
}
|
||||
.ansi-white-fg {
|
||||
color: #d3d7cf;
|
||||
}
|
||||
.ansi-bright-black-fg {
|
||||
color: #555753;
|
||||
}
|
||||
.ansi-bright-red-fg {
|
||||
color: #ef2929;
|
||||
}
|
||||
.ansi-bright-green-fg {
|
||||
color: #8ae234;
|
||||
}
|
||||
.ansi-bright-yellow-fg {
|
||||
color: #fce94f;
|
||||
}
|
||||
.ansi-bright-blue-fg {
|
||||
color: #32afff;
|
||||
}
|
||||
.ansi-bright-magenta-fg {
|
||||
color: #ad7fa8;
|
||||
}
|
||||
.ansi-bright-cyan-fg {
|
||||
color: #34e2e2;
|
||||
}
|
||||
.ansi-bright-white-fg {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ansi-black-bg {
|
||||
background-color: #374151;
|
||||
}
|
||||
.ansi-red-bg {
|
||||
background-color: #cc0000;
|
||||
}
|
||||
.ansi-green-bg {
|
||||
background-color: #4e9a06;
|
||||
}
|
||||
.ansi-yellow-bg {
|
||||
background-color: #c4a000;
|
||||
}
|
||||
.ansi-blue-bg {
|
||||
background-color: #729fcf;
|
||||
}
|
||||
.ansi-magenta-bg {
|
||||
background-color: #75507b;
|
||||
}
|
||||
.ansi-cyan-bg {
|
||||
background-color: #06989a;
|
||||
}
|
||||
.ansi-white-bg {
|
||||
background-color: #d3d7cf;
|
||||
}
|
||||
.ansi-bright-black-bg {
|
||||
background-color: #555753;
|
||||
}
|
||||
.ansi-bright-red-bg {
|
||||
background-color: #ef2929;
|
||||
}
|
||||
.ansi-bright-green-bg {
|
||||
background-color: #8ae234;
|
||||
}
|
||||
.ansi-bright-yellow-bg {
|
||||
background-color: #fce94f;
|
||||
}
|
||||
.ansi-bright-blue-bg {
|
||||
background-color: #32afff;
|
||||
}
|
||||
.ansi-bright-magenta-bg {
|
||||
background-color: #ad7fa8;
|
||||
}
|
||||
.ansi-bright-cyan-bg {
|
||||
background-color: #34e2e2;
|
||||
}
|
||||
.ansi-bright-white-bg {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.dark .ansi-black-fg {
|
||||
color: #666666;
|
||||
}
|
||||
.dark .ansi-red-fg {
|
||||
color: #ff7070;
|
||||
}
|
||||
.dark .ansi-green-fg {
|
||||
color: #b0f986;
|
||||
}
|
||||
.dark .ansi-yellow-fg {
|
||||
color: #c6c502;
|
||||
}
|
||||
.dark .ansi-blue-fg {
|
||||
color: #8db7e0;
|
||||
}
|
||||
.dark .ansi-magenta-fg {
|
||||
color: #f271fb;
|
||||
}
|
||||
.dark .ansi-cyan-fg {
|
||||
color: #6bf7ff;
|
||||
}
|
||||
.dark .ansi-white-fg {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.dark .ansi-bright-black-fg {
|
||||
color: #838887;
|
||||
}
|
||||
.dark .ansi-bright-red-fg {
|
||||
color: #ff3333;
|
||||
}
|
||||
.dark .ansi-bright-green-fg {
|
||||
color: #00ff00;
|
||||
}
|
||||
.dark .ansi-bright-yellow-fg {
|
||||
color: #fffc67;
|
||||
}
|
||||
.dark .ansi-bright-blue-fg {
|
||||
color: #6871ff;
|
||||
}
|
||||
.dark .ansi-bright-magenta-fg {
|
||||
color: #ff76ff;
|
||||
}
|
||||
.dark .ansi-bright-cyan-fg {
|
||||
color: #60fcff;
|
||||
}
|
||||
.dark .ansi-bright-white-fg {
|
||||
color: #e6e3e3;
|
||||
}
|
||||
|
||||
.dark .ansi-black-bg {
|
||||
background-color: #666666;
|
||||
}
|
||||
.dark .ansi-red-bg {
|
||||
background-color: #ff7070;
|
||||
}
|
||||
.dark .ansi-green-bg {
|
||||
background-color: #b0f986;
|
||||
}
|
||||
.dark .ansi-yellow-bg {
|
||||
background-color: #c6c502;
|
||||
}
|
||||
.dark .ansi-blue-bg {
|
||||
background-color: #8db7e0;
|
||||
}
|
||||
.dark .ansi-magenta-bg {
|
||||
background-color: #f271fb;
|
||||
}
|
||||
.dark .ansi-cyan-bg {
|
||||
background-color: #6bf7ff;
|
||||
}
|
||||
.dark .ansi-white-bg {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
.dark .ansi-bright-black-bg {
|
||||
background-color: #838887;
|
||||
}
|
||||
.dark .ansi-bright-red-bg {
|
||||
background-color: #ff3333;
|
||||
}
|
||||
.dark .ansi-bright-green-bg {
|
||||
background-color: #00ff00;
|
||||
}
|
||||
.dark .ansi-bright-yellow-bg {
|
||||
background-color: #fffc67;
|
||||
}
|
||||
.dark .ansi-bright-blue-bg {
|
||||
background-color: #6871ff;
|
||||
}
|
||||
.dark .ansi-bright-magenta-bg {
|
||||
background-color: #ff76ff;
|
||||
}
|
||||
.dark .ansi-bright-cyan-bg {
|
||||
background-color: #60fcff;
|
||||
}
|
||||
.dark .ansi-bright-white-bg {
|
||||
background-color: #e6e3e3;
|
||||
}
|
|
@ -677,12 +677,10 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
|||
dependencies:
|
||||
color-convert "^2.0.1"
|
||||
|
||||
ansi-to-html@0.7.2:
|
||||
version "0.7.2"
|
||||
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.7.2.tgz#a92c149e4184b571eb29a0135ca001a8e2d710cb"
|
||||
integrity sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==
|
||||
dependencies:
|
||||
entities "^2.2.0"
|
||||
ansi_up@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi_up/-/ansi_up-5.1.0.tgz#9cf10e6d359bb434bdcfab5ae4c3abfe1617b6db"
|
||||
integrity sha512-3wwu+nJCKBVBwOCurm0uv91lMoVkhFB+3qZQz3U11AmAdDJ4tkw1sNPWJQcVxMVYwe0pGEALOjSBOxdxNc+pNQ==
|
||||
|
||||
anymatch@~3.1.2:
|
||||
version "3.1.2"
|
||||
|
@ -1097,7 +1095,7 @@ enquirer@^2.3.5:
|
|||
dependencies:
|
||||
ansi-colors "^4.1.1"
|
||||
|
||||
entities@^2.0.0, entities@^2.2.0:
|
||||
entities@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
|
||||
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
|
||||
|
@ -3242,21 +3240,6 @@ wrappy@1:
|
|||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
||||
|
||||
xterm-addon-fit@0.5.0:
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596"
|
||||
integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ==
|
||||
|
||||
xterm-addon-web-links@0.5.1:
|
||||
version "0.5.1"
|
||||
resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.5.1.tgz#73bfa3ed567af98fba947f638bd12093ee2a0bc6"
|
||||
integrity sha512-dBjbOIrCNmxAcUQkkSrKj9BM6yLpmqUpZ9SOCUuZe/sznPl4d8OBZQClK7VcdZ0vf0+5i5Fce2rUUrew/XTZTg==
|
||||
|
||||
xterm@4.17.0:
|
||||
version "4.17.0"
|
||||
resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.17.0.tgz#e48ba6eeb83e118ec163f5512c3cfe9dbbf3f838"
|
||||
integrity sha512-WGXlIHvLvZKtwMdFaL6kUwp+c9abd2Pcakp/GmuefBuOtGCu9fP9tBDPKyL/A17N+5tt44EYk3YsBbvkPBubMw==
|
||||
|
||||
yallist@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
|
|
Loading…
Reference in a new issue