Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove xterm and use ansi converter for logs #1067

Merged
merged 7 commits into from
Aug 8, 2022

Conversation

lafriks
Copy link
Contributor

@lafriks lafriks commented Aug 3, 2022

  • 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

Screenshots:

attels

attels

@qwerty287
Copy link
Contributor

Are the issues that the move to xterm fixed as a side effect also fixed here? They are #718, #768 and #776

@6543 6543 added server ui frontend related refactor delete or replace old code labels Aug 3, 2022
@6543
Copy link
Member

6543 commented Aug 3, 2022

we first make sure the issues that termJS saved are still saved after this merge

@lafriks
Copy link
Contributor Author

lafriks commented Aug 3, 2022

Are the issues that the move to xterm fixed as a side effect also fixed here? They are #718, #768 and #776

Retested and fixed all these issues

@lafriks
Copy link
Contributor Author

lafriks commented Aug 3, 2022

Did a small improvement to show only unique time points:
attels

@qwerty287 qwerty287 added this to the 1.0.0 milestone Aug 4, 2022
@6543
Copy link
Member

6543 commented Aug 7, 2022

Important: is output sanitized? -> XSS !!!

@lafriks
Copy link
Contributor Author

lafriks commented Aug 7, 2022

Important: is output sanitized? -> XSS !!!

@6543 yes ansi_up library does both - console color rendering and html escaping
attels

@anbraten
Copy link
Member

anbraten commented Aug 8, 2022

@lafriks Could you try those adjustments of the BuildLog file.

BuildLog
<template>
  <div v-if="build" class="flex flex-col pt-10 md:pt-0">
    <div
      class="fixed top-0 left-0 w-full md:hidden flex px-4 py-2 bg-gray-600 dark:bg-dark-gray-800 text-gray-50"
      @click="$emit('update:proc-id', null)"
    >
      <span>{{ proc?.name }}</span>
      <Icon name="close" class="ml-auto" />
    </div>

    <div
      class="flex flex-grow flex-col bg-gray-300 dark:bg-dark-gray-700 md:m-2 md:mt-0 md:rounded-md overflow-hidden"
      @mouseover="showActions = true"
      @mouseleave="showActions = false"
    >
      <div v-show="showActions" class="absolute top-0 right-0 z-50 mt-2 mr-4 hidden md:flex">
        <Button
          v-if="proc?.end_time !== undefined"
          :is-loading="downloadInProgress"
          :title="$t('repo.build.actions.log_download')"
          start-icon="download"
          @click="download"
        />
      </div>

      <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">
        <span v-if="proc?.error" class="text-red-400">{{ proc.error }}</span>
        <span v-else-if="proc?.state === 'skipped'" class="text-red-400">{{ $t('repo.build.actions.canceled') }}</span>
        <span v-else-if="!proc?.start_time">{{ $t('repo.build.step_not_started') }}</span>
        <div v-else-if="!loadedLogs">{{ $t('repo.build.loading') }}</div>
      </div>

      <div
        v-if="proc?.end_time !== undefined"
        :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>
    </div>
  </div>
</template>

