Skip to content
Snippets Groups Projects
Status.vue 12.46 KiB
<template>
  <main class="container is-fluid">
    <h1 class="title">Process status</h1>
    <h2 class="subtitle">
      <ItemId label="Process ID:" :item-id="id" />
    </h2>

    <div class="columns">
      <NameField :process-id="process.id" />
      <div class="column" v-if="corpus && process.corpus">
        <strong>Project</strong><br />
        <router-link :to="{ name: 'navigation', params: { corpusId: process.corpus } }">{{ corpus.name }}</router-link>
      </div>
      <div class="column">
        <strong>Mode</strong><br />{{ processMode }}
      </div>
      <div class="column" v-if="process.farm">
        <strong>Farm</strong><br />{{ process.farm.name }}
      </div>
      <div class="column">
        <strong>Status</strong><br />{{ processStatus }}
      </div>
      <div class="column is-narrow" v-if="activeProcess || finishedProcess">
        <div class="dropdown is-right is-pulled-right is-hoverable dropdown-menu-min-width">
          <div class="dropdown-trigger">
            <button
              class="button is-info"
            >
              <span>Actions</span>
              <i class="icon-down-open"></i>
            </button>
          </div>
          <div class="dropdown-menu">
            <div class="dropdown-content">
              <template v-if="finishedProcess">
                <router-link
                  v-if="process.element"
                  class="dropdown-item"
                  :to="{ name: 'element-details', params: { id: process.element.id } }"
                >
                  <i class="icon-arrow-right"></i>
                  View element
                </router-link>
                <a
                  class="dropdown-item"
                  :class="!hasAdminAccess ? 'is-disabled' : ''"
                  v-if="isVerified"
                  v-on:click="retry"
                  :title="hasAdminAccess ? 'Retry this entire process' : 'An admin access is required to retry this process'"
                >
                  <i class="icon-undo"></i>
                  Retry process
                </a>
              </template>
              <template v-else-if="activeProcess">
                <a
                  class="dropdown-item has-text-danger"
                  :class="!isVerified ? 'is-disabled' : ''"
                  v-if="isVerified"
                  v-on:click="stop"
                  :title="hasAdminAccess ? 'Stop this process' : 'An admin access is required to stop this process'"
                >
                  <i class="icon-minus"></i>
                  Stop process
                </a>
              </template>
              <router-link
                :to="hasActivities ? { name: 'process-workers-activity', params: { processId: process.id } } : ''"
                class="dropdown-item"
                :class="!hasActivities ? 'is-disabled' : ''"
                :title="hasActivities ? 'Display statistics about workers activity' : 'This process has no workers activity tracking'"
              >
                <i class="icon-chart"></i>
                Workers activity
              </router-link>
              <router-link
                :to="hasConfiguration ? { name: 'process-configure', params: { processId: process.id } } : ''"
                class="dropdown-item"
                :class="!hasConfiguration ? 'is-disabled' : ''"
                :title="configurationTitle"
              >
                <i class="icon-arrow-left"></i>
                Process configuration
              </router-link>
              <TemplateCreation
                :process-id="process.id"
                v-if="process.state === 'completed'"
              >
                <template v-slot:default="{ open }">
                  <a
                    class="dropdown-item"
                    v-on:click="openTemplateModal(open)"
                    :class="!canCreateTemplate ? 'is-disabled' : ''"
                    :title="createTemplateTitle"
                  >
                    <i class="icon-plus"></i>
                    Create template
                  </a>
                </template>
              </TemplateCreation>
            </div>
          </div>
        </div>
      </div>
    </div>

    <div
      class="notification is-danger"
      v-if="process.mode === 'template'"
    >
      This is a process template so there is no detail to display.
    </div>
    <div
      class="notification is-warning"
      v-else-if="noTasks"
    >
      This process has no tasks.
    </div>
    <div
      class="notification is-info"
      v-else-if="!processTasks.length"
    >
      Loading tasks...
    </div>
    <template v-else>
      <div class="multi-progress">
        <div
          v-for="task in runs[lastRun]"
          :key="task.id"
          class="progress-block"
          :class="taskClass(task)"
        >
        </div>
      </div>
      <div class="tabs" v-if="Object.keys(runs).length > 1">
        <ul>
          <li
            v-for="run in Object.keys(runs)"
            :key="run"
            :class="run === selectedRun.toString() ? 'is-active' : ''"
          >
            <a v-on:click="selectRun(run)">Run {{ run }}</a>
          </li>
        </ul>
      </div>
      <Run
        :process-id="process.id"
        :selected-run="selectedRun"
      />
    </template>
  </main>
