Browse Source

[7] Implemented step download

Downloading pipeline step output was implemented on the frontend and backend.
at-robins 7 months ago
parent
commit
605090d9ef

+ 81 - 0
backend/src/controller/file_controller.rs

@@ -12,15 +12,18 @@ use crate::{
     model::{
         db::{experiment::Experiment, global_data::GlobalData},
         exchange::file_path::{FileDetails, FilePath},
+        internal::archive::ArchiveMetadata,
     },
     service::multipart_service::{
         create_temporary_file, delete_temporary_file, parse_multipart_file,
     },
 };
+use actix_files::NamedFile;
 use actix_multipart::Multipart;
 use actix_web::{web, HttpResponse};
 use diesel::SqliteConnection;
 use serde::{Deserialize, Serialize};
+use zip_extensions::zip_create_from_directory;
 
 #[derive(Debug, Deserialize, Serialize)]
 #[serde(rename_all = "lowercase")]
@@ -269,6 +272,84 @@ async fn persist_multipart<P: AsRef<Path>>(
     Ok(())
 }
 
+pub async fn post_experiment_archive_step_results(
+    database_manager: web::Data<DatabaseManager>,
+    app_config: web::Data<Configuration>,
+    experiment_id: web::Path<i32>,
+    step_id: web::Json<String>,
+) -> Result<String, SeqError> {
+    let experiment_id: i32 = experiment_id.into_inner();
+    let mut connection = database_manager.database_connection()?;
+    Experiment::exists_err(experiment_id, &mut connection)?;
+
+    // Sets up the required information.
+    let step_id: String = step_id.into_inner();
+    let archive_id = Configuration::generate_uuid();
+    let archive_meta = ArchiveMetadata::new(format!("{}.zip", &step_id));
+
+    // Defines source and target paths.
+    let source = app_config.experiment_step_path(experiment_id.to_string(), &step_id);
+    let target = app_config.temporary_download_file_path(archive_id);
+    let target_meta = ArchiveMetadata::metadata_path(&target);
+
+    // Creates the parent directory if necessary.
+    if let Some(target_parent) = target.parent() {
+        std::fs::create_dir_all(target_parent)?;
+    }
+    // Creates the archive.
+    zip_create_from_directory(&target, &source).map_err(|err| {
+        SeqError::new(
+            "Archiving error",
+            SeqErrorType::InternalServerError,
+            format!("Creation of a downloadable archive for experiment {} ({}) from {} to {} failed with error: {}", experiment_id, step_id, source.display(), target.display(), err),
+            "Downloadable archive could not be created.",
+        )
+    })?;
+    // Creates the archive metadata.
+    serde_json::to_writer(std::fs::File::create(target_meta)?, &archive_meta)?;
+
+    //Return the archive ID.
+    Ok(archive_id.to_string())
+}
+
+pub async fn get_experiment_download_step_results(
+    database_manager: web::Data<DatabaseManager>,
+    app_config: web::Data<Configuration>,
+    experiment_id: web::Path<(i32, String)>,
+) -> Result<NamedFile, SeqError> {
+    let (experiment_id, archive_id) = experiment_id.into_inner();
+    let mut connection = database_manager.database_connection()?;
+    Experiment::exists_err(experiment_id, &mut connection)?;
+
+    let archive_path = app_config.temporary_download_file_path(archive_id);
+    if !archive_path.exists() {
+        return Err(SeqError::new(
+            "Not found",
+            SeqErrorType::NotFoundError,
+            format!("Archive file at path {} does not exist.", archive_path.display()),
+            "File not found.",
+        ));
+    }
+
+    let archive_meta_path = ArchiveMetadata::metadata_path(&archive_path);
+    if !archive_meta_path.exists() {
+        return Err(SeqError::new(
+            "Not found",
+            SeqErrorType::NotFoundError,
+            format!(
+                "Archive metadata file at path {} does not exist.",
+                archive_meta_path.display()
+            ),
+            "File not found.",
+        ));
+    }
+
+    let archive_meta: ArchiveMetadata =
+        serde_json::from_reader(std::fs::File::open(&archive_meta_path)?)?;
+
+    Ok(NamedFile::from_file(std::fs::File::open(&archive_path)?, archive_meta.file_name())?)
+}
+
 fn temp_file_to_data_file<P: AsRef<Path>, Q: AsRef<Path>>(
     file_path: P,
     temp_file_path: Q,

+ 6 - 1
backend/src/controller/routing.rs

@@ -13,7 +13,10 @@ use super::{
         post_experiment_execution_abort, post_experiment_execution_reset,
         post_experiment_pipeline_variable,
     },
-    file_controller::{delete_files_by_path, get_files, post_add_file, post_add_folder},
+    file_controller::{
+        delete_files_by_path, get_experiment_download_step_results, get_files, post_add_file,
+        post_add_folder, post_experiment_archive_step_results,
+    },
     global_data_controller::{
         create_global_data, delete_global_data, get_global_data, list_global_data,
         patch_global_data_comment, patch_global_data_name,
@@ -54,7 +57,9 @@ pub fn routing_config(cfg: &mut ServiceConfig) {
     .route("/api/experiments/{id}", web::get().to(get_experiment))
     .route("/api/experiments/{id}", web::post().to(post_execute_experiment))
     .route("/api/experiments/{id}/abort", web::post().to(post_experiment_execution_abort))
+    .route("/api/experiments/{id}/archive", web::post().to(post_experiment_archive_step_results))
     .route("/api/experiments/{id}/comment", web::patch().to(patch_experiment_comment))
+    .route("/api/experiments/{id}/download/{archive}", web::get().to(get_experiment_download_step_results))
         // This method is only POST to support the JSON message body.
     .route("/api/experiments/{id}/logs", web::post().to(get_experiment_step_logs))
     .route("/api/experiments/{id}/mail", web::patch().to(patch_experiment_mail))

+ 76 - 18
frontend/src/components/experiment/ExperimentRunDetails.vue

@@ -103,24 +103,47 @@
         </q-expansion-item>
       </q-card-section>
       <div v-if="selectedStep" class="q-gutter-md q-pa-md col">
-        <q-btn label="Download output" class="row" />
+        <div class="row">
+          <q-btn
+            label="Download output"
+            outline
+            :icon="matDownload"
+            :color="downloadError ? 'negative' : 'primary'"
+            :loading="isArchiving"
+            :disable="selectedStep.status !== PipelineStepStatus.Finished"
+            @click="downloadStepResults(selectedStep)"
+          >
+            <template v-slot:loading>
+              <span class="block">
+                <q-spinner class="on-left" />
+                Generating archive
+              </span>
+            </template>
+            <q-tooltip>
+              <div v-if="downloadError">
+                <error-popup :error-response="downloadError" />
+              </div>
+              <div>Downloads the output of the execution step.</div>
+            </q-tooltip>
+          </q-btn>
 
-        <q-btn
-          v-if="canBeStarted(selectedStep)"
-          class="row"
-          :icon="matRestartAlt"
-          label="Restart step"
-          :color="restartingError ? 'negative' : 'positive'"
-          :loading="isRestarting"
-          @click="restartStep(selectedStep)"
-        >
-          <q-tooltip>
-            <div v-if="restartingError">
-              <error-popup :error-response="restartingError" />
-            </div>
-            <div>Restarts the experiment execution step.</div>
-          </q-tooltip>
-        </q-btn>
+          <q-btn
+            v-if="canBeStarted(selectedStep)"
+            :icon="matRestartAlt"
+            label="Restart step"
+            class="q-ml-md"
+            :color="restartingError ? 'negative' : 'positive'"
+            :loading="isRestarting"
+            @click="restartStep(selectedStep)"
+          >
+            <q-tooltip>
+              <div v-if="restartingError">
+                <error-popup :error-response="restartingError" />
+              </div>
+              <div>Restarts the experiment execution step.</div>
+            </q-tooltip>
+          </q-btn>
+        </div>
       </div>
     </q-card>
     <q-dialog v-model="showPollingError" v-if="pollingError">
@@ -147,7 +170,7 @@ import {
   symOutlinedStopCircle,
   symOutlinedTerminal,
 } from "@quasar/extras/material-symbols-outlined";
-import { matRestartAlt } from "@quasar/extras/material-icons";
+import { matDownload, matRestartAlt } from "@quasar/extras/material-icons";
 import ExperimentStepLogs from "./ExperimentStepLogs.vue";
 
 // The intervall in which pipeline updates are requested from the server.
@@ -167,6 +190,8 @@ const showPollingError = ref(false);
 const router = useRouter();
 const this_route = router.currentRoute.value.fullPath;
 const pollingTimer: Ref<number | null> = ref(null);
+const isArchiving = ref(false);
+const downloadError: Ref<ErrorResponse | null> = ref(null);
 
 const props = defineProps({
   id: { type: String, required: true },
@@ -373,6 +398,39 @@ function restartStep(step: PipelineStepBlueprint | null) {
       });
   }
 }
+
+/**
+ * Tries to download the specified step.
+ *
+ * @param step the step to download
+ */
+function downloadStepResults(step: PipelineStepBlueprint | null) {
+  if (step && step.status == PipelineStepStatus.Finished) {
+    isArchiving.value = true;
+    downloadError.value = null;
+    const config = {
+      headers: {
+        "content-type": "application/json",
+      },
+    };
+    axios
+      .post(
+        "/api/experiments/" + props.id + "/archive",
+        JSON.stringify(step.id),
+        config
+      )
+      .then((response) => {
+        window.location.href =
+          "/api/experiments/" + props.id + "/download/" + response.data;
+      })
+      .catch((error) => {
+        downloadError.value = error.response.data;
+      })
+      .finally(() => {
+        isArchiving.value = false;
+      });
+  }
+}
 </script>
 <style scoped lang="scss">
 .chip-unselected {