code.gitea.io/gitea@v1.22.3/web_src/js/components/RepoActionView.vue (about)

     1  <script>
     2  import {SvgIcon} from '../svg.js';
     3  import ActionRunStatus from './ActionRunStatus.vue';
     4  import {createApp} from 'vue';
     5  import {toggleElem} from '../utils/dom.js';
     6  import {formatDatetime} from '../utils/time.js';
     7  import {renderAnsi} from '../render/ansi.js';
     8  import {GET, POST, DELETE} from '../modules/fetch.js';
     9  
    10  const sfc = {
    11    name: 'RepoActionView',
    12    components: {
    13      SvgIcon,
    14      ActionRunStatus,
    15    },
    16    props: {
    17      runIndex: String,
    18      jobIndex: String,
    19      actionsURL: String,
    20      locale: Object,
    21    },
    22  
    23    data() {
    24      return {
    25        // internal state
    26        loading: false,
    27        intervalID: null,
    28        currentJobStepsStates: [],
    29        artifacts: [],
    30        onHoverRerunIndex: -1,
    31        menuVisible: false,
    32        isFullScreen: false,
    33        timeVisible: {
    34          'log-time-stamp': false,
    35          'log-time-seconds': false,
    36        },
    37  
    38        // provided by backend
    39        run: {
    40          link: '',
    41          title: '',
    42          status: '',
    43          canCancel: false,
    44          canApprove: false,
    45          canRerun: false,
    46          done: false,
    47          workflowID: '',
    48          workflowLink: '',
    49          isSchedule: false,
    50          jobs: [
    51            // {
    52            //   id: 0,
    53            //   name: '',
    54            //   status: '',
    55            //   canRerun: false,
    56            //   duration: '',
    57            // },
    58          ],
    59          commit: {
    60            localeCommit: '',
    61            localePushedBy: '',
    62            shortSHA: '',
    63            link: '',
    64            pusher: {
    65              displayName: '',
    66              link: '',
    67            },
    68            branch: {
    69              name: '',
    70              link: '',
    71            },
    72          },
    73        },
    74        currentJob: {
    75          title: '',
    76          detail: '',
    77          steps: [
    78            // {
    79            //   summary: '',
    80            //   duration: '',
    81            //   status: '',
    82            // }
    83          ],
    84        },
    85      };
    86    },
    87  
    88    async mounted() {
    89      // load job data and then auto-reload periodically
    90      // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
    91      await this.loadJob();
    92      this.intervalID = setInterval(this.loadJob, 1000);
    93      document.body.addEventListener('click', this.closeDropdown);
    94      this.hashChangeListener();
    95      window.addEventListener('hashchange', this.hashChangeListener);
    96    },
    97  
    98    beforeUnmount() {
    99      document.body.removeEventListener('click', this.closeDropdown);
   100      window.removeEventListener('hashchange', this.hashChangeListener);
   101    },
   102  
   103    unmounted() {
   104      // clear the interval timer when the component is unmounted
   105      // even our page is rendered once, not spa style
   106      if (this.intervalID) {
   107        clearInterval(this.intervalID);
   108        this.intervalID = null;
   109      }
   110    },
   111  
   112    methods: {
   113      // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
   114      getLogsContainer(idx) {
   115        const el = this.$refs.logs[idx];
   116        return el._stepLogsActiveContainer ?? el;
   117      },
   118      // begin a log group
   119      beginLogGroup(idx) {
   120        const el = this.$refs.logs[idx];
   121  
   122        const elJobLogGroup = document.createElement('div');
   123        elJobLogGroup.classList.add('job-log-group');
   124  
   125        const elJobLogGroupSummary = document.createElement('div');
   126        elJobLogGroupSummary.classList.add('job-log-group-summary');
   127  
   128        const elJobLogList = document.createElement('div');
   129        elJobLogList.classList.add('job-log-list');
   130  
   131        elJobLogGroup.append(elJobLogGroupSummary);
   132        elJobLogGroup.append(elJobLogList);
   133        el._stepLogsActiveContainer = elJobLogList;
   134      },
   135      // end a log group
   136      endLogGroup(idx) {
   137        const el = this.$refs.logs[idx];
   138        el._stepLogsActiveContainer = null;
   139      },
   140  
   141      // show/hide the step logs for a step
   142      toggleStepLogs(idx) {
   143        this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
   144        if (this.currentJobStepsStates[idx].expanded) {
   145          this.loadJob(); // try to load the data immediately instead of waiting for next timer interval
   146        }
   147      },
   148      // cancel a run
   149      cancelRun() {
   150        POST(`${this.run.link}/cancel`);
   151      },
   152      // approve a run
   153      approveRun() {
   154        POST(`${this.run.link}/approve`);
   155      },
   156  
   157      createLogLine(line, startTime, stepIndex) {
   158        const div = document.createElement('div');
   159        div.classList.add('job-log-line');
   160        div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);
   161        div._jobLogTime = line.timestamp;
   162  
   163        const lineNumber = document.createElement('a');
   164        lineNumber.classList.add('line-num', 'muted');
   165        lineNumber.textContent = line.index;
   166        lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);
   167        div.append(lineNumber);
   168  
   169        // for "Show timestamps"
   170        const logTimeStamp = document.createElement('span');
   171        logTimeStamp.className = 'log-time-stamp';
   172        const date = new Date(parseFloat(line.timestamp * 1000));
   173        const timeStamp = formatDatetime(date);
   174        logTimeStamp.textContent = timeStamp;
   175        toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
   176        // for "Show seconds"
   177        const logTimeSeconds = document.createElement('span');
   178        logTimeSeconds.className = 'log-time-seconds';
   179        const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
   180        logTimeSeconds.textContent = `${seconds}s`;
   181        toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
   182  
   183        const logMessage = document.createElement('span');
   184        logMessage.className = 'log-msg';
   185        logMessage.innerHTML = renderAnsi(line.message);
   186        div.append(logTimeStamp);
   187        div.append(logMessage);
   188        div.append(logTimeSeconds);
   189  
   190        return div;
   191      },
   192  
   193      appendLogs(stepIndex, logLines, startTime) {
   194        for (const line of logLines) {
   195          // TODO: group support: ##[group]GroupTitle , ##[endgroup]
   196          const el = this.getLogsContainer(stepIndex);
   197          el.append(this.createLogLine(line, startTime, stepIndex));
   198        }
   199      },
   200  
   201      async fetchArtifacts() {
   202        const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
   203        return await resp.json();
   204      },
   205  
   206      async deleteArtifact(name) {
   207        if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
   208        await DELETE(`${this.run.link}/artifacts/${name}`);
   209        await this.loadJob();
   210      },
   211  
   212      async fetchJob() {
   213        const logCursors = this.currentJobStepsStates.map((it, idx) => {
   214          // cursor is used to indicate the last position of the logs
   215          // it's only used by backend, frontend just reads it and passes it back, it and can be any type.
   216          // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
   217          return {step: idx, cursor: it.cursor, expanded: it.expanded};
   218        });
   219        const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
   220          data: {logCursors},
   221        });
   222        return await resp.json();
   223      },
   224  
   225      async loadJob() {
   226        if (this.loading) return;
   227        try {
   228          this.loading = true;
   229  
   230          let job, artifacts;
   231          try {
   232            [job, artifacts] = await Promise.all([
   233              this.fetchJob(),
   234              this.fetchArtifacts(), // refresh artifacts if upload-artifact step done
   235            ]);
   236          } catch (err) {
   237            if (err instanceof TypeError) return; // avoid network error while unloading page
   238            throw err;
   239          }
   240  
   241          this.artifacts = artifacts['artifacts'] || [];
   242  
   243          // save the state to Vue data, then the UI will be updated
   244          this.run = job.state.run;
   245          this.currentJob = job.state.currentJob;
   246  
   247          // sync the currentJobStepsStates to store the job step states
   248          for (let i = 0; i < this.currentJob.steps.length; i++) {
   249            if (!this.currentJobStepsStates[i]) {
   250              // initial states for job steps
   251              this.currentJobStepsStates[i] = {cursor: null, expanded: false};
   252            }
   253          }
   254          // append logs to the UI
   255          for (const logs of job.logs.stepsLog) {
   256            // save the cursor, it will be passed to backend next time
   257            this.currentJobStepsStates[logs.step].cursor = logs.cursor;
   258            this.appendLogs(logs.step, logs.lines, logs.started);
   259          }
   260  
   261          if (this.run.done && this.intervalID) {
   262            clearInterval(this.intervalID);
   263            this.intervalID = null;
   264          }
   265        } finally {
   266          this.loading = false;
   267        }
   268      },
   269  
   270      isDone(status) {
   271        return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
   272      },
   273  
   274      isExpandable(status) {
   275        return ['success', 'running', 'failure', 'cancelled'].includes(status);
   276      },
   277  
   278      closeDropdown() {
   279        if (this.menuVisible) this.menuVisible = false;
   280      },
   281  
   282      toggleTimeDisplay(type) {
   283        this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
   284        for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
   285          toggleElem(el, this.timeVisible[`log-time-${type}`]);
   286        }
   287      },
   288  
   289      toggleFullScreen() {
   290        this.isFullScreen = !this.isFullScreen;
   291        const fullScreenEl = document.querySelector('.action-view-right');
   292        const outerEl = document.querySelector('.full.height');
   293        const actionBodyEl = document.querySelector('.action-view-body');
   294        const headerEl = document.querySelector('#navbar');
   295        const contentEl = document.querySelector('.page-content.repository');
   296        const footerEl = document.querySelector('.page-footer');
   297        toggleElem(headerEl, !this.isFullScreen);
   298        toggleElem(contentEl, !this.isFullScreen);
   299        toggleElem(footerEl, !this.isFullScreen);
   300        // move .action-view-right to new parent
   301        if (this.isFullScreen) {
   302          outerEl.append(fullScreenEl);
   303        } else {
   304          actionBodyEl.append(fullScreenEl);
   305        }
   306      },
   307      async hashChangeListener() {
   308        const selectedLogStep = window.location.hash;
   309        if (!selectedLogStep) return;
   310        const [_, step, _line] = selectedLogStep.split('-');
   311        if (!this.currentJobStepsStates[step]) return;
   312        if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) {
   313          this.currentJobStepsStates[step].expanded = true;
   314          // need to await for load job if the step log is loaded for the first time
   315          // so logline can be selected by querySelector
   316          await this.loadJob();
   317        }
   318        const logLine = this.$refs.steps.querySelector(selectedLogStep);
   319        if (!logLine) return;
   320        logLine.querySelector('.line-num').click();
   321      },
   322    },
   323  };
   324  
   325  export default sfc;
   326  
   327  export function initRepositoryActionView() {
   328    const el = document.getElementById('repo-action-view');
   329    if (!el) return;
   330  
   331    // TODO: the parent element's full height doesn't work well now,
   332    // but we can not pollute the global style at the moment, only fix the height problem for pages with this component
   333    const parentFullHeight = document.querySelector('body > div.full.height');
   334    if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
   335  
   336    const view = createApp(sfc, {
   337      runIndex: el.getAttribute('data-run-index'),
   338      jobIndex: el.getAttribute('data-job-index'),
   339      actionsURL: el.getAttribute('data-actions-url'),
   340      locale: {
   341        approve: el.getAttribute('data-locale-approve'),
   342        cancel: el.getAttribute('data-locale-cancel'),
   343        rerun: el.getAttribute('data-locale-rerun'),
   344        rerun_all: el.getAttribute('data-locale-rerun-all'),
   345        scheduled: el.getAttribute('data-locale-runs-scheduled'),
   346        commit: el.getAttribute('data-locale-runs-commit'),
   347        pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
   348        artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
   349        areYouSure: el.getAttribute('data-locale-are-you-sure'),
   350        confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
   351        showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
   352        showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
   353        showFullScreen: el.getAttribute('data-locale-show-full-screen'),
   354        downloadLogs: el.getAttribute('data-locale-download-logs'),
   355        status: {
   356          unknown: el.getAttribute('data-locale-status-unknown'),
   357          waiting: el.getAttribute('data-locale-status-waiting'),
   358          running: el.getAttribute('data-locale-status-running'),
   359          success: el.getAttribute('data-locale-status-success'),
   360          failure: el.getAttribute('data-locale-status-failure'),
   361          cancelled: el.getAttribute('data-locale-status-cancelled'),
   362          skipped: el.getAttribute('data-locale-status-skipped'),
   363          blocked: el.getAttribute('data-locale-status-blocked'),
   364        },
   365      },
   366    });
   367    view.mount(el);
   368  }
   369  </script>
   370  <template>
   371    <div class="ui container action-view-container">
   372      <div class="action-view-header">
   373        <div class="action-info-summary">
   374          <div class="action-info-summary-title">
   375            <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
   376            <h2 class="action-info-summary-title-text">
   377              {{ run.title }}
   378            </h2>
   379          </div>
   380          <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
   381            {{ locale.approve }}
   382          </button>
   383          <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
   384            {{ locale.cancel }}
   385          </button>
   386          <button class="ui basic small compact button tw-mr-0 tw-whitespace-nowrap link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
   387            {{ locale.rerun_all }}
   388          </button>
   389        </div>
   390        <div class="action-commit-summary">
   391          <span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
   392          <template v-if="run.isSchedule">
   393            {{ locale.scheduled }}
   394          </template>
   395          <template v-else>
   396            {{ locale.commit }}
   397            <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
   398            {{ locale.pushedBy }}
   399            <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
   400          </template>
   401          <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
   402            <a class="gt-ellipsis" :href="run.commit.branch.link">{{ run.commit.branch.name }}</a>
   403          </span>
   404        </div>
   405      </div>
   406      <div class="action-view-body">
   407        <div class="action-view-left">
   408          <div class="job-group-section">
   409            <div class="job-brief-list">
   410              <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1">
   411                <div class="job-brief-item-left">
   412                  <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
   413                  <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
   414                </div>
   415                <span class="job-brief-item-right">
   416                  <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/>
   417                  <span class="step-summary-duration">{{ job.duration }}</span>
   418                </span>
   419              </a>
   420            </div>
   421          </div>
   422          <div class="job-artifacts" v-if="artifacts.length > 0">
   423            <div class="job-artifacts-title">
   424              {{ locale.artifactsTitle }}
   425            </div>
   426            <ul class="job-artifacts-list">
   427              <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">
   428                <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
   429                  <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
   430                </a>
   431                <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">
   432                  <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>
   433                </a>
   434              </li>
   435            </ul>
   436          </div>
   437        </div>
   438  
   439        <div class="action-view-right">
   440          <div class="job-info-header">
   441            <div class="job-info-header-left gt-ellipsis">
   442              <h3 class="job-info-header-title gt-ellipsis">
   443                {{ currentJob.title }}
   444              </h3>
   445              <p class="job-info-header-detail">
   446                {{ currentJob.detail }}
   447              </p>
   448            </div>
   449            <div class="job-info-header-right">
   450              <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
   451                <button class="btn gt-interact-bg tw-p-2">
   452                  <SvgIcon name="octicon-gear" :size="18"/>
   453                </button>
   454                <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
   455                  <a class="item" @click="toggleTimeDisplay('seconds')">
   456                    <i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
   457                    {{ locale.showLogSeconds }}
   458                  </a>
   459                  <a class="item" @click="toggleTimeDisplay('stamp')">
   460                    <i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
   461                    {{ locale.showTimeStamps }}
   462                  </a>
   463                  <a class="item" @click="toggleFullScreen()">
   464                    <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
   465                    {{ locale.showFullScreen }}
   466                  </a>
   467                  <div class="divider"/>
   468                  <a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
   469                    <i class="icon"><SvgIcon name="octicon-download"/></i>
   470                    {{ locale.downloadLogs }}
   471                  </a>
   472                </div>
   473              </div>
   474            </div>
   475          </div>
   476          <div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
   477            <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
   478              <div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
   479                <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
   480                  currentJobStepsStates[i].cursor === null means the log is loaded for the first time
   481                -->
   482                <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
   483                <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
   484                <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
   485  
   486                <span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
   487                <span class="step-summary-duration">{{ jobStep.duration }}</span>
   488              </div>
   489  
   490              <!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
   491              use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
   492              <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
   493            </div>
   494          </div>
   495        </div>
   496      </div>
   497    </div>
   498  </template>
   499  <style scoped>
   500  .action-view-body {
   501    padding-top: 12px;
   502    padding-bottom: 12px;
   503    display: flex;
   504    gap: 12px;
   505  }
   506  
   507  /* ================ */
   508  /* action view header */
   509  
   510  .action-view-header {
   511    margin-top: 8px;
   512  }
   513  
   514  .action-info-summary {
   515    display: flex;
   516    align-items: center;
   517    justify-content: space-between;
   518    gap: 8px;
   519  }
   520  
   521  .action-info-summary-title {
   522    display: flex;
   523  }
   524  
   525  .action-info-summary-title-text {
   526    font-size: 20px;
   527    margin: 0 0 0 8px;
   528    flex: 1;
   529    overflow-wrap: anywhere;
   530  }
   531  
   532  .action-commit-summary {
   533    display: flex;
   534    flex-wrap: wrap;
   535    gap: 5px;
   536    margin-left: 28px;
   537  }
   538  
   539  @media (max-width: 767.98px) {
   540    .action-commit-summary {
   541      margin-left: 0;
   542      margin-top: 8px;
   543    }
   544  }
   545  
   546  /* ================ */
   547  /* action view left */
   548  
   549  .action-view-left {
   550    width: 30%;
   551    max-width: 400px;
   552    position: sticky;
   553    top: 12px;
   554    max-height: 100vh;
   555    overflow-y: auto;
   556    background: var(--color-body);
   557    z-index: 2; /* above .job-info-header */
   558  }
   559  
   560  @media (max-width: 767.98px) {
   561    .action-view-left {
   562      position: static; /* can not sticky because multiple jobs would overlap into right view */
   563    }
   564  }
   565  
   566  .job-artifacts-title {
   567    font-size: 18px;
   568    margin-top: 16px;
   569    padding: 16px 10px 0 20px;
   570    border-top: 1px solid var(--color-secondary);
   571  }
   572  
   573  .job-artifacts-item {
   574    margin: 5px 0;
   575    padding: 6px;
   576    display: flex;
   577    justify-content: space-between;
   578  }
   579  
   580  .job-artifacts-list {
   581    padding-left: 12px;
   582    list-style: none;
   583  }
   584  
   585  .job-artifacts-icon {
   586    padding-right: 3px;
   587  }
   588  
   589  .job-brief-list {
   590    display: flex;
   591    flex-direction: column;
   592    gap: 8px;
   593  }
   594  
   595  .job-brief-item {
   596    padding: 10px;
   597    border-radius: var(--border-radius);
   598    text-decoration: none;
   599    display: flex;
   600    flex-wrap: nowrap;
   601    justify-content: space-between;
   602    align-items: center;
   603    color: var(--color-text);
   604  }
   605  
   606  .job-brief-item:hover {
   607    background-color: var(--color-hover);
   608  }
   609  
   610  .job-brief-item.selected {
   611    font-weight: var(--font-weight-bold);
   612    background-color: var(--color-active);
   613  }
   614  
   615  .job-brief-item:first-of-type {
   616    margin-top: 0;
   617  }
   618  
   619  .job-brief-item .job-brief-rerun {
   620    cursor: pointer;
   621    transition: transform 0.2s;
   622  }
   623  
   624  .job-brief-item .job-brief-rerun:hover {
   625    transform: scale(130%);
   626  }
   627  
   628  .job-brief-item .job-brief-item-left {
   629    display: flex;
   630    width: 100%;
   631    min-width: 0;
   632  }
   633  
   634  .job-brief-item .job-brief-item-left span {
   635    display: flex;
   636    align-items: center;
   637  }
   638  
   639  .job-brief-item .job-brief-item-left .job-brief-name {
   640    display: block;
   641    width: 70%;
   642  }
   643  
   644  .job-brief-item .job-brief-item-right {
   645    display: flex;
   646    align-items: center;
   647  }
   648  
   649  /* ================ */
   650  /* action view right */
   651  
   652  .action-view-right {
   653    flex: 1;
   654    color: var(--color-console-fg-subtle);
   655    max-height: 100%;
   656    width: 70%;
   657    display: flex;
   658    flex-direction: column;
   659    border: 1px solid var(--color-console-border);
   660    border-radius: var(--border-radius);
   661    background: var(--color-console-bg);
   662    align-self: flex-start;
   663  }
   664  
   665  /* begin fomantic button overrides */
   666  
   667  .action-view-right .ui.button,
   668  .action-view-right .ui.button:focus {
   669    background: transparent;
   670    color: var(--color-console-fg-subtle);
   671  }
   672  
   673  .action-view-right .ui.button:hover {
   674    background: var(--color-console-hover-bg);
   675    color: var(--color-console-fg);
   676  }
   677  
   678  .action-view-right .ui.button:active {
   679    background: var(--color-console-active-bg);
   680    color: var(--color-console-fg);
   681  }
   682  
   683  /* end fomantic button overrides */
   684  
   685  /* begin fomantic dropdown menu overrides */
   686  
   687  .action-view-right .ui.dropdown .menu {
   688    background: var(--color-console-menu-bg);
   689    border-color: var(--color-console-menu-border);
   690  }
   691  
   692  .action-view-right .ui.dropdown .menu > .item {
   693    color: var(--color-console-fg);
   694  }
   695  
   696  .action-view-right .ui.dropdown .menu > .item:hover {
   697    color: var(--color-console-fg);
   698    background: var(--color-console-hover-bg);
   699  }
   700  
   701  .action-view-right .ui.dropdown .menu > .item:active {
   702    color: var(--color-console-fg);
   703    background: var(--color-console-active-bg);
   704  }
   705  
   706  .action-view-right .ui.dropdown .menu > .divider {
   707    border-top-color: var(--color-console-menu-border);
   708  }
   709  
   710  .action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
   711    background: var(--color-console-menu-bg);
   712    box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
   713  }
   714  
   715  /* end fomantic dropdown menu overrides */
   716  
   717  .job-info-header {
   718    display: flex;
   719    justify-content: space-between;
   720    align-items: center;
   721    padding: 0 12px;
   722    position: sticky;
   723    top: 0;
   724    height: 60px;
   725    z-index: 1; /* above .job-step-container */
   726    background: var(--color-console-bg);
   727    border-radius: 3px;
   728  }
   729  
   730  .job-info-header:has(+ .job-step-container) {
   731    border-radius: var(--border-radius) var(--border-radius) 0 0;
   732  }
   733  
   734  .job-info-header .job-info-header-title {
   735    color: var(--color-console-fg);
   736    font-size: 16px;
   737    margin: 0;
   738  }
   739  
   740  .job-info-header .job-info-header-detail {
   741    color: var(--color-console-fg-subtle);
   742    font-size: 12px;
   743  }
   744  
   745  .job-info-header-left {
   746    flex: 1;
   747  }
   748  
   749  .job-step-container {
   750    max-height: 100%;
   751    border-radius: 0 0 var(--border-radius) var(--border-radius);
   752    border-top: 1px solid var(--color-console-border);
   753    z-index: 0;
   754  }
   755  
   756  .job-step-container .job-step-summary {
   757    padding: 5px 10px;
   758    display: flex;
   759    align-items: center;
   760    border-radius: var(--border-radius);
   761  }
   762  
   763  .job-step-container .job-step-summary.step-expandable {
   764    cursor: pointer;
   765  }
   766  
   767  .job-step-container .job-step-summary.step-expandable:hover {
   768    color: var(--color-console-fg);
   769    background: var(--color-console-hover-bg);
   770  }
   771  
   772  .job-step-container .job-step-summary .step-summary-msg {
   773    flex: 1;
   774  }
   775  
   776  .job-step-container .job-step-summary .step-summary-duration {
   777    margin-left: 16px;
   778  }
   779  
   780  .job-step-container .job-step-summary.selected {
   781    color: var(--color-console-fg);
   782    background-color: var(--color-console-active-bg);
   783    position: sticky;
   784    top: 60px;
   785  }
   786  
   787  @media (max-width: 767.98px) {
   788    .action-view-body {
   789      flex-direction: column;
   790    }
   791    .action-view-left, .action-view-right {
   792      width: 100%;
   793    }
   794    .action-view-left {
   795      max-width: none;
   796    }
   797  }
   798  </style>
   799  
   800  <style>
   801  /* some elements are not managed by vue, so we need to use global style */
   802  .job-status-rotate {
   803    animation: job-status-rotate-keyframes 1s linear infinite;
   804  }
   805  
   806  @keyframes job-status-rotate-keyframes {
   807    100% {
   808      transform: rotate(-360deg);
   809    }
   810  }
   811  
   812  .job-step-section {
   813    margin: 10px;
   814  }
   815  
   816  .job-step-section .job-step-logs {
   817    font-family: var(--fonts-monospace);
   818    margin: 8px 0;
   819    font-size: 12px;
   820  }
   821  
   822  .job-step-section .job-step-logs .job-log-line {
   823    display: flex;
   824  }
   825  
   826  .job-log-line:hover,
   827  .job-log-line:target {
   828    background-color: var(--color-console-hover-bg);
   829  }
   830  
   831  .job-log-line:target {
   832    scroll-margin-top: 95px;
   833  }
   834  
   835  /* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
   836  .job-log-line .line-num, .log-time-seconds {
   837    width: 48px;
   838    color: var(--color-text-light-3);
   839    text-align: right;
   840    user-select: none;
   841  }
   842  
   843  .job-log-line:target > .line-num {
   844    color: var(--color-primary);
   845    text-decoration: underline;
   846  }
   847  
   848  .log-time-seconds {
   849    padding-right: 2px;
   850  }
   851  
   852  .job-log-line .log-time,
   853  .log-time-stamp {
   854    color: var(--color-text-light-3);
   855    margin-left: 10px;
   856    white-space: nowrap;
   857  }
   858  
   859  .job-step-section .job-step-logs .job-log-line .log-msg {
   860    flex: 1;
   861    word-break: break-all;
   862    white-space: break-spaces;
   863    margin-left: 10px;
   864    overflow-wrap: anywhere;
   865  }
   866  
   867  /* selectors here are intentionally exact to only match fullscreen */
   868  
   869  .full.height > .action-view-right {
   870    width: 100%;
   871    height: 100%;
   872    padding: 0;
   873    border-radius: 0;
   874  }
   875  
   876  .full.height > .action-view-right > .job-info-header {
   877    border-radius: 0;
   878  }
   879  
   880  .full.height > .action-view-right > .job-step-container {
   881    height: calc(100% - 60px);
   882    border-radius: 0;
   883  }
   884  
   885  /* TODO: group support
   886  
   887  .job-log-group {
   888  
   889  }
   890  .job-log-group-summary {
   891  
   892  }
   893  .job-log-list {
   894  
   895  } */
   896  </style>