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:
Lauris BH 2022-08-08 16:29:52 +03:00 committed by GitHub
parent 98636a5493
commit 2f5e5b8e2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 318 additions and 136 deletions

View file

@ -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",

View file

@ -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
View 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;
}

View file

@ -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"