<script lang="ts">
import '~/style/console.css';

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 Button from '~/components/atomic/Button.vue';
import Icon from '~/components/atomic/Icon.vue';
import useApiClient from '~/compositions/useApiClient';
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',

  components: { Icon, Button },

  props: {
    build: {
      type: Object as PropType<Build>,
      required: true,
    },

    // used by toRef
    // eslint-disable-next-line vue/no-unused-properties
    procId: {
      type: Number,
      required: true,
    },
  },

  emits: {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    'update:proc-id': (procId: number | null) => true,
  },

  setup(props) {
    const notifications = useNotifications();
    const i18n = useI18n();
    const build = toRef(props, 'build');
    const procId = toRef(props, 'procId');
    const repo = inject<Ref<Repo>>('repo');
    const apiClient = useApiClient();

    const loadedProcSlug = ref<string>();
    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 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 flushTimer = ref<number>();

    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(() => {
      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 (autoScroll.value) {
        scrollDown();
      }
    }, 100);

    async function download() {
      if (!repo?.value || !build.value || !proc.value) {
        throw new Error('The repository, build or proc was undefined');
      }
      let logs;
      try {
        downloadInProgress.value = true;
        logs = await apiClient.getLogs(repo.value.owner, repo.value.name, build.value.number, proc.value.pid);
      } catch (e) {
        notifications.notifyError(e, i18n.t('repo.build.log_download_error'));
        return;
      } finally {
        downloadInProgress.value = false;
      }
      const fileURL = window.URL.createObjectURL(
        new Blob([logs.map((line) => line.out).join('')], {
          type: 'text/plain',
        }),
      );
      const fileLink = document.createElement('a');

      fileLink.href = fileURL;
      fileLink.setAttribute(
        'download',
        `${repo.value.owner}-${repo.value.name}-${build.value.number}-${proc.value.name}.log`,
      );
      document.body.appendChild(fileLink);

      fileLink.click();
      document.body.removeChild(fileLink);
      window.URL.revokeObjectURL(fileURL);
    }

    async function loadLogs() {
      if (loadedProcSlug.value === procSlug.value) {
        return;
      }
      loadedProcSlug.value = procSlug.value;
      log.value = [];
      logBuffer.value = [];
      ansiUp.value = new AnsiUp();
      ansiUp.value.use_classes = true;
      if (flushTimer.value) {
        window.clearInterval(flushTimer.value);
        flushTimer.value = undefined;
      }

      if (!repo) {
        throw new Error('Unexpected: "repo" should be provided at this place');
      }

      if (stream.value) {
        stream.value.close();
      }

      // we do not have logs for skipped jobs
      if (
        !repo.value ||
        !build.value ||
        !proc.value ||
        proc.value.state === 'skipped' ||
        proc.value.state === 'killed'
      ) {
        return;
      }

      if (isProcFinished(proc.value)) {
        const logs = await apiClient.getLogs(repo.value.owner, repo.value.name, build.value.number, proc.value.pid);
        logs.forEach((line) => writeLog({ index: line.pos, text: line.out, time: line.time }));
        flushLogs();
      }

      if (isProcRunning(proc.value)) {
        // load stream of parent process (which receives all child processes logs)
        // TODO: change stream to only send data of single child process
        stream.value = apiClient.streamLogs(
          repo.value.owner,
          repo.value.name,
          build.value.number,
          proc.value.ppid,
          (line) => {
            if (line?.proc !== proc.value?.name) {
              return;
            }
            writeLog({ index: line.pos, text: line.out, time: line.time });
            flushLogs();
          },
        );
      }
    }

    onMounted(async () => {
      loadLogs();
    });

    watch(procSlug, () => {
      loadLogs();
    });

    watch(proc, (oldProc, newProc) => {
      if (oldProc && oldProc.name === newProc?.name && oldProc?.end_time !== newProc?.end_time) {
        if (autoScroll.value) {
          scrollDown();
        }
      }
    });

    return { consoleElement, proc, log, loadedLogs, formatTime, showActions, download, downloadInProgress };
  },
});
</script>

@anbraten anbraten changed the title Remove xterm and use custom job build output rendering Remove xterm and use ansi converter for logs Aug 8, 2022
Co-Authored-By: Anbraten <anton@ju60.de>
@lafriks
Copy link
Contributor Author

lafriks commented Aug 8, 2022

@anbraten done with minor change to not scroll log to bottom if showing finished job log

Co-authored-by: Anbraten <anton@ju60.de>
@6543 6543 merged commit 2f5e5b8 into woodpecker-ci:master Aug 8, 2022
@lafriks lafriks deleted the feat/custom_console_output branch August 8, 2022 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
refactor delete or replace old code server ui frontend related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Terminal issues Task log output colors have numbering also colored
4 participants