</template>

<script>
import { groupBy } from 'lodash'
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'

import {
  PROCESS_MODES,
  PROCESS_STATES,
  PROCESS_STATE_COLORS,
  PROCESS_FINAL_STATES
} from '@/config'
import { corporaMixin } from '@/mixins'
import { errorParser } from '@/helpers'

import ItemId from '@/components/ItemId'
import NameField from '@/components/Process/Status/NameField'
import Run from '@/components/Process/Status/Run'
import TemplateCreation from '@/components/Process/TemplateCreation'

export default {
  mixins: [
    corporaMixin
  ],
  props: {
    id: {
      type: String,
      required: true
    },
    selectedRun: {
      type: Number,
      default: -1
    }
  },
  components: {
    ItemId,
    NameField,
    Run,
    TemplateCreation
  },
  data: () => ({
    PENDING_ACTIVITY_STATE: 'pending',
    READY_ACTIVITY_STATE: 'ready',
    loading: false,
    error: ''
  }),
  mounted () {
    if ('Notification' in window) Notification.requestPermission()
  },
  beforeUnmount () {
    this.stopPolling()
  },
  methods: {
    ...mapActions('process', ['startPolling']),
    ...mapMutations('process', ['setProcesses', 'stopPolling']),
    ...mapMutations('notifications', ['notify']),
    async retry () {
      if (!this.process.id || this.loading) return
      try {
        await this.$store.dispatch('process/retryProcess', this.process.id)
        await this.$store.dispatch('process/retrieveProcess', this.process.id)
        this.$router.push({
          name: this.$route.name,
          params: {
            ...this.$route.params,
            selectedRun: this.lastRun
          }
        })
      } catch (err) {
        this.notify({ type: 'error', text: errorParser(err) })
      }
    },
    async stop () {
      if (!this.process.id || this.loading) return
      this.loading = true
      try {
        await this.$store.dispatch('process/stop', this.process)
      } catch (err) {
        this.notify({ type: 'error', text: errorParser(err) })
      } finally {
        this.loading = false
      }
    },
    selectRun (newRun) {
      if (newRun === this.selectedRun) return
      const route = {
        /*
         * Avoid to use the spread operator on the current route to pass attributes.
         * Otherwise the object passed to router.replace or router.push will include
         * a `path` key, causing trouble to Vue Router building the new URL. On this
         * route, the selected run will be concatenated to the process ID making the
         * new URL invalid e.g. if someone loads this page they will get a HTTP 404.
         */
        name: this.$route.name,
        params: {
          ...this.$route.params,
          selectedRun: newRun
        }
      }
      // If we were accessing this route without a selected run, replace the current route, otherwise push a new route
      if (!Number.isFinite(this.selectedRun) || this.selectedRun < 0) this.$router.replace(route)
      else this.$router.push(route)
    },
    taskClass (task) {
      return task.state === 'unscheduled' ? '' : PROCESS_STATE_COLORS[task.state].cssClass
    },
    openTemplateModal (fn) {
      if (this.canCreateTemplate) fn()
    }
  },
  computed: {
    ...mapGetters('auth', ['isVerified']),
    ...mapState('process', ['processes', 'tasks']),
    process () {
      return this.processes[this.id] ?? {}
    },
    finishedProcess () {
      return PROCESS_FINAL_STATES.includes(this.process?.state)
    },
    activeProcess () {
      return ['pending', 'running'].includes(this.process?.state)
    },
    processMode () {
      return PROCESS_MODES[this.process?.mode] ?? 'Unknown'
    },
    processStatus () {
      return PROCESS_STATES[this.process?.state] ?? 'Unknown'
    },
    runs () {
      return groupBy(Object.values(this.tasks), t => t.run)
    },
    lastRun () {
      return (this.processTasks.length > 0) ? Math.max(...this.processTasks.map(t => t.run)) : null
    },
    corpusId () {
      return this.process.corpus
    },
    hasAdminAccess () {
      if (this.corpus.id) return this.canAdmin(this.corpus)
      return true
    },
    hasActivities () {
      return this.process && this.process.activity_state === this.READY_ACTIVITY_STATE
    },
    /**
     * Only the tasks of the current process, and not all the tasks available in the store.
     * This ensures we are not displaying the tasks of multiple processes at once, to prevent race conditions.
     */
    processTasks () {
      return Object.values(this.tasks).filter(task => this.process.id && this.process.id === task.process_id)
    },
    /**
     * Whether this process has no tasks. Returns `false` if the process tasks are not yet available.
     */
    noTasks () {
      return this.process._complete === true && !this.processTasks.length
    },
    hasConfiguration () {
      return (['dataset', 'workers'].includes(this.process.mode) && this.hasAdminAccess)
    },
    configurationTitle () {
      if (!['dataset', 'workers'].includes(this.process.mode)) return 'Process configuration is only accessible for dataset and workers processes'
      else if (!this.hasAdminAccess) return 'An admin access is required to see this process configuration'
      return 'See process configuration'
    },
    canCreateTemplate () {
      return ['workers', 'dataset'].includes(this.process.mode)
    },
    createTemplateTitle () {
      if (!this.canCreateTemplate) return 'Templates can only be created from worker or dataset processes'
      return 'Create a template based on this process'
    }
  },
  watch: {
    lastRun (newValue) {
      // If the lastRun value changes for any reason and is still a valid run number, select it if it is lower than the currently selected run
      if (newValue !== null && newValue > 0 && this.selectedRun > newValue) this.selectRun(newValue)
    },
    selectedRun (newValue) {
      // Ensure the selected run is always within the bounds of existing runs
      if (this.lastRun !== null && (newValue < 0 || newValue > this.lastRun)) this.selectRun(this.lastRun)
    },
    processTasks () {
      // When the tasks are loaded, if no valid run is selected, pick the last run if it is available
      if (this.lastRun !== null && (this.selectedRun < 0 || this.selectedRun > this.lastRun)) this.selectRun(this.lastRun)
    },
    process: {
      immediate: true,
      handler (newValue, oldValue) {
        // If this is the first time the process is loaded and no run is selected, auto-select the latest run.
        if (!oldValue && newValue && this.lastRun !== null && (this.selectedRun < 0 || this.selectedRun > this.lastRun)) this.selectRun(this.lastRun)

        if (newValue?.id !== oldValue?.id) {
          this.startPolling(newValue.id)
        }

        // Notify of process state changes
        if (!('Notification' in window) || !oldValue || !newValue || oldValue.id !== newValue.id || !newValue.state || oldValue.state === newValue.state) return
        const n = new Notification(
          `${this.processMode} process ${this.process.name?.trim() || this.process.id}`,
          { body: 'Process status updated to ' + this.processStatus }
        )
        setTimeout(n.close.bind(n), 5000)
      }
    }
  }
}
</script>

<style scoped>
.graph-container {
  padding-top: 2rem;
}
.small-edit-button {
  margin-left: 1rem;
  height: 1.8rem;
  width: 1.8rem;
}
</style>