938 lines · 29.6 KB · Vue
RawDownload
<script>import {SvgIcon} from '../svg.js';import ActionRunStatus from './ActionRunStatus.vue';import {createApp} from 'vue';import {toggleElem} from '../utils/dom.js';import {formatDatetime} from '../utils/time.js';import {renderAnsi} from '../render/ansi.js';import {GET, POST, DELETE} from '../modules/fetch.js'; const sfc = {  name: 'RepoActionView',  components: {    SvgIcon,    ActionRunStatus,  },  props: {    runIndex: String,    jobIndex: String,    actionsURL: String,    workflowName: String,    workflowURL: String,    locale: Object,  },   data() {    return {      // internal state      loading: false,      needLoadingWithLogCursors: null,      intervalID: null,      currentJobStepsStates: [],      artifacts: [],      menuVisible: false,      isFullScreen: false,      timeVisible: {        'log-time-stamp': false,        'log-time-seconds': false,      },       // provided by backend      run: {        link: '',        title: '',        titleHTML: '',        status: '',        canCancel: false,        canApprove: false,        canRerun: false,        done: false,        jobs: [          // {          //   id: 0,          //   name: '',          //   status: '',          //   canRerun: false,          //   duration: '',          // },        ],        commit: {          localeCommit: '',          localePushedBy: '',          localeWorkflow: '',          shortSHA: '',          link: '',          pusher: {            displayName: '',            link: '',          },          branch: {            name: '',            link: '',          },        },      },      currentJob: {        title: '',        detail: '',        steps: [          // {          //   summary: '',          //   duration: '',          //   status: '',          // }        ],      },    };  },   async mounted() {    // 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 this.loadJob();    this.intervalID = setInterval(this.loadJob, 1000);    document.body.addEventListener('click', this.closeDropdown);    this.hashChangeListener();    window.addEventListener('hashchange', this.hashChangeListener);  },   beforeUnmount() {    document.body.removeEventListener('click', this.closeDropdown);    window.removeEventListener('hashchange', this.hashChangeListener);  },   unmounted() {    // clear the interval timer when the component is unmounted    // even our page is rendered once, not spa style    if (this.intervalID) {      clearInterval(this.intervalID);      this.intervalID = null;    }  },   methods: {    // show/hide the step logs for a step    toggleStepLogs(idx) {      this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;      if (this.currentJobStepsStates[idx].expanded) {        // request data load immediately instead of waiting for next timer interval (which, if the job is done, will        // never happen because the interval will have been disabled)        this.loadJob();      }    },    // cancel a run    cancelRun() {      POST(`${this.run.link}/cancel`);    },    // approve a run    approveRun() {      POST(`${this.run.link}/approve`);    },    // show/hide the step logs for a group    toggleGroupLogs(event) {      const line = event.target.parentElement;      const list = line.nextSibling;      if (event.newState === 'open') {        list.classList.remove('hidden');      } else {        list.classList.add('hidden');      }    },     createLogLine(line, startTime, stepIndex, group) {      const div = document.createElement('div');      div.classList.add('job-log-line');      div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);      div._jobLogTime = line.timestamp;       const lineNumber = document.createElement('a');      lineNumber.classList.add('line-num', 'muted');      lineNumber.textContent = line.index;      lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);      div.append(lineNumber);       // for "Show timestamps"      const logTimeStamp = document.createElement('span');      logTimeStamp.className = 'log-time-stamp';      const date = new Date(parseFloat(line.timestamp * 1000));      const timeStamp = formatDatetime(date);      logTimeStamp.textContent = timeStamp;      toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);      // for "Show seconds"      const logTimeSeconds = document.createElement('span');      logTimeSeconds.className = 'log-time-seconds';      const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));      logTimeSeconds.textContent = `${seconds}s`;      toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);       let logMessage = document.createElement('span');      logMessage.innerHTML = renderAnsi(line.message);      if (group.isHeader) {        const details = document.createElement('details');        details.addEventListener('toggle', this.toggleGroupLogs);        const summary = document.createElement('summary');        summary.append(logMessage);        details.append(summary);        logMessage = details;      }      logMessage.className = 'log-msg';      logMessage.style.paddingLeft = `${group.depth}em`;       div.append(logTimeStamp);      div.append(logMessage);      div.append(logTimeSeconds);       return div;    },     appendLogs(stepIndex, logLines, startTime) {      const groupStack = [];      const container = this.$refs.logs[stepIndex];      for (const line of logLines) {        const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container;        const group = {          depth: groupStack.length,          isHeader: false,        };        if (line.message.startsWith('##[group]')) {          group.isHeader = true;           const logLine = this.createLogLine(            {              ...line,              message: line.message.substring(9),            },            startTime, stepIndex, group,          );          logLine.setAttribute('data-group', group.index);          el.append(logLine);           const list = document.createElement('div');          list.classList.add('job-log-list', 'hidden');          list.setAttribute('data-group', group.index);          groupStack.push(list);          el.append(list);        } else if (line.message.startsWith('##[endgroup]')) {          groupStack.pop();        } else {          el.append(this.createLogLine(line, startTime, stepIndex, group));        }      }    },     async fetchArtifacts() {      const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);      return await resp.json();    },     async deleteArtifact(name) {      if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;      await DELETE(`${this.run.link}/artifacts/${name}`);      await this.loadJob();    },     getLogCursors() {      return this.currentJobStepsStates.map((it, idx) => {        // 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 and can be any type.        // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc        return {step: idx, cursor: it.cursor, expanded: it.expanded};      });    },     async fetchJob(logCursors) {      const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {        data: {logCursors},      });      return await resp.json();    },     async loadJob() {      let myLoadingLogCursors = this.getLogCursors();      if (this.loading) {        // loadJob is already executing; but it's possible that our log cursor request has changed since it started.  If        // the interval load is active, that problem would solve itself, but if it isn't (say we're viewing a "done"        // job), then the change to the requested cursors may never be loaded.  To address this we set our newest        // requested log cursors into a state property and rely on loadJob to retry at the end of its execution if it        // notices these have changed.        this.needLoadingWithLogCursors = myLoadingLogCursors;        return;      }       try {        this.loading = true;        // Since no async operations occurred since fetching myLoadingLogCursors, we can be sure that we have the most        // recent needed log cursors, so we can reset needLoadingWithLogCursors -- it could be stale if exceptions        // occurred in previous load attempts.        this.needLoadingWithLogCursors = null;         let job, artifacts;         while (true) {          try {            [job, artifacts] = await Promise.all([              this.fetchJob(myLoadingLogCursors),              this.fetchArtifacts(), // refresh artifacts if upload-artifact step done            ]);          } catch (err) {            if (err instanceof TypeError) return; // avoid network error while unloading page            throw err;          }           // We can be done as long as needLoadingWithLogCursors is null, or the same as what we just loaded.          if (this.needLoadingWithLogCursors === null || JSON.stringify(this.needLoadingWithLogCursors) === JSON.stringify(myLoadingLogCursors)) {            this.needLoadingWithLogCursors = null;            break;          }           // Otherwise we need to retry that.          myLoadingLogCursors = this.needLoadingWithLogCursors;        }         this.artifacts = artifacts['artifacts'] || [];         // save the state to Vue data, then the UI will be updated        this.run = job.state.run;        this.currentJob = job.state.currentJob;         // sync the currentJobStepsStates to store the job step states        for (let i = 0; i < this.currentJob.steps.length; i++) {          if (!this.currentJobStepsStates[i]) {            // initial states for job steps            this.currentJobStepsStates[i] = {cursor: null, expanded: false};          }        }        // append logs to the UI        for (const logs of job.logs.stepsLog) {          // save the cursor, it will be passed to backend next time          this.currentJobStepsStates[logs.step].cursor = logs.cursor;          this.appendLogs(logs.step, logs.lines, logs.started);        }         if (this.run.done && this.intervalID) {          clearInterval(this.intervalID);          this.intervalID = null;        }      } finally {        this.loading = false;      }    },     isDone(status) {      return ['success', 'skipped', 'failure', 'cancelled'].includes(status);    },     isExpandable(status) {      return ['success', 'running', 'failure', 'cancelled'].includes(status);    },     closeDropdown() {      if (this.menuVisible) this.menuVisible = false;    },     toggleTimeDisplay(type) {      this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];      for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {        toggleElem(el, this.timeVisible[`log-time-${type}`]);      }    },     toggleFullScreen() {      this.isFullScreen = !this.isFullScreen;      const fullScreenEl = document.querySelector('.action-view-right');      const outerEl = document.querySelector('.full.height');      const actionBodyEl = document.querySelector('.action-view-body');      const headerEl = document.querySelector('#navbar');      const contentEl = document.querySelector('.page-content.repository');      const footerEl = document.querySelector('.page-footer');      toggleElem(headerEl, !this.isFullScreen);      toggleElem(contentEl, !this.isFullScreen);      toggleElem(footerEl, !this.isFullScreen);      // move .action-view-right to new parent      if (this.isFullScreen) {        outerEl.append(fullScreenEl);      } else {        actionBodyEl.append(fullScreenEl);      }    },    async hashChangeListener() {      const selectedLogStep = window.location.hash;      if (!selectedLogStep) return;      const [_, step, _line] = selectedLogStep.split('-');      if (!this.currentJobStepsStates[step]) return;      if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) {        this.currentJobStepsStates[step].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 this.loadJob();      }      const logLine = this.$refs.steps.querySelector(selectedLogStep);      if (!logLine) return;      logLine.querySelector('.line-num').click();    },  },}; export default sfc; export function initRepositoryActionView() {  const el = document.getElementById('repo-action-view');  if (!el) return;   // TODO: the parent element's full height doesn't work well now,  // but we can not pollute the global style at the moment, only fix the height problem for pages with this component  const parentFullHeight = document.querySelector('body > div.full.height');  if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';   const view = createApp(sfc, {    runIndex: el.getAttribute('data-run-index'),    jobIndex: el.getAttribute('data-job-index'),    actionsURL: el.getAttribute('data-actions-url'),    workflowName: el.getAttribute('data-workflow-name'),    workflowURL: el.getAttribute('data-workflow-url'),    locale: {      approve: el.getAttribute('data-locale-approve'),      cancel: el.getAttribute('data-locale-cancel'),      rerun: el.getAttribute('data-locale-rerun'),      artifactsTitle: el.getAttribute('data-locale-artifacts-title'),      areYouSure: el.getAttribute('data-locale-are-you-sure'),      confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),      rerun_all: el.getAttribute('data-locale-rerun-all'),      showTimeStamps: el.getAttribute('data-locale-show-timestamps'),      showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),      showFullScreen: el.getAttribute('data-locale-show-full-screen'),      downloadLogs: el.getAttribute('data-locale-download-logs'),      status: {        unknown: el.getAttribute('data-locale-status-unknown'),        waiting: el.getAttribute('data-locale-status-waiting'),        running: el.getAttribute('data-locale-status-running'),        success: el.getAttribute('data-locale-status-success'),        failure: el.getAttribute('data-locale-status-failure'),        cancelled: el.getAttribute('data-locale-status-cancelled'),        skipped: el.getAttribute('data-locale-status-skipped'),        blocked: el.getAttribute('data-locale-status-blocked'),      },    },  });  view.mount(el);}</script><template>  <div class="ui container fluid padded action-view-container">    <div class="action-view-header">      <div class="action-info-summary">        <div class="action-info-summary-title">          <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>          <!-- eslint-disable-next-line vue/no-v-html -->          <h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>        </div>        <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">          {{ locale.approve }}        </button>        <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">          {{ locale.cancel }}        </button>        <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">          {{ locale.rerun_all }}        </button>      </div>      <div class="action-summary">        {{ run.commit.localeCommit }}        <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>        {{ run.commit.localePushedBy }}        <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>        <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">          <span v-if="run.commit.branch.isDeleted" class="gt-ellipsis tw-line-through" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</span>          <a v-else class="gt-ellipsis" :href="run.commit.branch.link" :data-tooltip-content="run.commit.branch.name">{{ run.commit.branch.name }}</a>        </span>      </div>      <div class="action-summary">        {{ run.commit.localeWorkflow }}        <a class="muted" :href="workflowURL">{{ workflowName }}</a>      </div>    </div>    <div class="action-view-body">      <div class="action-view-left">        <div class="job-group-section">          <div class="job-brief-list">            <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">              <div class="job-brief-item-left">                <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>                <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>              </div>              <span class="job-brief-item-right">                <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-3 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/>                <span class="step-summary-duration">{{ job.duration }}</span>              </span>            </a>          </div>        </div>        <div class="job-artifacts" v-if="artifacts.length > 0">          <div class="job-artifacts-title">            {{ locale.artifactsTitle }}          </div>          <ul class="job-artifacts-list">            <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">              <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">                <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}              </a>              <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">                <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>              </a>            </li>          </ul>        </div>      </div>       <div class="action-view-right">        <div class="job-info-header">          <div class="job-info-header-left gt-ellipsis">            <h3 class="job-info-header-title gt-ellipsis">              {{ currentJob.title }}            </h3>            <p class="job-info-header-detail">              {{ currentJob.detail }}            </p>          </div>          <div class="job-info-header-right">            <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible">              <button class="btn gt-interact-bg tw-p-2">                <SvgIcon name="octicon-gear" :size="18"/>

Lines 1–500 of 938.