Pārlūkot izejas kodu

[7] Added run details page

A page showing the current experiment run details
was added.
at-robins 1 gadu atpakaļ
vecāks
revīzija
54e35da9e4

+ 0 - 197
frontend/src/components/PipelineDetails.vue

@@ -1,197 +0,0 @@
-<template>
-  <div class="q-pa-md q-gutter-md">
-    <q-card>
-      <q-card-section>
-        <div class="text-h6">Pipeline {{ id }}</div>
-      </q-card-section>
-      <div class="q-pa-md gutter-md wrap row" v-if="!loadingError">
-        <div
-          v-for="(detail, index) in details"
-          :key="detail.id"
-          class="q-pb-md"
-        >
-          <q-btn rounded no-caps @click="selectStep(detail)">
-            <q-spinner
-              v-if="isStepRunning(detail)"
-              :color="getDetailIconColour(detail)"
-              class="on-left"
-            ></q-spinner>
-            <q-icon
-              v-else
-              :name="getDetailIcon(detail)"
-              :color="getDetailIconColour(detail)"
-              left
-            ></q-icon>
-            <div class="text-center">{{ detail.name }}</div>
-          </q-btn>
-          <q-icon
-            v-if="index < details.length - 1"
-            name="trending_flat"
-          ></q-icon>
-        </div>
-      </div>
-      <div v-else>
-        <error-popup :error-response="loadingError" />
-      </div>
-    </q-card>
-    <q-card>
-      <q-card-section>
-        <div v-if="selectedStep === null" class="text-h6">
-          Select a step to display further information.
-        </div>
-        <div v-else class="text-h6">
-          Step {{ selectedStep.id }} - {{ selectedStep.name }}
-        </div>
-      </q-card-section>
-      <div v-if="selectedStep !== null" class="q-gutter-md q-pa-md col">
-        <q-btn label="Display logs" class="row" />
-        <q-btn label="Download output" class="row" />
-      </div>
-    </q-card>
-    <q-dialog v-model="showPollingError" v-if="pollingError">
-      <error-popup :error-response="pollingError" />
-    </q-dialog>
-  </div>
-</template>
-
-<script setup lang="ts">
-import {
-  PipelineStepStatus,
-  type ErrorResponse,
-  type PipelineStepDetail,
-} from "@/scripts/types";
-import axios from "axios";
-import { ref, onMounted, type Ref } from "vue";
-import ErrorPopup from "./ErrorPopup.vue";
-import { useRouter } from "vue-router";
-
-// The intervall in which pipeline updates are requested from the server.
-const POLLING_INTERVALL_MILLISECONDS = 3000;
-
-const details: Ref<Array<PipelineStepDetail>> = ref([]);
-const isLoadingPipelineDetails = ref(false);
-const loadingError: Ref<ErrorResponse | null> = ref(null);
-const isPollingPipelineDetails = ref(false);
-const pollingError: Ref<ErrorResponse | null> = ref(null);
-const selectedStep: Ref<PipelineStepDetail | null> = ref(null);
-const showPollingError = ref(false);
-const router = useRouter();
-const this_route = router.currentRoute.value.fullPath;
-
-const props = defineProps({
-  id: { type: String, required: true },
-});
-
-onMounted(() => {
-  loadPipelineDetails();
-});
-
-/**
- * Initial loading of details from the server.
- */
-function loadPipelineDetails() {
-  isLoadingPipelineDetails.value = true;
-  loadingError.value = null;
-  axios
-    .get("/api/pipeline/instance/" + props.id)
-    .then((response) => {
-      setPipelineDetails(response.data);
-      setTimeout(pollDetailsChanges, POLLING_INTERVALL_MILLISECONDS);
-    })
-    .catch((error) => {
-      details.value = [];
-      loadingError.value = error.response.data;
-    })
-    .finally(() => {
-      isLoadingPipelineDetails.value = false;
-    });
-}
-
-/**
- * Conitinuesly polls changes from the server.
- */
-function pollDetailsChanges() {
-  if (
-    !isPollingPipelineDetails.value &&
-    !loadingError.value &&
-    !pollingError.value &&
-    // Stop polling if the route changes.
-    router.currentRoute.value.fullPath === this_route
-  ) {
-    pollingError.value = null;
-    axios
-      .get("/api/pipeline/instance/" + props.id)
-      .then((response) => {
-        setPipelineDetails(response.data);
-        setTimeout(pollDetailsChanges, POLLING_INTERVALL_MILLISECONDS);
-      })
-      .catch((error) => {
-        showPollingError.value = true;
-        pollingError.value = error.response.data;
-      })
-      .finally(() => {
-        isPollingPipelineDetails.value = false;
-      });
-  }
-}
-
-function setPipelineDetails(response: PipelineStepDetail[]) {
-  details.value = response;
-  if (selectedStep.value) {
-    const update_selected = response.find(
-      (detail) => detail.id === selectedStep.value?.id
-    );
-    selectedStep.value = !update_selected ? null : update_selected;
-  }
-}
-
-/**
- * Returns the icon corresponding to the pipeline step status.
- */
-function getDetailIcon(detail: PipelineStepDetail): string {
-  switch (detail.status) {
-    case PipelineStepStatus.Success:
-      return "check_circle_outline";
-    case PipelineStepStatus.Failed:
-      return "highlight_off";
-    case PipelineStepStatus.Pending:
-      return "update";
-    case PipelineStepStatus.Running:
-      return "update";
-    default:
-      throw new Error("Unknown status: " + detail.status);
-  }
-}
-
-/**
- * Returns the icon colour corresponding to the pipeline step status.
- */
-function getDetailIconColour(detail: PipelineStepDetail): string {
-  switch (detail.status) {
-    case PipelineStepStatus.Success:
-      return "positive";
-    case PipelineStepStatus.Failed:
-      return "negative";
-    case PipelineStepStatus.Pending:
-      return "primary";
-    case PipelineStepStatus.Running:
-      return "primary";
-    default:
-      throw new Error("Unknown status: " + detail.status);
-  }
-}
-
-/**
- * Convinience method to check if the pipeline step is currently being run by the server.
- */
-function isStepRunning(detail: PipelineStepDetail): boolean {
-  return detail.status === PipelineStepStatus.Running;
-}
-
-/**
- * Selects the specified pipeline step to display related information.
- */
-function selectStep(detail: PipelineStepDetail) {
-  selectedStep.value = detail;
-}
-</script>

