mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-25 22:23:42 +09:00
1. Give menu items an inset pill highlight, text position and menu width are unchanged in most menus 1. Add arrow key, Enter, Space and Escape handling to all tippy menus 1. Mark the keyboard cursor of fomantic and tippy menus with the focus ring instead of the hover background 1. Mark the current dropdown item with the active color so it stands out from the hovered one 1. Stop navbar dropdown links from taking the navbar item hover color 1. Replace the actions job log options dropdown with the shared tippy menu, its hover highlight was invisible 1. Stop changing font weight on active and selected menu items, it resized rows while arrowing 1. Stretch the "All extensions" button in the diff file extension filter to the full menu width 1. Fix the actions run summary block covering the panel's rounded corners and indenting wrapped stats
808 lines
27 KiB
Vue
808 lines
27 KiB
Vue
<script setup lang="ts">
|
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, useTemplateRef, watch} from 'vue';
|
|
import SvgIcon from './SvgIcon.vue';
|
|
import ActionStatusIcon from './ActionStatusIcon.vue';
|
|
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
|
|
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
|
import {POST} from '../modules/fetch.ts';
|
|
import {createTippy} from '../modules/tippy.ts';
|
|
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
|
import type {Instance} from 'tippy.js';
|
|
import type {IntervalId} from '../types.ts';
|
|
import {toggleFullScreen} from '../utils.ts';
|
|
import {localUserSettings} from '../modules/user-settings.ts';
|
|
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
|
import {AnsiLineRenderer} from '../render/ansi.ts';
|
|
import {
|
|
type ActionRunViewStore,
|
|
createLogLineMessage,
|
|
type LogLine,
|
|
type LogLineCommand,
|
|
parseLogLineCommand,
|
|
} from './ActionRunView.ts';
|
|
|
|
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
|
|
const rect = el.getBoundingClientRect();
|
|
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
|
|
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
|
}
|
|
|
|
export type ActionRunJobViewLocale = {
|
|
status: Record<ActionsStatus, string>,
|
|
showTimeStamps: string,
|
|
showLogSeconds: string,
|
|
showFullScreen: string,
|
|
logsAlwaysAutoScroll: string,
|
|
logsAlwaysExpandRunning: string,
|
|
downloadLogs: string,
|
|
copyOutput: string,
|
|
};
|
|
|
|
type Step = {
|
|
summary: string,
|
|
duration: string,
|
|
status: ActionsStatus,
|
|
}
|
|
|
|
type JobStepState = {
|
|
cursor: string|null,
|
|
expanded: boolean,
|
|
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
|
firstLogTime?: number, // the step's first log line time, what "Show seconds" counts from
|
|
}
|
|
|
|
// one ANSI renderer per step, so an unterminated color carries between that step's lines only
|
|
const stepAnsiRenderers: AnsiLineRenderer[] = [];
|
|
|
|
type StepContainerElement = HTMLElement & {
|
|
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
|
// then the following batches of logs should still use the same group (active logs container).
|
|
// maybe it can be refactored to decouple from the HTML element in the future.
|
|
_stepLogsActiveContainer?: HTMLElement;
|
|
}
|
|
|
|
type LocaleStorageOptions = {
|
|
autoScroll: boolean;
|
|
expandRunning: boolean;
|
|
actionsLogShowSeconds: boolean;
|
|
actionsLogShowTimestamps: boolean;
|
|
};
|
|
|
|
type CurrentJob = {
|
|
title: string;
|
|
detail: string;
|
|
steps: Array<Step>;
|
|
};
|
|
|
|
type JobData = {
|
|
artifacts: Array<ActionsArtifact>;
|
|
state: {
|
|
run: ActionsRun;
|
|
currentJob: CurrentJob;
|
|
},
|
|
logs: {
|
|
stepsLog?: Array<{
|
|
step: number;
|
|
cursor: string | null;
|
|
lines: LogLine[];
|
|
}>;
|
|
},
|
|
};
|
|
|
|
defineOptions({
|
|
name: 'ActionRunJobView',
|
|
});
|
|
|
|
const props = defineProps<{
|
|
store: ActionRunViewStore,
|
|
jobId: number;
|
|
actionsViewUrl: string;
|
|
locale: ActionRunJobViewLocale;
|
|
}>();
|
|
const store = props.store;
|
|
const {currentRun: run} = toRefs(store.viewData);
|
|
|
|
const defaultViewOptions: LocaleStorageOptions = {
|
|
autoScroll: true,
|
|
expandRunning: false,
|
|
actionsLogShowSeconds: false,
|
|
actionsLogShowTimestamps: false,
|
|
};
|
|
|
|
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
|
|
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
|
|
|
|
// internal state
|
|
let loadingAbortController: AbortController | null = null;
|
|
let intervalID: IntervalId | null = null;
|
|
|
|
const currentJobStepsStates = ref<Array<JobStepState>>([]);
|
|
const isFullScreen = ref(false);
|
|
const timeVisible = ref<Record<string, boolean>>({
|
|
'log-time-stamp': actionsLogShowTimestamps,
|
|
'log-time-seconds': actionsLogShowSeconds,
|
|
});
|
|
const optionAlwaysAutoScroll = ref(autoScroll);
|
|
const optionAlwaysExpandRunning = ref(expandRunning);
|
|
const currentJob = ref<CurrentJob>({
|
|
title: '',
|
|
detail: '',
|
|
steps: [] as Array<Step>,
|
|
});
|
|
const stepsContainer = ref<HTMLElement | null>(null);
|
|
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
|
const menuTriggerEl = useTemplateRef<HTMLButtonElement>('menuTriggerEl');
|
|
const menuPanelEl = useTemplateRef<HTMLDivElement>('menuPanelEl');
|
|
let menuTippy: Instance;
|
|
|
|
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
|
|
// status). Callers don't run on a runner, and the dependency graph for their children lives
|
|
// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
|
|
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
|
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
|
|
|
watch(optionAlwaysAutoScroll, () => {
|
|
saveLocaleStorageOptions();
|
|
});
|
|
|
|
watch(optionAlwaysExpandRunning, () => {
|
|
saveLocaleStorageOptions();
|
|
});
|
|
|
|
onMounted(async () => {
|
|
menuTippy = createTippy(menuTriggerEl.value!, {
|
|
content: menuPanelEl.value!,
|
|
trigger: 'click',
|
|
interactive: true,
|
|
hideOnClick: true,
|
|
placement: 'bottom-end',
|
|
theme: 'menu',
|
|
arrow: false,
|
|
});
|
|
|
|
// load job data and then auto-reload periodically
|
|
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
|
|
await loadJob();
|
|
|
|
// auto-scroll to the bottom of the log group when it is opened
|
|
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
|
|
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
|
|
if (!optionAlwaysAutoScroll.value) return;
|
|
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
|
|
setTimeout(() => {
|
|
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
|
|
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
|
|
}
|
|
}, 0);
|
|
});
|
|
|
|
intervalID = setInterval(() => void loadJob(), 1000);
|
|
void hashChangeListener();
|
|
window.addEventListener('hashchange', hashChangeListener);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
menuTippy.destroy();
|
|
window.removeEventListener('hashchange', hashChangeListener);
|
|
// clear the interval timer when the component is unmounted
|
|
// even our page is rendered once, not spa style
|
|
if (intervalID) {
|
|
clearInterval(intervalID);
|
|
intervalID = null;
|
|
}
|
|
});
|
|
|
|
function saveLocaleStorageOptions() {
|
|
const opts: LocaleStorageOptions = {
|
|
autoScroll: optionAlwaysAutoScroll.value,
|
|
expandRunning: optionAlwaysExpandRunning.value,
|
|
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
|
|
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
|
|
};
|
|
localUserSettings.setJsonObject('actions-view-options', opts);
|
|
}
|
|
|
|
// get the job step logs container ('.job-step-logs')
|
|
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
|
|
return jobStepLogs.value[stepIndex] as StepContainerElement;
|
|
}
|
|
|
|
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
|
|
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
|
|
const el = getJobStepLogsContainer(stepIndex);
|
|
return el._stepLogsActiveContainer ?? el;
|
|
}
|
|
|
|
// begin a log group
|
|
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
|
|
const el = getJobStepLogsContainer(stepIndex);
|
|
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
|
|
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
|
|
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
|
|
createLogLine(stepIndex, startTime, line, cmd),
|
|
);
|
|
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
|
|
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
|
|
elJobLogGroupSummary,
|
|
elJobLogList,
|
|
);
|
|
el.append(elJobLogGroup);
|
|
el._stepLogsActiveContainer = elJobLogList;
|
|
}
|
|
|
|
// end a log group
|
|
function endLogGroup(stepIndex: number) {
|
|
const el = getJobStepLogsContainer(stepIndex);
|
|
el._stepLogsActiveContainer = undefined;
|
|
}
|
|
|
|
async function copyStepOutput(event: MouseEvent, stepIndex: number) {
|
|
await copyToClipboardWithFeedback(event.currentTarget as HTMLElement, async () => {
|
|
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
|
|
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
|
|
const lines: string[] = [];
|
|
const ansi = new AnsiLineRenderer();
|
|
for (const line of stepLog?.lines ?? []) {
|
|
const cmd = parseLogLineCommand(line);
|
|
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
|
|
const msg = createLogLineMessage(ansi, line, cmd).textContent ?? '';
|
|
lines.push(timeVisible.value['log-time-stamp'] ? `${formatDatetimeISO(line.timestamp)} ${msg}` : msg);
|
|
}
|
|
return lines.join('\n');
|
|
});
|
|
}
|
|
|
|
// show/hide the step logs for a step
|
|
function toggleStepLogs(idx: number) {
|
|
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
|
|
if (currentJobStepsStates.value[idx].expanded) {
|
|
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
|
|
} else if (currentJob.value.steps[idx].status === 'running') {
|
|
currentJobStepsStates.value[idx].manuallyCollapsed = true;
|
|
}
|
|
}
|
|
|
|
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
|
|
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
|
|
String(line.index),
|
|
);
|
|
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
|
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
|
|
);
|
|
const logMsg = createLogLineMessage(stepAnsiRenderers[stepIndex] ??= new AnsiLineRenderer(), line, cmd);
|
|
const seconds = Math.floor(line.timestamp - startTime);
|
|
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
|
`${seconds}s`, // for "Show seconds"
|
|
);
|
|
|
|
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
|
|
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
|
|
lineNum, logTimeStamp, logMsg, logTimeSeconds,
|
|
);
|
|
}
|
|
|
|
function shouldAutoScroll(stepIndex: number): boolean {
|
|
if (!optionAlwaysAutoScroll.value) return false;
|
|
const el = getJobStepLogsContainer(stepIndex);
|
|
// if the logs container is empty, then auto-scroll if the step is expanded
|
|
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
|
|
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
|
|
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
|
|
}
|
|
|
|
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
|
|
for (const line of logLines) {
|
|
const cmd = parseLogLineCommand(line);
|
|
switch (cmd?.name) {
|
|
case 'hidden':
|
|
continue;
|
|
case 'group':
|
|
beginLogGroup(stepIndex, startTime, line, cmd);
|
|
continue;
|
|
case 'endgroup':
|
|
endLogGroup(stepIndex);
|
|
continue;
|
|
}
|
|
// the active logs container may change during the loop, for example: entering and leaving a group
|
|
const el = getActiveLogsContainer(stepIndex);
|
|
el.append(createLogLine(stepIndex, startTime, line, cmd));
|
|
}
|
|
}
|
|
|
|
// "cursor" is used to indicate the last position of the logs.
|
|
// It's only used by backend, frontend just reads it and passes it back, it can be any type.
|
|
// Frontend knows nothing about its type, never uses its value.
|
|
// For example: backend can make cursor=null means the first time to fetch logs, cursor=1234 for a position, cursor=eof for no more logs, etc.
|
|
type LogCursor = {step: number, cursor: any, expanded: boolean};
|
|
|
|
async function fetchJobData(logCursors: LogCursor[], signal?: AbortSignal): Promise<JobData> {
|
|
const resp = await POST(props.actionsViewUrl, {signal, data: {logCursors}});
|
|
return await resp.json();
|
|
}
|
|
|
|
async function loadJobForce() {
|
|
loadingAbortController?.abort();
|
|
loadingAbortController = null;
|
|
await loadJob();
|
|
}
|
|
|
|
async function loadJob() {
|
|
if (loadingAbortController) return;
|
|
const abortController = new AbortController();
|
|
loadingAbortController = abortController;
|
|
try {
|
|
const logCursors = currentJobStepsStates.value.map((it, idx) => ({step: idx, cursor: it.cursor, expanded: it.expanded}));
|
|
const runJobResp = await fetchJobData(logCursors, abortController.signal);
|
|
if (loadingAbortController !== abortController) return;
|
|
|
|
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
|
|
// Use consistent "store" operations to load/update the view data
|
|
store.viewData.runArtifacts = runJobResp.artifacts || [];
|
|
store.viewData.currentRun = runJobResp.state.run;
|
|
|
|
currentJob.value = runJobResp.state.currentJob;
|
|
const jobLogs = runJobResp.logs.stepsLog ?? [];
|
|
|
|
// sync the currentJobStepsStates to store the job step states
|
|
for (let i = 0; i < currentJob.value.steps.length; i++) {
|
|
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
|
|
if (!currentJobStepsStates.value[i]) {
|
|
// initial states for job steps
|
|
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
|
|
} else {
|
|
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
|
|
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
|
|
currentJobStepsStates.value[i].expanded = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
await nextTick();
|
|
|
|
// find the step indexes that need to auto-scroll
|
|
const autoScrollStepIndexes = new Map<number, boolean>();
|
|
for (const stepLogs of jobLogs) {
|
|
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
|
|
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
|
|
}
|
|
|
|
// append logs to the UI
|
|
for (const stepLogs of jobLogs) {
|
|
const stepState = currentJobStepsStates.value[stepLogs.step];
|
|
// save the cursor, it will be passed to backend next time
|
|
stepState.cursor = stepLogs.cursor;
|
|
if (!stepLogs.lines.length) continue;
|
|
stepState.firstLogTime ??= stepLogs.lines[0].timestamp;
|
|
appendLogs(stepLogs.step, stepState.firstLogTime, stepLogs.lines);
|
|
}
|
|
|
|
// auto-scroll to the last log line of the last step
|
|
let autoScrollJobStepElement: StepContainerElement | undefined;
|
|
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
|
|
if (!autoScrollStepIndexes.get(stepIndex)) continue;
|
|
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
|
|
}
|
|
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
|
|
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
|
|
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
|
|
}
|
|
|
|
// clear the interval timer if the job is done
|
|
if (run.value.done && intervalID) {
|
|
clearInterval(intervalID);
|
|
intervalID = null;
|
|
}
|
|
} catch (e) {
|
|
// avoid network error while unloading page, and ignore "abort" error
|
|
if (e instanceof TypeError || abortController.signal.aborted) return;
|
|
throw e;
|
|
} finally {
|
|
if (loadingAbortController === abortController) loadingAbortController = null;
|
|
}
|
|
}
|
|
|
|
function isDone(status: ActionsStatus) {
|
|
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
|
|
}
|
|
|
|
function isExpandable(status: ActionsStatus) {
|
|
return ['success', 'running', 'failure', 'cancelled'].includes(status);
|
|
}
|
|
|
|
function elStepsContainer(): HTMLElement {
|
|
return stepsContainer.value as HTMLElement;
|
|
}
|
|
|
|
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
|
|
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
|
|
saveLocaleStorageOptions();
|
|
}
|
|
|
|
function toggleFullScreenMode() {
|
|
isFullScreen.value = !isFullScreen.value;
|
|
toggleFullScreen(document.querySelector('.action-view-right')!, isFullScreen.value, '.action-view-body');
|
|
}
|
|
|
|
async function hashChangeListener() {
|
|
const selectedLogStep = window.location.hash;
|
|
if (!selectedLogStep) return;
|
|
const [_, step, _line] = selectedLogStep.split('-');
|
|
const stepNum = Number(step);
|
|
if (!currentJobStepsStates.value[stepNum]) return;
|
|
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
|
|
currentJobStepsStates.value[stepNum].expanded = true;
|
|
// need to await for load job if the step log is loaded for the first time
|
|
// so logline can be selected by querySelector
|
|
await loadJob();
|
|
}
|
|
await nextTick();
|
|
const logLine = elStepsContainer().querySelector(selectedLogStep);
|
|
if (!logLine) return;
|
|
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
|
|
}
|
|
</script>
|
|
<template>
|
|
<div class="job-info-header">
|
|
<div class="job-info-header-left gt-ellipsis">
|
|
<div class="job-info-header-title-row">
|
|
<h3 class="job-info-header-title gt-ellipsis">
|
|
{{ isCallerJob ? selectedJob?.name : currentJob.title }}
|
|
</h3>
|
|
<span v-if="isCallerJob && selectedJob?.callUses" class="ui label job-info-header-uses">
|
|
<span>uses:</span>
|
|
<span class="gt-ellipsis">{{ selectedJob.callUses }}</span>
|
|
</span>
|
|
</div>
|
|
<p class="job-info-header-detail">
|
|
{{ isCallerJob && selectedJob ? locale.status[selectedJob.status] : currentJob.detail }}
|
|
</p>
|
|
</div>
|
|
<div class="job-info-header-right">
|
|
<button ref="menuTriggerEl" type="button" class="btn interact-bg tw-p-2">
|
|
<SvgIcon name="octicon-gear" :size="18"/>
|
|
</button>
|
|
<div ref="menuPanelEl" class="tippy-target" @click="menuTippy.hide()">
|
|
<a class="item" role="menuitemcheckbox" :aria-checked="timeVisible['log-time-seconds']" @click="toggleTimeDisplay('seconds')">
|
|
<SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/>
|
|
{{ locale.showLogSeconds }}
|
|
</a>
|
|
<a class="item" role="menuitemcheckbox" :aria-checked="timeVisible['log-time-stamp']" @click="toggleTimeDisplay('stamp')">
|
|
<SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/>
|
|
{{ locale.showTimeStamps }}
|
|
</a>
|
|
<a class="item" role="menuitemcheckbox" :aria-checked="isFullScreen" @click="toggleFullScreenMode()">
|
|
<SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/>
|
|
{{ locale.showFullScreen }}
|
|
</a>
|
|
<div class="divider"/>
|
|
<a class="item" role="menuitemcheckbox" :aria-checked="optionAlwaysAutoScroll" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
|
|
<SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/>
|
|
{{ locale.logsAlwaysAutoScroll }}
|
|
</a>
|
|
<a class="item" role="menuitemcheckbox" :aria-checked="optionAlwaysExpandRunning" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
|
|
<SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/>
|
|
{{ locale.logsAlwaysExpandRunning }}
|
|
</a>
|
|
<div class="divider"/>
|
|
<a class="item" role="menuitem" :class="{disabled: !currentJob.steps.length}" :href="run.link + '/jobs/' + jobId + '/logs'" download>
|
|
<SvgIcon name="octicon-download"/>
|
|
{{ locale.downloadLogs }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
|
<div
|
|
class="job-step-container"
|
|
ref="stepsContainer"
|
|
v-show="!isCallerJob && currentJob.steps.length"
|
|
:class="{
|
|
'log-line-show-timestamps': timeVisible['log-time-stamp'],
|
|
'log-line-show-seconds': timeVisible['log-time-seconds']
|
|
}"
|
|
>
|
|
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
|
<div
|
|
class="job-step-summary"
|
|
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
|
|
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
|
|
>
|
|
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
|
|
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
|
|
-->
|
|
<SvgIcon
|
|
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
|
|
name="gitea-running"
|
|
class="rotate-clockwise"
|
|
/>
|
|
<SvgIcon
|
|
v-else
|
|
name="octicon-chevron-right"
|
|
class="step-summary-chevron"
|
|
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
|
|
/>
|
|
<ActionStatusIcon :status="jobStep.status" icon-variant="circle-fill"/>
|
|
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
|
|
<button
|
|
v-if="isExpandable(jobStep.status)"
|
|
class="btn interact-fg step-copy-btn"
|
|
:aria-label="locale.copyOutput"
|
|
:data-tooltip-content="locale.copyOutput"
|
|
@click.stop="copyStepOutput($event, stepIdx)"
|
|
>
|
|
<SvgIcon name="octicon-copy" :size="14"/>
|
|
</button>
|
|
<span class="step-summary-duration">{{ jobStep.duration }}</span>
|
|
</div>
|
|
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
|
|
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
|
|
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<style scoped>
|
|
.job-info-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
position: sticky;
|
|
top: 0;
|
|
height: 60px;
|
|
z-index: 1; /* above .job-step-container */
|
|
background: var(--color-console-bg);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.job-info-header:has(+ .job-step-container) {
|
|
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
|
border-bottom: 1px solid var(--color-secondary);
|
|
}
|
|
|
|
.job-info-header .job-info-header-title {
|
|
color: var(--color-console-fg);
|
|
font-size: 16px;
|
|
margin: 0;
|
|
}
|
|
|
|
.job-info-header .job-info-header-detail {
|
|
color: var(--color-console-fg-subtle);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.job-info-header-left {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.job-info-header-title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.job-info-header-uses {
|
|
display: inline-flex !important;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.job-step-container {
|
|
max-height: 100%;
|
|
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
|
}
|
|
|
|
.job-step-container .job-step-summary {
|
|
padding: 5px 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-radius: var(--border-radius);
|
|
}
|
|
|
|
.job-step-container .job-step-summary.step-expandable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.job-step-container .job-step-summary.step-expandable:hover {
|
|
color: var(--color-console-fg);
|
|
background: var(--color-console-hover-bg);
|
|
}
|
|
|
|
.job-step-container .job-step-summary .step-summary-chevron {
|
|
transition: transform 0.1s ease;
|
|
}
|
|
|
|
.job-step-container .job-step-summary.selected .step-summary-chevron {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.job-step-container .job-step-summary .step-summary-msg {
|
|
flex: 1;
|
|
}
|
|
|
|
.job-step-container .job-step-summary .step-copy-btn {
|
|
visibility: hidden;
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.job-step-container .job-step-summary:hover .step-copy-btn,
|
|
.job-step-container .job-step-summary.selected .step-copy-btn {
|
|
visibility: visible;
|
|
}
|
|
|
|
@media (hover: none) {
|
|
.job-step-container .job-step-summary:focus-within .step-copy-btn {
|
|
visibility: visible;
|
|
}
|
|
}
|
|
|
|
.job-step-container .job-step-summary.selected {
|
|
color: var(--color-console-fg);
|
|
background-color: var(--color-console-active-bg);
|
|
position: sticky;
|
|
top: 60px;
|
|
}
|
|
</style>
|
|
|
|
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
|
|
/* some elements are not managed by vue, so we need to use global style */
|
|
.job-step-section {
|
|
margin: 10px;
|
|
}
|
|
|
|
.job-step-section .job-step-logs {
|
|
font-family: var(--fonts-monospace);
|
|
margin: 8px 0;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.job-step-section .job-step-logs .job-log-line {
|
|
display: flex;
|
|
}
|
|
|
|
.job-log-line:hover,
|
|
.job-log-line:target {
|
|
background-color: var(--color-console-hover-bg);
|
|
}
|
|
|
|
.job-log-line:target {
|
|
scroll-margin-top: 95px;
|
|
}
|
|
|
|
.job-log-line .log-time-stamp,
|
|
.job-log-line .log-time-seconds {
|
|
display: none;
|
|
}
|
|
|
|
.log-line-show-timestamps .job-log-line .log-time-stamp {
|
|
display: inline;
|
|
}
|
|
|
|
.log-line-show-seconds .job-log-line .log-time-seconds {
|
|
display: inline;
|
|
}
|
|
|
|
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
|
|
.job-log-line .line-num,
|
|
.job-log-line .log-time-seconds {
|
|
width: 48px;
|
|
color: var(--color-text-light-3);
|
|
text-align: right;
|
|
user-select: none;
|
|
}
|
|
|
|
.job-log-line:target > .line-num {
|
|
color: var(--color-primary);
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.log-time-seconds {
|
|
padding-right: 2px;
|
|
}
|
|
|
|
.job-log-line .log-time,
|
|
.job-log-line .log-time-stamp {
|
|
color: var(--color-text-light-3);
|
|
margin-left: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.job-step-logs .job-log-line .log-msg {
|
|
flex: 1;
|
|
white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
|
|
margin-left: 12px;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.job-step-logs .log-msg a {
|
|
color: var(--color-console-link) !important;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.job-step-logs .job-log-line .log-cmd-command {
|
|
color: var(--color-ansi-blue);
|
|
}
|
|
|
|
.job-step-logs .log-msg-label {
|
|
font-weight: var(--font-weight-semibold);
|
|
}
|
|
|
|
.job-step-logs .log-line-error {
|
|
background: var(--color-error-bg);
|
|
}
|
|
|
|
.job-step-logs .log-line-warning {
|
|
background: var(--color-warning-bg);
|
|
}
|
|
|
|
.job-step-logs .log-line-notice {
|
|
background: var(--color-info-bg);
|
|
}
|
|
|
|
.job-step-logs .log-line-debug {
|
|
background: var(--color-secondary-alpha-30);
|
|
}
|
|
|
|
.job-step-logs .log-cmd-error > .log-msg-label {
|
|
color: var(--color-error-text);
|
|
}
|
|
|
|
.job-step-logs .log-cmd-warning > .log-msg-label {
|
|
color: var(--color-warning-text);
|
|
}
|
|
|
|
.job-step-logs .log-cmd-notice > .log-msg-label {
|
|
color: var(--color-info-text);
|
|
}
|
|
|
|
.job-step-logs .log-cmd-debug > .log-msg-label {
|
|
color: var(--color-violet);
|
|
}
|
|
|
|
/* selectors here are intentionally exact to only match fullscreen */
|
|
|
|
.full.height > .action-view-right {
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.full.height > .action-view-right > .job-info-header {
|
|
border-radius: 0;
|
|
}
|
|
|
|
.full.height > .action-view-right > .job-step-container {
|
|
height: calc(100% - 60px);
|
|
border-radius: 0;
|
|
}
|
|
|
|
.job-log-group-summary {
|
|
cursor: pointer;
|
|
list-style: none; /* hide the standard disclosure marker (Chrome, Edge, Firefox) */
|
|
}
|
|
|
|
.job-log-group-summary::-webkit-details-marker { /* hide the disclosure marker on Safari */
|
|
display: none;
|
|
}
|
|
|
|
.log-line-group .log-msg::before {
|
|
content: "";
|
|
display: inline-block;
|
|
vertical-align: middle;
|
|
margin-top: -2.5px;
|
|
margin-right: 8px;
|
|
border-top: 4px solid transparent;
|
|
border-bottom: 4px solid transparent;
|
|
border-left: 6px solid var(--color-text-light-3);
|
|
transition: transform 0.1s ease;
|
|
}
|
|
|
|
.job-log-group[open] .log-line-group .log-msg::before {
|
|
transform: rotate(90deg);
|
|
}
|
|
</style>
|