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