+ 13 - 3
frontend/src/components/experiment/ExperimentDetails.vue

@@ -17,7 +17,7 @@
             />
           </q-card-section>
           <q-card-section>
-            <experiment-status-indicator :status="status" />
+            <experiment-status-indicator :id="props.id" :status="status" />
           </q-card-section>
           <q-card-section>
             <entity-comment
@@ -145,7 +145,7 @@ import {
   matRestartAlt,
   matStopCircle,
 } from "@quasar/extras/material-icons";
-import { useRouter } from "vue-router";
+import { onBeforeRouteLeave, useRouter } from "vue-router";
 
 // The intervall in which status updates are requested from the server.
 const POLLING_INTERVALL_MILLISECONDS = 10000;
@@ -166,11 +166,18 @@ const isPolling = ref(false);
 const pollingError: Ref<ErrorResponse | null> = ref(null);
 const router = useRouter();
 const this_route = router.currentRoute.value.fullPath;
+const pollingTimer: Ref<number | null> = ref(null);
 
 const props = defineProps({
   id: { type: String, required: true },
 });
 
+onBeforeRouteLeave(() => {
+  if (pollingTimer.value !== null) {
+    clearTimeout(pollingTimer.value);
+  }
+});
+
 function updateTitle(title: string) {
   if (experiment.value) {
     experiment.value.name = title;
@@ -426,7 +433,10 @@ function pollStatusChanges() {
       .get("/api/experiments/" + props.id + "/status")
       .then((response) => {
         status.value = response.data;
-        setTimeout(pollStatusChanges, POLLING_INTERVALL_MILLISECONDS);
+        pollingTimer.value = window.setTimeout(
+          pollStatusChanges,
+          POLLING_INTERVALL_MILLISECONDS
+        );
       })
       .catch((error) => {
         pollingError.value = error.response.data;

+ 283 - 0
frontend/src/components/experiment/ExperimentRunDetails.vue

@@ -0,0 +1,283 @@
+<template>
+  <div class="q-pa-md q-gutter-md">
+    <q-card>
+      <q-card-section>
+        <div class="text-h6">Experiment {{ id }}</div>
+      </q-card-section>
+      <div class="q-pa-md gutter-md no-wrap row" v-if="!loadingError">
+        <div
+          v-if="isLoadingPipelineDetails"
+          class="flex-center col q-ma-lg"
+          style="display: flex"
+        >
+          <q-spinner size="xl" color="primary" />
+        </div>
+        <div
+          v-else
+          v-for="(stepGroup, indexGroup) in sortedSteps"
+          :key="stepGroup.map((s) => s.id).reduce((p, c) => p + c, 'stepGroup')"
+          class="q-pb-md row flex-center"
+        >
+          <div>
+            <div
+              v-for="(step, indexStep) in stepGroup"
+              :key="step.id"
+              :class="{ 'q-pb-md row': indexStep < stepGroup.length - 1 }"
+            >
+              <q-btn
+                rounded
+                flat
+                no-caps
+                @click="selectStep(step)"
+                :class="getChipColour(step)"
+              >
+                <div v-if="!step.status">
+                  <q-icon :name="symOutlinedNotStarted" color="primary" left />
+                </div>
+                <div v-else-if="step.status == PipelineStepStatus.Aborted">
+                  <q-icon :name="symOutlinedStopCircle" color="warning" left />
+                </div>
+                <div v-else-if="step.status == PipelineStepStatus.Failed">
+                  <q-icon :name="symOutlinedError" color="negative" left />
+                </div>
+                <div v-else-if="step.status == PipelineStepStatus.Finished">
+                  <q-icon
+                    :name="symOutlinedCheckCircle"
+                    color="positive"
+                    left
+                  />
+                </div>
+                <div v-else-if="step.status == PipelineStepStatus.Running">
+                  <q-spinner-orbit color="primary" class="on-left" />
+                </div>
+                <div v-else-if="step.status == PipelineStepStatus.Waiting">
+                  <q-spinner-hourglass color="primary" class="on-left" />
+                </div>
+                <div class="text-center">{{ step.name }}</div>
+              </q-btn>
+            </div>
+          </div>
+          <div class="q-ma-md">
+            <q-icon
+              v-if="indexGroup < sortedSteps.length - 1"
+              name="trending_flat"
+              size="lg"
+            />
+          </div>
+        </div>
+      </div>
+      <div v-else>
+        <error-popup :error-response="loadingError" />
+      </div>
+    </q-card>
+    <q-card>
+      <q-card-section>
+        <div v-if="selectedStep === null" class="text-h6">
+          Select a step to display further information.
+        </div>
+        <div v-else class="text-h6">{{ selectedStep.name }}</div>
+      </q-card-section>
+      <div v-if="selectedStep !== null" class="q-gutter-md q-pa-md col">
+        <q-btn label="Display logs" class="row" />
+        <q-btn label="Download output" class="row" />
+      </div>
+    </q-card>
+    <q-dialog v-model="showPollingError" v-if="pollingError">
+      <error-popup :error-response="pollingError" />
+    </q-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { type ErrorResponse } from "@/scripts/types";
+import axios from "axios";
+import { ref, onMounted, type Ref } from "vue";
+import ErrorPopup from "@/components/ErrorPopup.vue";
+import { onBeforeRouteLeave, useRouter } from "vue-router";
+import {
+  PipelineStepStatus,
+  type PipelineBlueprint,
+  type PipelineStepBlueprint,
+} from "@/scripts/pipeline-blueprint";
+import {
+  symOutlinedCheckCircle,
+  symOutlinedError,
+  symOutlinedNotStarted,
+  symOutlinedStopCircle,
+} from "@quasar/extras/material-symbols-outlined";
+
+// The intervall in which pipeline updates are requested from the server.
+const POLLING_INTERVALL_MILLISECONDS = 10000;
+
+const pipeline: Ref<PipelineBlueprint | null> = ref(null);
+const sortedSteps: Ref<PipelineStepBlueprint[][]> = ref([]);
+const isLoadingPipelineDetails = ref(false);
+const loadingError: Ref<ErrorResponse | null> = ref(null);
+const isPollingPipelineDetails = ref(false);
+const pollingError: Ref<ErrorResponse | null> = ref(null);
+const selectedStep: Ref<PipelineStepBlueprint | null> = ref(null);
+const showPollingError = ref(false);
+const router = useRouter();
+const this_route = router.currentRoute.value.fullPath;
+const pollingTimer: Ref<number | null> = ref(null);
+
+const props = defineProps({
+  id: { type: String, required: true },
+});
+
+onMounted(() => {
+  loadPipelineDetails();
+});
+
+onBeforeRouteLeave(() => {
+  if (pollingTimer.value !== null) {
+    clearTimeout(pollingTimer.value);
+  }
+});
+
+/**
+ * Initial loading of details from the server.
+ */
+function loadPipelineDetails() {
+  isLoadingPipelineDetails.value = true;
+  loadingError.value = null;
+  axios
+    .get("/api/experiments/" + props.id + "/run")
+    .then((response) => {
+      setPipelineDetails(response.data);
+      pollingTimer.value = window.setTimeout(
+        pollDetailsChanges,
+        POLLING_INTERVALL_MILLISECONDS
+      );
+    })
+    .catch((error) => {
+      pipeline.value = null;
+      loadingError.value = error.response.data;
+    })
+    .finally(() => {
+      isLoadingPipelineDetails.value = false;
+    });
+}
+
+/**
+ * Conitinuesly polls changes from the server.
+ */
+function pollDetailsChanges() {
+  if (
+    !isPollingPipelineDetails.value &&
+    !loadingError.value &&
+    !pollingError.value &&
+    // Stop polling if the route changes.
+    router.currentRoute.value.fullPath === this_route
+  ) {
+    pollingError.value = null;
+    axios
+      .get("/api/experiments/" + props.id + "/run")
+      .then((response) => {
+        setPipelineDetails(response.data);
+        pollingTimer.value = window.setTimeout(
+          pollDetailsChanges,
+          POLLING_INTERVALL_MILLISECONDS
+        );
+      })
+      .catch((error) => {
+        showPollingError.value = true;
+        pollingError.value = error.response.data;
+      })
+      .finally(() => {
+        isPollingPipelineDetails.value = false;
+      });
+  }
+}
+
+function setPipelineDetails(response: PipelineBlueprint | null) {
+  pipeline.value = response;
+  // Groupes pipeline steps based on dependencies.
+  // This sorting algorithm has a bad time complexity, but since it is
+  // executed asynchronously it does not really matter.
+  if (pipeline.value) {
+    const stepsByDependency: PipelineStepBlueprint[][] = [];
+    const satisfiedDependencies: string[] = [];
+    let remainingSteps = [...pipeline.value.steps];
+    while (remainingSteps.length > 0) {
+      const numberOfRemainingSteps = remainingSteps.length;
+      // Obtaines steps with satisfied dependencies.
+      const steps_with_satisfied_dependencies = remainingSteps.filter((step) =>
+        step.dependencies.every((dependency) =>
+          satisfiedDependencies.includes(dependency)
+        )
+      );
+      // Removes the obtained steps from the remaining steps.
+      remainingSteps = remainingSteps.filter(
+        (step) =>
+          !step.dependencies.every((dependency) =>
+            satisfiedDependencies.includes(dependency)
+          )
+      );
+      // Updates the dependencies which have already been
+      /// satisfied with the newly obtained values.
+      for (const step of steps_with_satisfied_dependencies) {
+        satisfiedDependencies.push(step.id);
+      }
+      stepsByDependency.push(steps_with_satisfied_dependencies);
+      // If for any reason there remain invalid pipeline steps that
+      // have dependiencies, which cannot be satisfied, append all
+      // of them to the end.
+      if (numberOfRemainingSteps == remainingSteps.length) {
+        stepsByDependency.push(remainingSteps);
+        break;
+      }
+    }
+    // Update at the end to avoid inconsitent UI state.
+    sortedSteps.value = stepsByDependency;
+  } else {
+    sortedSteps.value = [];
+  }
+  if (selectedStep.value) {
+    const update_selected = response?.steps.find(
+      (detail) => detail.id === selectedStep.value?.id
+    );
+    selectedStep.value = !update_selected ? null : update_selected;
+  }
+}
+
+function getChipColour(step: PipelineStepBlueprint) {
+  if (!selectedStep.value) {
+    return "chip-unselected";
+  } else if (step === selectedStep.value) {
+    return "chip-selected";
+  } else if (selectedStep.value.dependencies.includes(step.id)) {
+    return "chip-dependency";
+  } else {
+    return "chip-unselected";
+  }
+}
+
+/**
+ * Selects the specified pipeline step to display related information.
+ */
+function selectStep(step: PipelineStepBlueprint) {
+  if (selectedStep.value === step) {
+    selectedStep.value = null;
+  } else {
+    selectedStep.value = step;
+  }
+}
+</script>
+<style scoped lang="scss">
+.chip-unselected {
+  box-shadow: 0 1px 5px rgba(0, 0, 0, 0.2), 0 2px 2px rgba(0, 0, 0, 0.141),
+    0 3px 1px -2px rgba(0, 0, 0, 0.122);
+  transition: box-shadow 0.3s ease-in-out;
+}
+.chip-selected {
+  box-shadow: 0 1px 5px rgba(0, 100, 255, 0.4),
+    0 2px 2px rgba(0, 100, 255, 0.282), 0 3px 1px -2px rgba(0, 100, 255, 0.244);
+  transition: box-shadow 0.3s ease-in-out;
+}
+.chip-dependency {
+  box-shadow: 0 1px 5px rgba(255, 190, 0, 0.4),
+    0 2px 2px rgba(255, 190, 0, 0.282), 0 3px 1px -2px rgba(255, 190, 0, 0.244);
+  transition: box-shadow 0.3s ease-in-out;
+}
+</style>

+ 22 - 4
frontend/src/components/shared/ExperimentStatusIndicator.vue

@@ -1,8 +1,8 @@
 <template>
   <div class="row no-wrap">
     <div class="q-ma-md">
-      <q-btn round
-        ><div v-if="status == ExperimentExecutionStatus.None">
+      <q-btn round @click="navigateToRunDetails">
+        <div v-if="status == ExperimentExecutionStatus.None">
           <q-icon :name="matNotStarted" color="primary" />
         </div>
         <div v-else-if="status == ExperimentExecutionStatus.Aborted">
@@ -20,7 +20,10 @@
         <div v-else-if="status == ExperimentExecutionStatus.Waiting">
           <q-spinner-hourglass color="primary" />
         </div>
-        <q-tooltip>The current execution status of the experiment.</q-tooltip>
+        <q-tooltip
+          >The current execution status of the experiment. Click to display
+          further information.</q-tooltip
+        >
       </q-btn>
     </div>
     <q-separator vertical class="q-ml-md q-mr-md" />
@@ -44,6 +47,10 @@
       <div v-else-if="status == ExperimentExecutionStatus.Waiting">
         The next pipeline step is ready and waiting for execution.
       </div>
+      <div>
+        To display further details on the current experiment execution status
+        click on the status icon.
+      </div>
     </div>
   </div>
 </template>
@@ -57,12 +64,23 @@ import {
   matStop,
 } from "@quasar/extras/material-icons";
 import { ExperimentExecutionStatus } from "@/scripts/types";
+import { useRouter } from "vue-router";
+
+const router = useRouter();
 
-defineProps({
+const props = defineProps({
+  id: { type: String, required: true },
   status: {
     type: Object as PropType<ExperimentExecutionStatus>,
     required: true,
   },
 });
+
+function navigateToRunDetails() {
+  router.push({
+    name: "experiments_run_detail",
+    params: { id: props.id },
+  });
+}
 </script>
 <style scoped lang="scss"></style>

+ 4 - 4
frontend/src/router/index.ts

@@ -1,10 +1,10 @@
-import PipelineDetailsView from "@/views/PipelineDetailsView.vue";
 import { createRouter, createWebHistory } from "vue-router";
 import HomeView from "../views/HomeView.vue";
 import GlobalDataView from "@/views/GlobalDataView.vue";
 import GlobalDataDetailsView from "@/views/GlobalDataDetailsView.vue";
 import ExperimentView from "@/views/ExperimentView.vue";
 import ExperimentDetailsView from "@/views/ExperimentDetailsView.vue";
+import ExperimentRunDetails from "@/components/experiment/ExperimentRunDetails.vue";
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -41,9 +41,9 @@ const router = createRouter({
       props: true,
     },
     {
-      path: "/ui/pipeline/:id",
-      name: "pipeline_details",
-      component: PipelineDetailsView,
+      path: "/ui/experiments/:id/run",
+      name: "experiments_run_detail",
+      component: ExperimentRunDetails,
       props: true,
     },
   ],

+ 9 - 0
frontend/src/scripts/pipeline-blueprint.ts

@@ -12,6 +12,7 @@ export type PipelineStepBlueprint = {
   container: string;
   dependencies: string[];
   variables: PipelineStepBlueprintVariable[];
+  status: PipelineStepStatus | null | undefined;
 };
 
 export type PipelineStepBlueprintVariable = {
@@ -40,6 +41,14 @@ export type PipelineStepVariableUpload = {
   variableValue: string | null | undefined;
 };
 
+export enum PipelineStepStatus {
+  Aborted = "Aborted",
+  Failed = "Failed",
+  Finished = "Finished",
+  Running = "Running",
+  Waiting = "Waiting",
+}
+
 /**
  * Returns ```true``` if the variable is a boolean checkbox.
  *

+ 0 - 14
frontend/src/scripts/types.ts

@@ -1,17 +1,3 @@
-export type PipelineStepDetail = {
-  id: number;
-  name: string;
-  status: PipelineStepStatus;
-  creationTime: string;
-};
-
-export enum PipelineStepStatus {
-  Running = "Running",
-  Pending = "Pending",
-  Success = "Success",
-  Failed = "Failed",
-}
-
 export type ExperimentUpload = {
   name: string;
   mail: string | undefined;