diff --git a/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue b/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue index f65a46dcc70853e3306308fb1bc2fca51e6fc955..ebe7ebb7375519fb43036ffe00cdb246392d2cbc 100644 --- a/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue +++ b/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui'; import { scrollToElement, backOff } from '~/lib/utils/common_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { s__, sprintf } from '~/locale'; @@ -28,6 +28,7 @@ export default { GlLink, GlButton, GlSearchBoxByClick, + GlSprintf, HelpPopover, }, directives: { @@ -78,6 +79,11 @@ export default { required: false, default: false, }, + logViewerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -190,7 +196,22 @@ export default { <div class="gl-display-none gl-sm-display-block gl-text-truncate" data-testid="showing-last"> <template v-if="isJobLogSizeVisible"> {{ jobLogSize }} - <gl-link v-if="rawPath" :href="rawPath">{{ s__('Job|View raw') }}</gl-link> + <gl-sprintf + v-if="rawPath && isComplete && logViewerPath" + :message=" + s__( + 'Job|%{rawLinkStart}View raw%{rawLinkEnd} or %{fullLinkStart}view full log%{fullLinkEnd}.', + ) + " + > + <template #rawLink="{ content }"> + <gl-link :href="rawPath">{{ content }}</gl-link> + </template> + <template #fullLink="{ content }"> + <gl-link :href="logViewerPath"> {{ content }}</gl-link> + </template> + </gl-sprintf> + <gl-link v-else-if="rawPath" :href="rawPath">{{ s__('Job|View raw') }}</gl-link> </template> </div> <!-- eo truncated log information --> diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js index 9aa01c4686e9b24a896a35ccfec257b9e5efed7d..a0c6971a939f4a86a48ca69bf942eafb69e31c6f 100644 --- a/app/assets/javascripts/ci/job_details/index.js +++ b/app/assets/javascripts/ci/job_details/index.js @@ -32,6 +32,7 @@ export const initJobDetails = () => { aiRootCauseAnalysisAvailable, testReportSummaryUrl, pipelineTestReportUrl, + logViewerPath, } = el.dataset; const fullScreenAPIAvailable = document.fullscreenEnabled; @@ -63,6 +64,7 @@ export const initJobDetails = () => { deploymentHelpUrl, runnerSettingsUrl, subscriptionsMoreMinutesUrl, + logViewerPath, }, }); }, diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index 99446029cef2c7433ba92b483842d5a4826a517b..7f04f5cf7985d53dafda6b1151ffb975d696dc0f 100644 --- a/app/assets/javascripts/ci/job_details/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -61,6 +61,11 @@ export default { required: false, default: null, }, + logViewerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -290,6 +295,7 @@ export default { }" :size="jobLogSize" :raw-path="job.raw_path" + :log-viewer-path="logViewerPath" :is-scroll-bottom-disabled="isScrollBottomDisabled" :is-scroll-top-disabled="isScrollTopDisabled" :is-job-log-size-visible="isJobLogSizeVisible" diff --git a/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue new file mode 100644 index 0000000000000000000000000000000000000000..98b0a778481f0cee478b509ccd97c5e96fd2646c --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer.vue @@ -0,0 +1,81 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + name: 'LogViewer', + components: { + GlIcon, + }, + props: { + log: { + type: Array, + default: () => [], + required: false, + }, + loading: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + hiddenSections: new Set(), // Use Set instead of Array. has() is more performant than includes() and it's executed more frequently + }; + }, + methods: { + isLineHidden(sections = []) { + for (const s of sections) { + if (this.hiddenSections.has(s)) { + return true; + } + } + return false; + }, + toggleSection(section) { + if (this.hiddenSections.has(section)) { + this.hiddenSections.delete(section); + } else { + this.hiddenSections.add(section); + } + this.hiddenSections = new Set(this.hiddenSections); // `Set` is not reactive in Vue 2, create new instance it to trigger reactivity + }, + }, +}; +</script> + +<template> + <div + class="job-log-viewer gl-font-monospace gl-p-3 gl-font-sm gl-rounded-base" + role="log" + aria-live="polite" + :aria-busy="loading" + > + <div + v-for="(line, index) in log" + v-show="!isLineHidden(line.sections)" + :key="index" + class="log-line" + :class="{ 'log-line-header': line.header }" + v-on="line.header ? { click: () => toggleSection(line.header) } : {}" + > + <div> + <gl-icon + v-if="line.header" + :name="hiddenSections.has(line.header) ? 'chevron-lg-right' : 'chevron-lg-down'" + /><a :id="`L${index + 1}`" :href="`#L${index + 1}`" class="log-line-number" @click.stop>{{ + index + 1 + }}</a> + </div> + <div> + <span v-for="(c, j) in line.content" :key="j" :class="c.style">{{ c.text }}</span> + </div> + </div> + <div v-if="loading" class="loader-animation gl-p-3"> + <span class="gl-sr-only">{{ __('Loading...') }}</span> + <div class="dot"></div> + <div class="dot"></div> + <div class="dot"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue new file mode 100644 index 0000000000000000000000000000000000000000..af0342e571c4d2b3a19beacd7d4911af6f3068c8 --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/components/log_viewer_top_bar.vue @@ -0,0 +1,24 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ExperimentBadge from '~/vue_shared/components/badges/experiment_badge.vue'; + +export default { + name: 'LogViewerFeedbackPopover', + components: { + GlLink, + ExperimentBadge, + }, +}; +</script> +<template> + <div + class="job-log-viewer-top-bar gl-display-flex gl-align-items-center gl-justify-content-space-between" + > + <div>{{ s__('Job|Full log viewer') }} <experiment-badge class="gl-display-inline" /></div> + <div> + <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/454817" target="_blank">{{ + s__('Job|Feedback issue') + }}</gl-link> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_log_viewer/index.js b/app/assets/javascripts/ci/job_log_viewer/index.js new file mode 100644 index 0000000000000000000000000000000000000000..a66d74816104d26c594c6f09b934650f95ee5cdc --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import LogViewerApp from './log_viewer_app.vue'; + +export const initJobLogViewer = async () => { + const el = document.getElementById('js-job-log-viewer'); + const { rawLogPath } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(LogViewerApp, { + props: { + rawLogPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js b/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js new file mode 100644 index 0000000000000000000000000000000000000000..127e61ec34e57a1361c73e1d8bb802a1551b2180 --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/lib/ansi_evaluator.js @@ -0,0 +1,183 @@ +const STYLES_MAP = { + bold: 1, + italic: 2, + underline: 4, + conceal: 8, + cross: 16, +}; + +const get256Color = (stack) => { + if (stack.length < 2 || stack[0] !== '5') { + return null; + } + + stack.shift(); + + const color = parseInt(stack.shift(), 10); + if (color < 0 || color > 255) { + return null; + } + return color; +}; + +/** + * Reads an ansi "Select Graphic Rendition (SGR)" sequence and returns + * CSS classes corresponding to it. + * + */ +export class AnsiEvaluator { + constructor() { + this.reset(); + } + + /** + * Starts evaluation of a "Select Graphic Rendition (SGR)" sequence + * + * Sequences ending in 'm' are SGR and set display attributes in the logs + */ + evaluate(op) { + if (op?.endsWith('m')) { + this.#evaluateStack(op.substring(0, op.length - 1).split(';')); + } + } + + /** + * Return currently present classes, should be used once the sequence has been + * evaluated. + */ + getClasses() { + const classes = []; + + if (this.fgColor !== null) { + classes.push(`xterm-fg-${this.fgColor}`); + } + + if (this.bgColor !== null) { + classes.push(`xterm-bg-${this.bgColor}`); + } + + for (const s in STYLES_MAP) { + // eslint-disable-next-line no-bitwise + if ((this.styleMask & STYLES_MAP[s]) !== 0) { + classes.push(`term-${s}`); + } + } + + return classes; + } + + /** + * Reset the current state to neutral/unassigned styles + */ + reset() { + this.fgColor = null; + this.bgColor = null; + this.styleMask = 0; + } + + // Private properties + + #evaluateStack(stack) { + const command = stack.shift(); + if (!command) { + return; + } + + switch (command) { + case '38': { + const color = get256Color(stack); + if (color !== null) { + this.fgColor = color; + } + break; + } + case '48': { + const color = get256Color(stack); + if (color !== null) { + this.bgColor = color; + } + break; + } + default: { + this.#dispatch[command]?.(); + } + } + + this.#evaluateStack(stack); + } + + #dispatch = { + 0: () => this.reset(), + + 1: () => this.#enableStyle('bold'), + 3: () => this.#enableStyle('italic'), + 4: () => this.#enableStyle('underline'), + 8: () => this.#enableStyle('conceal'), + 9: () => this.#enableStyle('cross'), + + 21: () => this.#disableStyle('bold'), + 22: () => this.#disableStyle('bold'), + 23: () => this.#disableStyle('italic'), + 24: () => this.#disableStyle('underline'), + 28: () => this.#disableStyle('conceal'), + 29: () => this.#disableStyle('cross'), + + 30: () => this.#setFg(0), + 31: () => this.#setFg(9), + 32: () => this.#setFg(10), + 33: () => this.#setFg(11), + 34: () => this.#setFg(12), + 35: () => this.#setFg(13), + 36: () => this.#setFg(14), + 37: () => this.#setFg(15), + 39: () => this.#setFg(null), + + 40: () => this.#setBg(0), + 41: () => this.#setBg(9), + 42: () => this.#setBg(10), + 43: () => this.#setBg(11), + 44: () => this.#setBg(12), + 45: () => this.#setBg(13), + 46: () => this.#setBg(14), + 47: () => this.#setBg(15), + 49: () => this.#setBg(null), + + 90: () => this.#setFg(8), + 91: () => this.#setFg(9), + 92: () => this.#setFg(10), + 93: () => this.#setFg(11), + 94: () => this.#setFg(12), + 95: () => this.#setFg(13), + 96: () => this.#setFg(14), + 97: () => this.#setFg(15), + 99: () => this.#setFg(null), + + 100: () => this.#setBg(8), + 101: () => this.#setBg(9), + 102: () => this.#setBg(10), + 103: () => this.#setBg(11), + 104: () => this.#setBg(12), + 105: () => this.#setBg(13), + 106: () => this.#setBg(14), + 107: () => this.#setBg(15), + 109: () => this.#setBg(null), + }; + + #enableStyle(flag) { + // eslint-disable-next-line no-bitwise + this.styleMask |= STYLES_MAP[flag]; + } + + #disableStyle(flag) { + // eslint-disable-next-line no-bitwise + this.styleMask &= STYLES_MAP[flag]; + } + + #setFg(color) { + this.fgColor = color; + } + + #setBg(color) { + this.bgColor = color; + } +} diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js b/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js new file mode 100644 index 0000000000000000000000000000000000000000..2fbe8275111c890f369914b2bd34fbb315d4e748 --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/lib/generate_stream.js @@ -0,0 +1,61 @@ +import Scanner from './scanner'; + +/** + * Turns a fetch stream into an async iterable. + * + * Could be removed if Chrome implements: + * https://issues.chromium.org/issues/40612900 + */ +async function* getIterableFileStream(path) { + const response = await fetch(path); + const reader = response.body.getReader(); + + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) break; + yield value; + } +} + +/** + * Obtains stream lines as an async iterable + * + * NOTE: This code wrongly assumes each chunk has no cut lines. + * Large logs may contain several chunk and this may effectively + * split some lines in two. + */ +async function* getLogStreamLines(stream) { + const textDecoder = new TextDecoder(); + + for await (const chunk of stream) { + const decodedChunk = textDecoder.decode(chunk); + const lines = decodedChunk.split('\n'); + for (const line of lines) { + if (line.trim() !== '') { + yield { + text: line, + }; + } + } + } +} + +/** + * Fetches a raw log and returns a promise with + * the entire log as an array the can be rendered. + */ +export async function fetchLogLines(path) { + const iterableStream = getIterableFileStream(path); + const lines = getLogStreamLines(iterableStream); + + const res = []; + const scanner = new Scanner(); + + for await (const line of lines) { + const scanned = scanner.scan(line.text); + res.push(scanned); + } + + return res; +} diff --git a/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js b/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js new file mode 100644 index 0000000000000000000000000000000000000000..319a9a9f6b0b7a295002d59ede00ba4c561f1634 --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/lib/scanner.js @@ -0,0 +1,158 @@ +import { AnsiEvaluator } from './ansi_evaluator'; + +const SECTION_PREFIX = 'section_'; +const SECTION_START = 'start'; +const SECTION_END = 'end'; +const SECTION_SEPARATOR = ':'; + +const ANSI_CSI = '\u001b['; +const CR_LINE_ENDING = '\r'; + +const parseSection = (input, offset) => { + const from = offset; + let to = offset; + + for (; to < input.length; to += 1) { + const c = input[to]; + + // if we find CR, indicates ending of section line + if (c === CR_LINE_ENDING) { + to += 1; + break; + } + } + + const section = input + .slice(from, to) + .split(SECTION_SEPARATOR) + .map((s) => s.trim()); + if (section.length === 3) { + return [section, to]; + } + + return [null, to]; +}; + +const parseAnsi = (input, offset) => { + const from = offset; + let to = offset; + + // find any number of parameter bytes (0x30-0x3f) + for (; to < input.length; to += 1) { + const c = input.charCodeAt(to); + if (!(c >= 0x30 && c <= 0x3f)) { + break; + } + } + + // any number of intermediate bytes (0x20–0x2f) + for (; to < input.length; to += 1) { + const c = input.charCodeAt(to); + if (!(c >= 0x20 && c <= 0x2f)) { + break; + } + } + + // single final byte (0x40–0x7e) + const c = input.charCodeAt(to); + if (c >= 0x40 && c <= 0x7e) { + to += 1; + } + + return [input.slice(from, to), to]; +}; + +export default class { + constructor() { + this.ansi = new AnsiEvaluator(); + this.content = []; + this.sections = []; + } + + scan(input) { + let start = 0; + let offset = 0; + + while (offset < input.length) { + if (input.startsWith(ANSI_CSI, offset)) { + this.append(input.slice(start, offset)); + + let op; + [op, offset] = parseAnsi(input, offset + ANSI_CSI.length); + + this.ansi.evaluate(op); + + start = offset; + } else if (input.startsWith(SECTION_PREFIX, offset)) { + this.append(input.slice(start, offset)); + + let section; + [section, offset] = parseSection(input, offset + SECTION_PREFIX.length); + + if (section !== null) { + this.handleSection(section[0], section[1], section[2]); + } + + start = offset; + } else { + offset += 1; + } + } + + this.append(input.slice(start, offset)); + + const { content } = this; + this.content = []; + + const section = this.sections[this.sections.length - 1]; + + if (section?.start) { + section.start = false; + // returns a header line, which can toggle other lines + return { + header: section.name, + sections: this.sections.map(({ name }) => name).slice(0, -1), + content, + }; + } + + return { + sections: this.sections.map(({ name }) => name), + content, + }; + } + + append(text) { + if (text.length === 0) { + return; + } + + this.content.push({ + style: this.ansi.getClasses(), + text, + }); + } + + handleSection(type, time, name) { + switch (type) { + case SECTION_START: { + this.sections.push({ name, time, start: true }); + break; + } + case SECTION_END: { + if (this.sections.length === 0) { + return; + } + + const section = this.sections[this.sections.length - 1]; + if (section.name === name) { + const duration = time - section.time; + this.content.push({ section: name, duration }); + this.sections.pop(); + } + break; + } + default: + } + } +} diff --git a/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue b/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..ded25ebc56320f061944a501dfcbce6e45a74f45 --- /dev/null +++ b/app/assets/javascripts/ci/job_log_viewer/log_viewer_app.vue @@ -0,0 +1,75 @@ +<script> +import { s__ } from '~/locale'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; + +import LogViewer from './components/log_viewer.vue'; +import LogViewerTopBar from './components/log_viewer_top_bar.vue'; + +import { fetchLogLines } from './lib/generate_stream'; + +export default { + name: 'LogViewerApp', + components: { + LogViewerTopBar, + LogViewer, + }, + props: { + rawLogPath: { + required: true, + type: String, + default: null, + }, + }, + data() { + return { + log: [], + loading: false, + }; + }, + async mounted() { + performance.mark('LogViewerApp-showLogStart'); + await this.fetchLog(); + performance.mark('LogViewerApp-showLogEnd'); + + // scroll once log is loaded and rendered + this.scrollToLine(); + }, + methods: { + async fetchLog() { + this.loading = true; + + try { + const log = await fetchLogLines(this.rawLogPath); + Object.freeze(log); // freezing object removes reactivity and lowers memory consumption for large objects + + this.log = log; + } catch (error) { + createAlert({ + message: s__('Job|Something went wrong while loading the log.'), + captureError: true, + error, + }); + } finally { + this.loading = false; + } + }, + scrollToLine() { + const { hash } = window.location; + if (hash) { + try { + scrollToElement(document.querySelector(hash)); + } catch { + // selector provider by user is invalid, pass through + } + } + }, + }, +}; +</script> +<template> + <div class="build-page gl-m-3"> + <log-viewer-top-bar /> + <log-viewer :log="log" :loading="loading" /> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/jobs/viewer/index.js b/app/assets/javascripts/pages/projects/jobs/viewer/index.js new file mode 100644 index 0000000000000000000000000000000000000000..1d3abb27dd4130c79db35daab30d41840b90901c --- /dev/null +++ b/app/assets/javascripts/pages/projects/jobs/viewer/index.js @@ -0,0 +1,3 @@ +import { initJobLogViewer } from '~/ci/job_log_viewer'; + +initJobLogViewer(); diff --git a/app/assets/stylesheets/page_bundles/log_viewer.scss b/app/assets/stylesheets/page_bundles/log_viewer.scss new file mode 100644 index 0000000000000000000000000000000000000000..af3a653f26820f96a54f742146206c95639f9822 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/log_viewer.scss @@ -0,0 +1,63 @@ +@import 'mixins_and_variables_and_functions'; + +.job-log-viewer-top-bar { + @include job-log-top-bar; +} + +.job-log-viewer { + background-color: $builds-log-bg; // stays the same in dark more + color: $white-contrast; // stays the same in dark more + + .log-line { + display: flex; + position: relative; + } + + .log-line-header { + cursor: pointer; + + &:hover { + background-color: rgba($white, 0.2); + } + } + + // line number + .log-line div:first-child { + flex-shrink: 0; + flex-basis: $gl-spacing-scale-9; + text-align: right; + user-select: none; + margin-right: $gl-spacing-scale-3; + margin-left: $gl-spacing-scale-3; + + .gl-icon { + position: absolute; + left: 0; + } + + a { + color: $gray-500; + + &:hover, + &:active, + &:visited { + text-decoration: underline; + color: $gray-500; + } + } + } + + // line text + .log-line div:last-child { + white-space: pre; + text-wrap: wrap; + word-break: break-all; + word-wrap: break-word; + flex-grow: 0; + } + + .loader-animation { + @include build-loader-animation; + } +} + diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 4062e625e0753daf68dc4077be69c62545697a6f..5b40dc3140bf7b506c3e4dcf8924fb8367e93220 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -6,15 +6,15 @@ class Projects::JobsController < Projects::ApplicationController include ContinueParams include ProjectStatsRefreshConflictsGuard - urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw, :test_report_summary] + urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :viewer, :raw, :test_report_summary] before_action :find_job_as_build, except: [:index, :play, :retry, :show] before_action :find_job_as_processable, only: [:play, :retry, :show] - before_action :authorize_read_build_trace!, only: [:trace, :raw] + before_action :authorize_read_build_trace!, only: [:trace, :viewer, :raw] before_action :authorize_read_build!, except: [:test_report_summary] before_action :authorize_read_build_report_results!, only: [:test_report_summary] before_action :authorize_update_build!, - except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary] + except: [:index, :show, :viewer, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary] before_action :authorize_cancel_build!, only: [:cancel] before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] @@ -154,6 +154,8 @@ def raw end end + def viewer; end + def test_report_summary return not_found unless @build.report_results.present? diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index 37b008611e6700ff43c2619d9bb42a5534be1840..3575616f5946a0c83935922d61f562c0d8a1011b 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -13,7 +13,8 @@ def jobs_data(project, build) "deployment_help_url" => help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'troubleshooting'), "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'), "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'), - "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline) + "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline), + "log_viewer_path" => viewer_project_job_path(project, build) } end diff --git a/app/views/projects/jobs/viewer.html.haml b/app/views/projects/jobs/viewer.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a86d5051d1b3187bb6dee25d3e9b3250926725ff --- /dev/null +++ b/app/views/projects/jobs/viewer.html.haml @@ -0,0 +1,13 @@ +- @no_container = true +- add_to_breadcrumbs _("Jobs"), project_jobs_path(@project) +- add_to_breadcrumbs "##{@build.id}", project_job_path(@project, @build) +- breadcrumb_title s_("Job|Full log viewer") +- page_title "#{@build.name} (##{@build.id})", _("Jobs") + +- add_page_specific_style 'page_bundles/xterm' +- add_page_specific_style 'page_bundles/log_viewer' + +%h1.gl-font-size-h-display.gl-m-3 + = @build.name + +#js-job-log-viewer{ data: { raw_log_path: raw_project_job_path(@project, @build) } } diff --git a/config/application.rb b/config/application.rb index 448acc9c400a3ff88a44889e1a4bea2815dab526..93df40f07528dc5359e37fc92136dd487239c9fc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -317,6 +317,7 @@ class Application < Rails::Application config.assets.precompile << "page_bundles/issues_show.css" config.assets.precompile << "page_bundles/jira_connect.css" config.assets.precompile << "page_bundles/learn_gitlab.css" + config.assets.precompile << "page_bundles/log_viewer.css" config.assets.precompile << "page_bundles/login.css" config.assets.precompile << "page_bundles/members.css" config.assets.precompile << "page_bundles/merge_conflicts.css" diff --git a/config/routes/project.rb b/config/routes/project.rb index c4479ff2c6304f75870394652e9ebdd818c09ab6..f509676152d1b5a539c7f6c9a4f5bc5e5d46cf76 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -68,6 +68,7 @@ post :erase get :trace, defaults: { format: 'json' } get :raw + get :viewer get :terminal get :proxy get :test_report_summary diff --git a/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue b/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue index 7f3eb96aef67e6554133d8e88c84f8fff2785b10..0abd0d8b194fab29b5cc1f65f4a5973f67bed9e4 100644 --- a/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue +++ b/ee/app/assets/javascripts/ci/job_details/components/job_log_top_bar.vue @@ -121,6 +121,7 @@ export default { :job-log="jobLog" :full-screen-mode-available="fullScreenModeAvailable" :full-screen-enabled="fullScreenEnabled" + v-bind="$attrs" @scrollJobLogTop="handleScrollTop" @scrollJobLogBottom="handleScrollBottom" @searchResults="handleSearchResults" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index dcd300631ea6c3b673111435229a1caa591dd994..d78eb41529449040eb81b3bf6c5dd519fc7f4e2a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -29283,6 +29283,9 @@ msgstr "" msgid "Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}" msgstr "" +msgid "Job|%{rawLinkStart}View raw%{rawLinkEnd} or %{fullLinkStart}view full log%{fullLinkEnd}." +msgstr "" + msgid "Job|%{searchLength} results found for %{searchTerm}" msgstr "" @@ -29319,9 +29322,15 @@ msgstr "" msgid "Job|Failed" msgstr "" +msgid "Job|Feedback issue" +msgstr "" + msgid "Job|Finished at" msgstr "" +msgid "Job|Full log viewer" +msgstr "" + msgid "Job|Full screen mode is not available" msgstr "" @@ -29406,6 +29415,9 @@ msgstr "" msgid "Job|Skipped" msgstr "" +msgid "Job|Something went wrong while loading the log." +msgstr "" + msgid "Job|Status" msgstr "" diff --git a/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js b/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js index 252af6ef27b8abd218680c47cffc5a39e3ddeed3..40e68e0103d28f815ef55777d2954321e40d7462 100644 --- a/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js +++ b/spec/frontend/ci/job_details/components/job_log_top_bar_spec.js @@ -21,6 +21,7 @@ describe('JobLogTopBar', () => { const defaultProps = { rawPath: '/raw', + logViewerPath: '/viewer', size: 511952, isScrollTopDisabled: false, isScrollBottomDisabled: false, @@ -72,7 +73,21 @@ describe('JobLogTopBar', () => { expect(findShowingLast().text()).toMatch('Showing last 499.95 KiB of log.'); }); - it('renders link', () => { + it('renders links', () => { + expect(findShowingLastLinks()).toHaveLength(2); + expect(findShowingLastLinks().at(0).attributes('href')).toBe('/raw'); + expect(findShowingLastLinks().at(1).attributes('href')).toBe('/viewer'); + }); + }); + + describe('with isJobLogSizeVisible and log viewer is not available', () => { + beforeEach(() => { + createWrapper({ + logViewerPath: null, + }); + }); + + it('renders size information', () => { expect(findShowingLastLinks()).toHaveLength(1); expect(findShowingLastLinks().at(0).attributes('href')).toBe('/raw'); }); diff --git a/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js b/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e52f907f02f6623715037f79fa406946c4b2a039 --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/component/log_viewer_spec.js @@ -0,0 +1,129 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import LogViewer from '~/ci/job_log_viewer/components/log_viewer.vue'; + +describe('LogViewer', () => { + let wrapper; + + const createWrapper = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(LogViewer, { + propsData: { + ...props, + }, + }); + }; + + const findLogLineAt = (i) => wrapper.findAll('.log-line').at(i); + const getShownLines = () => { + return wrapper + .findAll('.log-line') + .wrappers.filter((w) => w.isVisible()) + .map((w) => w.text()); + }; + + it('displays an empty log', () => { + createWrapper(); + + expect(wrapper.attributes()).toMatchObject({ + 'aria-live': 'polite', + role: 'log', + }); + expect(wrapper.text()).toBe(''); + }); + + it('displays a log', () => { + createWrapper({ + props: { + log: [{ content: [{ text: 'line' }] }], + }, + }); + + expect(wrapper.text()).toBe('1 line'); + }); + + it('displays log with style', () => { + createWrapper({ + props: { + log: [ + { + content: [ + { style: ['class-a'], text: 'key:' }, + { style: ['class-b'], text: 'value' }, + ], + }, + ], + }, + }); + + expect(wrapper.text()).toBe('1 key:value'); + + expect(wrapper.find('.class-a').text()).toBe('key:'); + expect(wrapper.find('.class-b').text()).toBe('value'); + }); + + it('displays loading log', () => { + createWrapper({ + props: { + log: [], + loading: true, + }, + }); + + expect(wrapper.attributes('aria-busy')).toBe('true'); + expect(wrapper.text()).toBe('Loading...'); + }); + + describe('when displaying a section', () => { + beforeEach(() => { + createWrapper({ + props: { + log: [ + { + sections: [], + content: [{ text: 'log:' }], + }, + { + sections: [], + header: 'section_1', + content: [{ text: 'header' }], + }, + { + sections: ['section_1'], + header: 'section_1_1', + content: [{ text: 'line 1' }], + }, + { + sections: ['section_1', 'section_1_1'], + content: [{ text: 'line 1.1' }], + }, + { + sections: [], + content: [{ text: 'done!' }], + }, + ], + }, + }); + }); + + it('shows an open section', () => { + expect(findLogLineAt(1).findComponent(GlIcon).props('name')).toBe('chevron-lg-down'); + + expect(getShownLines()).toEqual(['1 log:', '2 header', '3 line 1', '4 line 1.1', '5 done!']); + }); + + it('collapses a section', async () => { + await findLogLineAt(1).trigger('click'); + + expect(findLogLineAt(1).findComponent(GlIcon).props('name')).toBe('chevron-lg-right'); + expect(getShownLines()).toEqual(['1 log:', '2 header', '5 done!']); + }); + + it('collapses a subsection', async () => { + await findLogLineAt(2).trigger('click'); + + expect(findLogLineAt(2).findComponent(GlIcon).props('name')).toBe('chevron-lg-right'); + expect(getShownLines()).toEqual(['1 log:', '2 header', '3 line 1', '5 done!']); + }); + }); +}); diff --git a/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js b/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dd0047099ff45adb0a993466f8cee35fd6998b2d --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/component/log_viewer_top_bar_spec.js @@ -0,0 +1,30 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExperimentBadge from '~/vue_shared/components/badges/experiment_badge.vue'; + +import LogViewerTopBar from '~/ci/job_log_viewer/components/log_viewer_top_bar.vue'; + +describe('LogViewerTopBar', () => { + let wrapper; + + const createWrapper = ({ props = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(LogViewerTopBar, { + propsData: { + ...props, + }, + }); + }; + + const findExperimentBadge = () => wrapper.findComponent(ExperimentBadge); + const findLink = () => wrapper.findComponent(GlLink); + + it('renders help experiment badge with link', () => { + createWrapper(); + + expect(findExperimentBadge().exists()).toBe(true); + + expect(findLink().attributes('href')).toEqual( + 'https://gitlab.com/gitlab-org/gitlab/-/issues/454817', + ); + }); +}); diff --git a/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js b/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c6180de4caa7af84647ab821afbd6b64f6e36dd0 --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/lib/ansi_evaluator_spec.js @@ -0,0 +1,99 @@ +import { AnsiEvaluator } from '~/ci/job_log_viewer/lib/ansi_evaluator'; + +describe('AnsiEvaluator', () => { + let ansi; + + beforeEach(() => { + ansi = new AnsiEvaluator(); + }); + + it('parses empty stack', () => { + ansi.evaluate(''); + + expect(ansi.getClasses()).toEqual([]); + }); + + it('parses no style', () => { + ansi.evaluate('0m'); + + expect(ansi.getClasses()).toEqual([]); + }); + + it('parses bold', () => { + ansi.evaluate('1m'); + + expect(ansi.getClasses()).toEqual(['term-bold']); + }); + + it('parses cyan', () => { + ansi.evaluate('36m'); + + expect(ansi.getClasses()).toEqual(['xterm-fg-14']); + }); + + it('parses cyan + bold', () => { + ansi.evaluate('36;1m'); + + expect(ansi.getClasses()).toEqual(['xterm-fg-14', 'term-bold']); + }); + + it('parses green + bold', () => { + ansi.evaluate('32;1m'); + + expect(ansi.getClasses()).toEqual(['xterm-fg-10', 'term-bold']); + }); + + it('parses "set foreground color"', () => { + ansi.evaluate('38;5;100m'); + + expect(ansi.getClasses()).toEqual(['xterm-fg-100']); + }); + + it('parses "set background color"', () => { + ansi.evaluate('48;5;100m'); + + expect(ansi.getClasses()).toEqual(['xterm-bg-100']); + }); + + it('parses "set foreground color" + "set background color"', () => { + ansi.evaluate('48;5;100;38;5;100m'); + + expect(ansi.getClasses()).toEqual(['xterm-fg-100', 'xterm-bg-100']); + }); + + it('parses non-color styles', () => { + ansi.evaluate('1;3;4;8;9m'); + + expect(ansi.getClasses()).toEqual([ + 'term-bold', + 'term-italic', + 'term-underline', + 'term-conceal', + 'term-cross', + ]); + }); + + it('parses styles that get a reset', () => { + ansi.evaluate('1;3;4;8;9;0m'); + + expect(ansi.getClasses()).toEqual([]); + }); + + it('safely parses non-ansi string', () => { + ansi.evaluate('0K'); + + expect(ansi.getClasses()).toEqual([]); + }); + + it('safely parses unknown command', () => { + ansi.evaluate('1000m'); + + expect(ansi.getClasses()).toEqual([]); + }); + + it('safely parses unknown color keeping previous colors', () => { + ansi.evaluate('48;5;100;48;5;256m'); + + expect(ansi.getClasses()).toEqual(['xterm-bg-100']); + }); +}); diff --git a/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js b/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4984268dad58658be70f9d635d98a446e8a4c81d --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/lib/generate_stream_spec.js @@ -0,0 +1,70 @@ +import { fetchLogLines } from '~/ci/job_log_viewer/lib/generate_stream'; + +const mockFetchResponse = (chunks) => { + const encoder = new TextEncoder(); + const queue = chunks.map((chunk) => encoder.encode(chunk)); + + global.fetch.mockResolvedValue({ + body: { + getReader() { + return new ReadableStream({ + pull(controller) { + if (queue.length) { + controller.enqueue(queue.shift()); + } else { + controller.close(); + } + }, + }).getReader(); + }, + }, + }); +}; + +describe('generate stream', () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + it('uses path to fetch', async () => { + mockFetchResponse([]); + + await fetchLogLines('/jobs/1/raw'); + + expect(global.fetch).toHaveBeenCalledWith('/jobs/1/raw'); + }); + + it('fetches lines in chunk', async () => { + mockFetchResponse(['line 1\nline 2']); + + expect(await fetchLogLines()).toEqual([ + { content: [{ style: [], text: 'line 1' }], sections: [] }, + { content: [{ style: [], text: 'line 2' }], sections: [] }, + ]); + }); + + it('fetches lines in separate chunks', async () => { + mockFetchResponse(['line 1\nline 2\n', 'line 3\nline 4']); + + expect(await fetchLogLines()).toEqual([ + { content: [{ style: [], text: 'line 1' }], sections: [] }, + { content: [{ style: [], text: 'line 2' }], sections: [] }, + { content: [{ style: [], text: 'line 3' }], sections: [] }, + { content: [{ style: [], text: 'line 4' }], sections: [] }, + ]); + }); + + it('decodes using utf-8', async () => { + mockFetchResponse(['ðŸ¤ðŸ¤ðŸ¤ðŸ¤']); + + expect(await fetchLogLines('/raw')).toEqual([ + { content: [{ style: [], text: 'ðŸ¤ðŸ¤ðŸ¤ðŸ¤' }], sections: [] }, + ]); + }); + + it('skips an empty log line', async () => { + mockFetchResponse(['\n']); + + expect(await fetchLogLines('/raw')).toEqual([]); + }); +}); diff --git a/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js b/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..40c0cd4ffea5e6475b36c01a0772079033996373 --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/lib/scanner_spec.js @@ -0,0 +1,97 @@ +import Scanner from '~/ci/job_log_viewer/lib/scanner'; + +describe('Log scanner', () => { + let scanner; + + beforeEach(() => { + scanner = new Scanner(); + }); + + it('scans a line', () => { + expect(scanner.scan('line')).toEqual({ + content: [{ style: [], text: 'line' }], + sections: [], + }); + }); + + it('scans a line with spacing', () => { + expect(scanner.scan(' on runner')).toEqual({ + content: [{ style: [], text: ' on runner' }], + sections: [], + }); + }); + + it('scans a line with a style', () => { + expect(scanner.scan('\u001b[1;41mline 2\u001b[0;m')).toEqual({ + content: [ + { + text: 'line 2', + style: ['xterm-bg-9', 'term-bold'], + }, + ], + sections: [], + }); + }); + + it('scans a line with styles', () => { + expect(scanner.scan('\u001b[32;1mline 1\u001b[0;m')).toEqual({ + content: [ + { + text: 'line 1', + style: ['xterm-fg-10', 'term-bold'], + }, + ], + sections: [], + }); + }); + + it('scans a section with its duration', () => { + const lines = [ + 'section_start:1000:my_section\rheader 1', + 'line 1', + 'line 2', + 'section_end:1010:my_section', + ]; + + expect(lines.map((l) => scanner.scan(l))).toEqual([ + { + content: [{ style: [], text: 'header 1' }], + header: 'my_section', + sections: [], + }, + { content: [{ style: [], text: 'line 1' }], sections: ['my_section'] }, + { content: [{ style: [], text: 'line 2' }], sections: ['my_section'] }, + { content: [{ duration: 10, section: 'my_section' }], sections: [] }, + ]); + }); + + it('scans a sub section with their durations', () => { + const lines = [ + 'section_start:1010:my_section\rheader 1', + 'line 1', + 'section_start:1020:my_sub_section\rheader 2', + 'line 2', + 'section_end:1030:my_sub_section', + 'line 3', + 'section_end:1040:my_section', + ]; + + expect(lines.map((l) => scanner.scan(l))).toEqual([ + { + content: [{ style: [], text: 'header 1' }], + header: 'my_section', + sections: [], + }, + { content: [{ style: [], text: 'line 1' }], sections: ['my_section'] }, + { + content: [{ style: [], text: 'header 2' }], + header: 'my_sub_section', + sections: ['my_section'], + }, + { content: [{ style: [], text: 'line 2' }], sections: ['my_section', 'my_sub_section'] }, + { content: [{ duration: 10, section: 'my_sub_section' }], sections: ['my_section'] }, + { content: [{ style: [], text: 'line 3' }], sections: ['my_section'] }, + { content: [{ duration: 30, section: 'my_section' }], sections: [] }, + ]); + }); +}); diff --git a/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js b/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..61f495e33e4343439ead0a9432416fb0780e8bcf --- /dev/null +++ b/spec/frontend/ci/job_log_viewer/log_viewer_app_spec.js @@ -0,0 +1,91 @@ +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; +import { stubPerformanceWebAPI } from 'helpers/performance'; + +import LogViewerApp from '~/ci/job_log_viewer/log_viewer_app.vue'; + +import LogViewerTopBar from '~/ci/job_log_viewer/components/log_viewer_top_bar.vue'; +import LogViewer from '~/ci/job_log_viewer/components/log_viewer.vue'; +import { fetchLogLines } from '~/ci/job_log_viewer/lib/generate_stream'; + +jest.mock('~/alert'); +jest.mock('~/ci/job_log_viewer/lib/generate_stream'); +jest.mock('~/lib/utils/common_utils'); + +const mockLog = [{ content: [{ text: 'line' }], sections: [] }]; + +describe('LogViewerApp', () => { + let wrapper; + + const createWrapper = ({ mountFn = shallowMountExtended, ...options } = {}) => { + wrapper = mountFn(LogViewerApp, { + propsData: { + rawLogPath: '/job/1/raw', + }, + ...options, + }); + }; + + const findLogViewerTopBar = () => wrapper.findComponent(LogViewerTopBar); + const findLogViewer = () => wrapper.findComponent(LogViewer); + + beforeEach(() => { + stubPerformanceWebAPI(); + + fetchLogLines.mockResolvedValue(mockLog); + }); + + it('renders help popover', () => { + createWrapper(); + + expect(findLogViewerTopBar().exists()).toBe(true); + }); + + it('renders a log', async () => { + createWrapper(); + + await waitForPromises(); + + expect(findLogViewer().props()).toEqual({ loading: false, log: mockLog }); + }); + + it('renders a loading log', async () => { + fetchLogLines.mockReturnValue(new Promise(() => {})); + createWrapper(); + + await waitForPromises(); + + expect(findLogViewer().props()).toEqual({ loading: true, log: [] }); + }); + + it('navigates to bookmarked line', async () => { + setWindowLocation('#L1'); + + createWrapper({ + mountFn: mountExtended, + attachTo: document.body, + }); + + await waitForPromises(); + + expect(scrollToElement).toHaveBeenCalledWith(wrapper.find('#L1').element); + }); + + it('shows alert when log cannot be fetched', async () => { + const error = new Error('Something went wrong'); + fetchLogLines.mockRejectedValue(error); + + createWrapper(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong while loading the log.', + captureError: true, + error, + }); + expect(findLogViewer().props()).toEqual({ loading: false, log: [] }); + }); +});