diff --git a/README.md b/README.md index 26d5966723926c2278d366ce9cc261ac6fea4e12..4733d826e19f1495e86207bb7bd713ed97185739 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ The docker container replaces them in the `env.template.js` file and moves that When accessing the website, these variables will be loaded dynamically into the application. -| Variable | Default | Value | Description | -|-------------------------|---------|-----------|----------------------------------------| -| `AUTH_API_BASE_URL` | unset | HTTP URL | Base URL for the Auth Service API | -| `WORKFLOW_API_BASE_URL` | unset | HTTP URL | Base URL for the Workflow Service API | -| `S3PROXY_API_BASE_URL` | unset | HTTP URL | Base URL for the S3Proxy Service API | -| `S3_URL` | unset | HTTP URL | URL of the S3 storage to interact with | +| Variable | Default | Value | Description | +|-------------------------|---------|----------|--------------------------------------------------| +| `AUTH_API_BASE_URL` | unset | HTTP URL | Base URL for the Auth Service API | +| `WORKFLOW_API_BASE_URL` | unset | HTTP URL | Base URL for the Workflow Service API | +| `S3PROXY_API_BASE_URL` | unset | HTTP URL | Base URL for the S3Proxy Service API | +| `S3_URL` | unset | HTTP URL | URL of the S3 storage to interact with | +| `DEV_SYSTEM` | `false` | boolean | Flag if the service is installed on a Dev system | diff --git a/src/assets/env.template.js b/src/assets/env.template.js index 492751650dcba9de2a84c86872e1d7a3ad3dbea7..c2ddbd6f476a9e787b158e38a695bef8924f79c9 100644 --- a/src/assets/env.template.js +++ b/src/assets/env.template.js @@ -6,4 +6,5 @@ window["env"]["workflowApiUrl"] = "${WORKFLOW_API_BASE_URL}"; window["env"]["s3proxyApiUrl"] = "${S3PROXY_API_BASE_URL}"; window["env"]["authApiUrl"] = "${AUTH_API_BASE_URL}"; + window["env"]["devSystem"] = "${DEV_SYSTEM}"; })(this); diff --git a/src/components/modals/BootstrapModal.vue b/src/components/modals/BootstrapModal.vue index cc2b4071374b560d85a2ae74b59e516351af93d8..3f88264b9aa8aacaa129e5dab86fe9e4b935d09e 100644 --- a/src/components/modals/BootstrapModal.vue +++ b/src/components/modals/BootstrapModal.vue @@ -15,10 +15,7 @@ defineProps<{ aria-hidden="true" :data-bs-backdrop="staticBackdrop ? 'static' : null" > - <div - class="modal-dialog modal-dialog-centered modal-dialog-scrollable" - style="min-width: 35%" - > + <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable"> <div class="modal-content"> <div class="modal-header"> <div class="modal-title fs-5" :id="modalLabel"> diff --git a/src/components/object-storage/modals/CreateFolderModal.vue b/src/components/object-storage/modals/CreateFolderModal.vue index 433053548a88834652e4aae2aeb515b13d05865a..7b0400c6aebddd0e380115e5715889477606397d 100644 --- a/src/components/object-storage/modals/CreateFolderModal.vue +++ b/src/components/object-storage/modals/CreateFolderModal.vue @@ -35,14 +35,25 @@ const formState = reactive<{ function uploadFolder() { const key = - (props.keyPrefix.length > 0 + props.keyPrefix.length > 0 ? props.keyPrefix + "/" + formState.folderName - : formState.folderName) + "/.s3keep"; + : formState.folderName; + const reversedKey = key + .replace(/(\/)\1+/g, "/") + .split("") + .reverse(); + console.log(reversedKey); + const firstLetterIndex = reversedKey.findIndex((char) => char !== "/"); + if (firstLetterIndex < 0) { + return; + } + const realKey = reversedKey.slice(firstLetterIndex).reverse().join("") + "/"; + console.log(realKey); const command = new PutObjectCommand({ Bucket: props.bucketName, Body: "", ContentType: "text/plain", - Key: key, + Key: realKey, }); formState.uploading = true; props.s3Client diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue index 135a1520ca31b4c6ae51b3852354fc69bb3dad77..096e2ce775528fe9c702bd4fc1bd95416b5c715e 100644 --- a/src/components/parameter-schema/ParameterSchemaFormComponent.vue +++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue @@ -2,13 +2,10 @@ import { computed, ref, reactive, watch, onMounted } from "vue"; import ParameterGroupForm from "@/components/parameter-schema/form-mode/ParameterGroupForm.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; -import { WorkflowExecutionService } from "@/client/workflow"; -import type { ApiError } from "@/client/workflow"; import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; import ParameterStringInput from "@/components/parameter-schema/form-mode/ParameterStringInput.vue"; import { Toast } from "bootstrap"; -import { useRouter } from "vue-router"; // Props // ============================================================================= @@ -16,13 +13,23 @@ const props = defineProps({ schema: { type: Object, }, - workflowVersionId: { - type: String, - required: true, + loading: { + type: Boolean, + }, + allowNotes: { + type: Boolean, }, }); -const router = useRouter(); +const emit = defineEmits<{ + ( + e: "start-workflow", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: Record<string, any>, + notes?: string, + report_output_bucket?: string + ): void; +}>(); // Bootstrap Elements // ============================================================================= @@ -54,14 +61,12 @@ const formState = reactive<{ validated: boolean; pipelineNotes: string; report_bucket?: string; - loading: boolean; errorType?: string; }>({ formInput: {}, validated: false, pipelineNotes: "", report_bucket: undefined, - loading: false, errorType: undefined, }); @@ -123,12 +128,14 @@ function updateSchema(schema: Record<string, any>) { formState.formInput = Object.fromEntries(b); } /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars */ - function startWorkflow() { + errorToast?.hide(); formState.validated = true; formState.errorType = undefined; if (launchForm.value?.checkValidity()) { - const realInput = Object.values(formState.formInput).reduce((acc, val) => { + const realInput: Record<string, any> = Object.values( + formState.formInput + ).reduce((acc, val) => { return { ...acc, ...val }; }); const schemaValid = validateSchema(realInput); @@ -137,28 +144,12 @@ function startWorkflow() { console.error(validateSchema.errors); errorToast?.show(); } else { - formState.loading = true; - WorkflowExecutionService.workflowExecutionStartWorkflow({ - workflow_version_id: props.workflowVersionId, - parameters: realInput, - notes: formState.pipelineNotes, - report_output_bucket: formState.report_bucket, - }) - .then(() => { - router.push({ - name: "workflow-executions", - }); - }) - .catch((err: ApiError) => { - console.error(err); - if (err.body["detail"].includes("workflow execution limit")) { - formState.errorType = "limit"; - } - errorToast?.show(); - }) - .finally(() => { - formState.loading = false; - }); + emit( + "start-workflow", + realInput, + formState.pipelineNotes, + formState.report_bucket + ); } } else { formState.errorType = "form"; @@ -186,10 +177,7 @@ onMounted(() => { > <div class="d-flex p-2 justify-content-between align-items-center"> <div class="toast-body"> - <template v-if="formState.errorType === 'limit'"> - You have too many active workflow executions to start a new one. - </template> - <template v-else-if="formState.errorType === 'form'"> + <template v-if="formState.errorType === 'form'"> Some inputs are not valid. </template> <template v-else> @@ -225,20 +213,25 @@ onMounted(() => { <h5 class="card-title"> General Options about the pipeline execution </h5> - <div class="input-group"> - <span class="input-group-text" id="pipelineNotes"> - <font-awesome-icon class="me-2" icon="fa-solid fa-sticky-note" /> - <code>--notes</code> - </span> - <textarea - class="form-control" - rows="1" - v-model="formState.pipelineNotes" - /> - </div> - <label class="mb-3" - >Personal notes about the pipeline execution</label - > + <template v-if="props.allowNotes"> + <div class="input-group"> + <span class="input-group-text" id="pipelineNotes"> + <font-awesome-icon + class="me-2" + icon="fa-solid fa-sticky-note" + /> + <code>--notes</code> + </span> + <textarea + class="form-control" + rows="1" + v-model="formState.pipelineNotes" + /> + </div> + <label class="mb-3" + >Personal notes about the pipeline execution</label + > + </template> <div class="input-group"> <span class="input-group-text" id="pipelineNotes"> <font-awesome-icon class="me-2" icon="fa-solid fa-sticky-note" /> @@ -301,7 +294,7 @@ onMounted(() => { type="submit" form="launchWorkflowForm" class="btn btn-success w-50 mx-2" - :disabled="formState.loading || !props.schema" + :disabled="props.loading || !props.schema" > <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" /> Launch diff --git a/src/components/workflows/WorkflowDocumentationTabs.vue b/src/components/workflows/WorkflowDocumentationTabs.vue new file mode 100644 index 0000000000000000000000000000000000000000..6fd048de6e9edf84526d700274baef99a92e6d34 --- /dev/null +++ b/src/components/workflows/WorkflowDocumentationTabs.vue @@ -0,0 +1,137 @@ +<script setup lang="ts"> +import { computed } from "vue"; +import MarkdownRenderer from "@/components/MarkdownRenderer.vue"; +import ParameterSchemaDescriptionComponent from "@/components/parameter-schema/ParameterSchemaDescriptionComponent.vue"; +import { useRoute } from "vue-router"; + +const route = useRoute(); + +const props = defineProps<{ + loading: boolean; + descriptionMarkdown?: string; + changelogMarkdown?: string; + usageMarkdown?: string; + outputMarkdown?: string; + parameterSchema?: Record<string, never>; +}>(); + +const activeTab = computed<string>( + () => (route.query["tab"] as string) ?? "description" +); +</script> + +<template> + <ul class="nav justify-content-evenly nav-tabs fs-5 mb-3"> + <li class="nav-item"> + <router-link + class="nav-link" + aria-current="page" + :to="{ query: { ...route.query, tab: 'description' } }" + :class="{ + active: activeTab === 'description', + disabled: !props.descriptionMarkdown, + }" + >Description + </router-link> + </li> + <li class="nav-item"> + <router-link + class="nav-link" + :to="{ query: { ...route.query, tab: 'usage' } }" + :class="{ + active: activeTab === 'usage', + disabled: !props.usageMarkdown, + }" + >Usage + </router-link> + </li> + <li class="nav-item"> + <router-link + class="nav-link" + :to="{ query: { ...route.query, tab: 'parameters' } }" + :class="{ + active: activeTab === 'parameters', + disabled: !props.parameterSchema, + }" + >Parameters + </router-link> + </li> + <li class="nav-item"> + <router-link + class="nav-link" + :to="{ query: { ...route.query, tab: 'output' } }" + :class="{ + active: activeTab === 'output', + disabled: !props.outputMarkdown, + }" + >Output + </router-link> + </li> + <li class="nav-item"> + <router-link + class="nav-link" + :to="{ query: { ...route.query, tab: 'changes' } }" + :class="{ + active: activeTab === 'changes', + disabled: !props.changelogMarkdown, + }" + >Releases + </router-link> + </li> + </ul> + <div v-if="props.loading"> + <p class="placeholder-glow mt-2 mb-4"> + <span class="placeholder col-7 fs-1"></span> + </p> + <p + v-for="n in 8" + :key="n" + class="placeholder-glow row ms-1" + :class="'my-' + Math.floor(Math.random() * 6)" + > + <span + class="placeholder" + :class="'col-' + Math.floor(Math.random() * 9 + 2)" + ></span> + </p> + </div> + <div v-else class="px-2"> + <div v-if="activeTab === 'description'"> + <markdown-renderer + v-if="props.descriptionMarkdown" + :markdown="props.descriptionMarkdown" + /> + <h4 v-else class="text-center mt-5">Description is not available</h4> + </div> + <template v-else-if="activeTab === 'parameters'"> + <parameter-schema-description-component + v-if="props.parameterSchema" + :schema="props.parameterSchema" + /> + <h4 v-else class="text-center mt-5">Parameters are not available</h4> + </template> + <div v-else-if="activeTab === 'changes'"> + <markdown-renderer + v-if="props.changelogMarkdown" + :markdown="props.changelogMarkdown" + /> + <h4 v-else class="text-center mt-5">Changelog is not available</h4> + </div> + <div v-else-if="activeTab === 'output'"> + <markdown-renderer + v-if="props.outputMarkdown" + :markdown="props.outputMarkdown" + /> + <h4 v-else class="text-center mt-5">Output is not available</h4> + </div> + <div v-else-if="activeTab === 'usage'"> + <markdown-renderer + v-if="props.usageMarkdown" + :markdown="props.usageMarkdown" + /> + <h4 v-else class="text-center mt-5">Usage is not available</h4> + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/components/workflows/modals/ArbitraryWorkflowModal.vue b/src/components/workflows/modals/ArbitraryWorkflowModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..7280c079907d6e3665396576562ff7ea16e75c9b --- /dev/null +++ b/src/components/workflows/modals/ArbitraryWorkflowModal.vue @@ -0,0 +1,219 @@ +<script setup lang="ts"> +import BootstrapModal from "@/components/modals/BootstrapModal.vue"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { computed, onMounted, reactive, ref } from "vue"; +import { useRouter } from "vue-router"; +import { + GitRepository, + requiredRepositoryFiles, + determineGitIcon, +} from "@/utils/GitRepository"; +import { Modal } from "bootstrap"; + +const props = defineProps<{ + modalID: string; +}>(); + +let createWorkflowModal: Modal | null = null; +const arbitraryWorkflowForm = ref<HTMLFormElement | undefined>(undefined); +const workflowRepositoryElement = ref<HTMLInputElement | undefined>(undefined); +const router = useRouter(); + +const workflow = reactive<{ + repository_url: string; + git_commit_hash: string; +}>({ + repository_url: "", + git_commit_hash: "", +}); + +const formState = reactive<{ + loading: boolean; + checkRepoLoading: boolean; + validated: boolean; + allowUpload: boolean; + missingFiles: string[]; + unsupportedRepository: boolean; +}>({ + validated: false, + allowUpload: false, + loading: false, + checkRepoLoading: false, + missingFiles: [], + unsupportedRepository: false, +}); + +function modalClosed() { + formState.validated = false; +} + +function viewWorkflow() { + console.log("View", workflow); + createWorkflowModal?.hide(); + router.push({ + name: "arbitrary-workflow", + query: { + repository: encodeURI(workflow.repository_url), + commit_hash: workflow.git_commit_hash, + }, + }); +} + +function checkRepository() { + formState.validated = true; + if (arbitraryWorkflowForm.value?.checkValidity() && !formState.allowUpload) { + formState.unsupportedRepository = false; + formState.missingFiles = []; + workflowRepositoryElement.value?.setCustomValidity(""); + try { + const repo = GitRepository.buildRepository( + workflow.repository_url, + workflow.git_commit_hash + ); + repo + .checkFilesExist(requiredRepositoryFiles, true) + .then(() => { + formState.allowUpload = true; + }) + .catch((e: Error) => { + formState.missingFiles = e.message.split(","); + // Allow execution of the workflow if main.nf and parameter schema are not missing + if ( + formState.missingFiles.findIndex( + (file) => file === "main.nf" || file === "nextflow_schema.json" + ) < 0 + ) { + formState.allowUpload = true; + } + }); + } catch (e) { + formState.unsupportedRepository = true; + workflowRepositoryElement.value?.setCustomValidity( + "Repository is not supported" + ); + } + } +} +const gitIcon = computed<string>(() => + determineGitIcon(workflow.repository_url) +); + +onMounted(() => { + createWorkflowModal = new Modal("#" + props.modalID); +}); +</script> + +<template> + <bootstrap-modal + :modalID="modalID" + :static-backdrop="false" + modal-label="Create Workflow Modal" + v-on="{ 'hidden.bs.modal': modalClosed }" + > + <template v-slot:header>Start arbitrary Workflow</template> + <template v-slot:body> + <form + id="arbitraryWorkflowForm" + :class="{ 'was-validated': formState.validated }" + ref="arbitraryWorkflowForm" + > + <div class="mb-3"> + <label for="workflowRepositoryInput" class="form-label" + >Git Repository URL</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon :icon="gitIcon" /> + </div> + <input + type="url" + class="form-control" + id="workflowRepositoryInput" + placeholder="https://..." + required + ref="workflowRepositoryElement" + v-model="workflow.repository_url" + @change="formState.allowUpload = false" + aria-describedby="gitRepoProviderHelp" + /> + </div> + <div id="gitRepoProviderHelp" class="form-text"> + We support Github and GitLab Repositories + </div> + <div class="text-danger"> + <div v-if="formState.unsupportedRepository"> + Repository is not supported + </div> + </div> + </div> + <div class="mb-3"> + <label for="workflowGitCommitInput" class="form-label" + >Git Commit Hash</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-code-commit" /> + </div> + <input + type="text" + class="form-control text-lowercase" + id="workflowGitCommitInput" + placeholder="ba8bcd9..." + required + ref="workflowGitCommitHashElement" + maxlength="40" + minlength="40" + pattern="[0-9a-f]{40}" + v-model="workflow.git_commit_hash" + @change="formState.allowUpload = false" + /> + </div> + </div> + <div v-if="formState.missingFiles.length > 0" class="text-danger"> + The following files are missing in the repository + <ul> + <li v-for="file in formState.missingFiles" :key="file"> + {{ file }} + </li> + </ul> + </div> + </form> + </template> + <template v-slot:footer> + <button + type="button" + class="btn btn-info me-auto" + @click="checkRepository" + :disabled="formState.allowUpload" + > + <span + v-if="formState.checkRepoLoading" + class="spinner-border spinner-border-sm" + role="status" + aria-hidden="true" + ></span> + Check Repository + </button> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> + Close + </button> + <button + type="submit" + form="workflowCreateForm" + class="btn btn-primary" + :disabled="formState.loading || !formState.allowUpload" + @click.prevent="viewWorkflow" + > + <span + v-if="formState.loading" + class="spinner-border spinner-border-sm" + role="status" + aria-hidden="true" + ></span> + Save + </button> + </template> + </bootstrap-modal> +</template> + +<style scoped></style> diff --git a/src/environment.ts b/src/environment.ts index a63ee4ea2d35f99704658443dfac4e189dcce8e1..cb756b7e77d18de3fe8208de0e8670b87e123a66 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -6,6 +6,7 @@ export const environment: env = { S3PROXY_API_BASE_URL: windowEnv["s3proxyApiUrl"], AUTH_API_BASE_URL: windowEnv["authApiUrl"], WORKFLOW_API_BASE_URL: windowEnv["workflowApiUrl"], + DEV_SYSTEM: windowEnv["devSystem"].toLowerCase() === "true", }; type env = { @@ -13,4 +14,5 @@ type env = { S3PROXY_API_BASE_URL: string; AUTH_API_BASE_URL: string; WORKFLOW_API_BASE_URL: string; + DEV_SYSTEM: boolean; }; diff --git a/src/router/index.ts b/src/router/index.ts index 7b9d1ff72eb4e38b6d9cf9e8156bd2af77f75545..8abd1c20ea0b19fd0cc8c0381e86aeb4e2e7fcc2 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -51,6 +51,17 @@ const router = createRouter({ component: () => import("../views/workflows/ReviewWorkflowsView.vue"), meta: { requiresReviewerRole: true }, }, + { + path: "workflows/arbitrary", + name: "arbitrary-workflow", + component: () => + import("../views/workflows/ArbitraryWorkflowView.vue"), + meta: { requiresDeveloperRole: true }, + props: (route) => ({ + repository: route.query.repository, + commit_hash: route.query.commit_hash, + }), + }, { path: "workflows/:workflowId", name: "workflow", diff --git a/src/utils/GitRepository.ts b/src/utils/GitRepository.ts index 60065008a05214eaf4b1b38a0b64367428cefc0f..697d6963e3d945722115768d35a7fd299ef2c1ea 100644 --- a/src/utils/GitRepository.ts +++ b/src/utils/GitRepository.ts @@ -39,6 +39,15 @@ export abstract class GitRepository { return true; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async downloadFile(filepath: string): Promise<any> { + try { + return await axios.get(this.fileUrl(filepath)); + } catch (e) { + return ""; + } + } + async checkFilesExist( files: string[], raiseError = false diff --git a/src/views/object-storage/BucketView.vue b/src/views/object-storage/BucketView.vue index 77bcb0dde6387c0d5e3b9ce4c28fe35cd310cfc3..e1fedbf606b0edc004082cc4b5b165e3ce241757 100644 --- a/src/views/object-storage/BucketView.vue +++ b/src/views/object-storage/BucketView.vue @@ -273,7 +273,7 @@ const visibleObjects = computed<(S3ObjectWithFolder | S3PseudoFolder)[]>(() => { } as S3PseudoFolder; }) ); - return arr.filter((obj) => !obj.key.endsWith(".s3keep")); + return arr.filter((obj) => !obj.key.endsWith("/") && obj.key.length > 0); }); const subFolderInUrl = computed<boolean>( diff --git a/src/views/workflows/ArbitraryWorkflowView.vue b/src/views/workflows/ArbitraryWorkflowView.vue new file mode 100644 index 0000000000000000000000000000000000000000..6cbf6d1c2c42c641601ec6dd64cc911081d89b15 --- /dev/null +++ b/src/views/workflows/ArbitraryWorkflowView.vue @@ -0,0 +1,176 @@ +<script setup lang="ts"> +import WorkflowDocumentationTabs from "@/components/workflows/WorkflowDocumentationTabs.vue"; +import { onMounted, reactive, ref } from "vue"; +import { GitRepository, requiredRepositoryFiles } from "@/utils/GitRepository"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.vue"; +import { WorkflowExecutionService } from "@/client/workflow"; +import { useRouter } from "vue-router"; +import { Toast } from "bootstrap"; + +const props = defineProps<{ + repository?: string; + commit_hash?: string; +}>(); + +const router = useRouter(); + +const workflowState = reactive<{ + loading: boolean; + changelogMarkdown?: string; + descriptionMarkdown?: string; + usageMarkdown?: string; + outputMarkdown?: string; + parameterSchema?: Record<string, never>; +}>({ + loading: true, +}); + +const workflowExecutionState = reactive<{ + loading: boolean; + errorType?: string; +}>({ + loading: false, + errorType: undefined, +}); + +const showDocumentation = ref<boolean>(true); +let errorToast: Toast | null = null; + +function downloadVersionFiles(repository: string, commit_hash: string) { + workflowState.loading = true; + const repo = GitRepository.buildRepository(repository, commit_hash); + //const descriptionPromise = repo.downloadFile().then((response) => { + // versionState.descriptionMarkdown = response.data; + //}); + Promise.all( + requiredRepositoryFiles.map((file) => + repo.downloadFile(file).then((response) => { + if (file.includes("README")) { + workflowState.descriptionMarkdown = response.data; + } else if (file.includes("CHANGELOG")) { + workflowState.changelogMarkdown = response.data; + } else if (file.includes("schema")) { + workflowState.parameterSchema = response.data; + } else if (file.includes("usage")) { + workflowState.usageMarkdown = response.data; + } else if (file.includes("output")) { + workflowState.outputMarkdown = response.data; + } + }) + ) + ).finally(() => { + workflowState.loading = false; + }); +} + +function startWorkflow( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: Record<string, any>, + notes?: string, + report_output_bucket?: string +) { + if (props.repository && props.commit_hash) { + errorToast?.hide(); + workflowExecutionState.loading = true; + WorkflowExecutionService.workflowExecutionStartArbitraryWorkflow({ + git_commit_hash: props.commit_hash, + parameters: parameters, + report_output_bucket: report_output_bucket, + repository_url: props.repository, + }) + .then(() => { + router.push({ + name: "workflow-executions", + }); + }) + .catch((err) => { + console.error(err); + if (err.body["detail"].includes("workflow execution limit")) { + workflowExecutionState.errorType = "limit"; + } + errorToast?.show(); + }) + .finally(() => { + workflowExecutionState.loading = false; + }); + } +} + +onMounted(() => { + errorToast = new Toast("#arbitraryWorkflowExecutionViewErrorToast"); + if (props.commit_hash && props.repository) { + downloadVersionFiles(props.repository, props.commit_hash); + } +}); +</script> + +<template> + <div class="toast-container position-fixed top-toast end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-danger align-items-center border-0" + data-bs-autohide="true" + id="arbitraryWorkflowExecutionViewErrorToast" + > + <div class="d-flex p-2 justify-content-between align-items-center"> + <div class="toast-body"> + <template v-if="workflowExecutionState.errorType === 'limit'"> + You have too many active workflow executions to start a new one. + </template> + <template v-else> + There was an error with starting the workflow execution. Look in the + console for more information. + </template> + </div> + <button + type="button" + class="btn-close btn-close-white" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> + <div class="row m-1 border-bottom mb-4"> + <h1 class="mb-2">Arbitrary Workflow</h1> + </div> + <h4> + Git Repository: <a target="_blank" :href="repository">{{ repository }}</a> + </h4> + <h4 class="mb-5">Git Commit Hash: {{ commit_hash }}</h4> + <div class="d-flex justify-content-center mb-5" v-if="showDocumentation"> + <a + role="button" + href="#" + class="btn btn-success btn-lg mx-auto fs-4" + :class="{ disabled: !workflowState.parameterSchema }" + @click="showDocumentation = false" + > + <font-awesome-icon icon="fa-solid fa-rocket" class="me-2" /> + <span class="align-middle">Launch</span> + </a> + </div> + <template v-if="repository && commit_hash"> + <workflow-documentation-tabs + v-if="showDocumentation" + :loading="workflowState.loading" + :output-markdown="workflowState.outputMarkdown" + :usage-markdown="workflowState.usageMarkdown" + :description-markdown="workflowState.descriptionMarkdown" + :changelog-markdown="workflowState.changelogMarkdown" + :parameter-schema="workflowState.parameterSchema" + /> + <parameter-schema-form-component + v-else + :loading="workflowExecutionState.loading" + :schema="workflowState.parameterSchema" + @start-workflow="startWorkflow" + /> + </template> + <p v-else>Nope</p> +</template> + +<style scoped></style> diff --git a/src/views/workflows/ListWorkflowExecutionsView.vue b/src/views/workflows/ListWorkflowExecutionsView.vue index a7c4395bd793a7ac4021ffb09ac51ee54f1b5bc1..7f33954c2b86893ff243ad4e025504b591ce3c54 100644 --- a/src/views/workflows/ListWorkflowExecutionsView.vue +++ b/src/views/workflows/ListWorkflowExecutionsView.vue @@ -254,7 +254,6 @@ onMounted(() => { <th scope="col">Status</th> <th scope="col">Started</th> <th scope="col">Ended</th> - <th scope="col">Notes</th> <th scope="col"></th> </tr> </thead> @@ -273,9 +272,6 @@ onMounted(() => { <td class="placeholder-glow" style="width: 15%"> <span class="placeholder col-6"></span> </td> - <td class="placeholder-glow" style="width: 15%"> - <span class="placeholder col-6"></span> - </td> <td class="text-end"> <div class="btn-group btn-group-sm dropdown-center dropdown-menu-start" @@ -335,10 +331,6 @@ onMounted(() => { </template> <template v-else> - </template> </td> - <td class="text-truncate"> - <template v-if="execution.notes">{{ execution.notes }}</template> - <template v-else>-</template> - </td> <td class="text-end"> <div class="btn-group btn-group-sm dropdown-center dropdown-menu-start" diff --git a/src/views/workflows/ListWorkflowsView.vue b/src/views/workflows/ListWorkflowsView.vue index 8c40805a39d88f4eb4b06b1ab2cc371be8e29b4f..bc1198992c275c512ce9f51a236ba4bc8f720ee3 100644 --- a/src/views/workflows/ListWorkflowsView.vue +++ b/src/views/workflows/ListWorkflowsView.vue @@ -6,6 +6,8 @@ import CardTransitionGroup from "@/components/transitions/CardTransitionGroup.vu import dayjs from "dayjs"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import { WorkflowService } from "@/client/workflow"; +import { environment } from "@/environment"; +import ArbitraryWorkflowModal from "@/components/workflows/modals/ArbitraryWorkflowModal.vue"; const workflowsState = reactive<{ loading: boolean; @@ -95,7 +97,7 @@ onMounted(() => { /> </div> </div> - <span class="fs-5 me-3 bla">Sort By</span> + <span class="fs-5 me-3 text-shadow">Sort By</span> <div class="btn-group btn-group-sm w-fit shadow-sm" role="group" @@ -138,6 +140,17 @@ onMounted(() => { class="fs-5 ms-3 cursor-pointer" /> </div> + <div v-if="environment.DEV_SYSTEM" class="d-grid gap-2 col-4 mx-auto"> + <button + class="btn btn-success btn-lg fs-3" + role="button" + data-bs-toggle="modal" + data-bs-target="#arbitraryWorkflowModal" + > + Arbitrary Workflow + </button> + <arbitrary-workflow-modal modal-i-d="arbitraryWorkflowModal" /> + </div> <div v-if="!workflowsState.loading"> <div v-if="workflowsState.workflows.length === 0" @@ -198,7 +211,7 @@ onMounted(() => { </template> <style scoped> -.bla { +.text-shadow { text-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; } </style> diff --git a/src/views/workflows/StartWorkflowView.vue b/src/views/workflows/StartWorkflowView.vue index fc8c681ba372cb3a63515f239530618e3354f18f..f0f9d1f2d1cea3dc245e4ce81c1556e6d8200772 100644 --- a/src/views/workflows/StartWorkflowView.vue +++ b/src/views/workflows/StartWorkflowView.vue @@ -1,9 +1,15 @@ <script setup lang="ts"> import ParameterSchemaFormComponent from "@/components/parameter-schema/ParameterSchemaFormComponent.vue"; import type { WorkflowVersionFull } from "@/client/workflow"; -import { WorkflowVersionService } from "@/client/workflow"; +import { + ApiError, + WorkflowExecutionService, + WorkflowVersionService, +} from "@/client/workflow"; import axios from "axios"; import { onMounted, ref, reactive } from "vue"; +import { useRouter } from "vue-router"; +import { Toast } from "bootstrap"; const props = defineProps<{ versionId: string; @@ -11,14 +17,20 @@ const props = defineProps<{ }>(); const parameterSchema = ref(undefined); +const router = useRouter(); +let errorToast: Toast | null = null; const versionState = reactive<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any parameterSchema?: Record<string, any>; workflowVersion?: WorkflowVersionFull; + loading: boolean; + workflowExecutionError?: string; }>({ parameterSchema: undefined, workflowVersion: undefined, + loading: false, + workflowExecutionError: undefined, }); function downloadVersion() { @@ -39,15 +51,80 @@ function downloadVersionFiles(version: WorkflowVersionFull) { }); } +function startWorkflow( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: Record<string, any>, + notes?: string, + report_output_bucket?: string +) { + if (props.versionId) { + versionState.workflowExecutionError = undefined; + versionState.loading = true; + WorkflowExecutionService.workflowExecutionStartWorkflow({ + workflow_version_id: props.versionId, + parameters: parameters, + notes: notes, + report_output_bucket: report_output_bucket, + }) + .then(() => { + router.push({ + name: "workflow-executions", + }); + }) + .catch((err: ApiError) => { + console.error(err); + if (err.body["detail"].includes("workflow execution limit")) { + versionState.workflowExecutionError = "limit"; + } + errorToast?.show(); + }) + .finally(() => { + versionState.loading = false; + }); + } +} + onMounted(() => { + errorToast = new Toast("#workflowExecutionViewErrorToast"); downloadVersion(); }); </script> <template> + <div class="toast-container position-fixed top-toast end-0 p-3"> + <div + role="alert" + aria-live="assertive" + aria-atomic="true" + class="toast text-bg-danger align-items-center border-0" + data-bs-autohide="true" + id="workflowExecutionViewErrorToast" + > + <div class="d-flex p-2 justify-content-between align-items-center"> + <div class="toast-body"> + <template v-if="versionState.workflowExecutionError === 'limit'"> + You have too many active workflow executions to start a new one. + </template> + <template v-else> + There was an error with starting the workflow execution. Look in the + console for more information. + </template> + </div> + <button + type="button" + class="btn-close btn-close-white" + data-bs-dismiss="toast" + aria-label="Close" + ></button> + </div> + </div> + </div> <parameter-schema-form-component :workflow-version-id="versionId" :schema="parameterSchema" + :loading="versionState.loading" + allow-notes + @start-workflow="startWorkflow" /> </template> diff --git a/src/views/workflows/WorkflowVersionView.vue b/src/views/workflows/WorkflowVersionView.vue index 7dacd5d4d42a9bf3a2c5c6d1d015b82ed42fd8b5..0d4f955cfef1bdb12836df7009e60b8d14122569 100644 --- a/src/views/workflows/WorkflowVersionView.vue +++ b/src/views/workflows/WorkflowVersionView.vue @@ -3,13 +3,11 @@ import { onMounted, reactive, watch } from "vue"; import { WorkflowVersionService } from "@/client/workflow"; import type { WorkflowVersionFull } from "@/client/workflow"; import axios from "axios"; -import MarkdownRenderer from "@/components/MarkdownRenderer.vue"; -import ParameterSchemaDescriptionComponent from "@/components/parameter-schema/ParameterSchemaDescriptionComponent.vue"; +import WorkflowDocumentationTabs from "@/components/workflows/WorkflowDocumentationTabs.vue"; const props = defineProps<{ versionId: string; workflowId: string; - activeTab: string; }>(); const versionState = reactive<{ @@ -100,83 +98,13 @@ onMounted(() => { </script> <template> - <ul class="nav justify-content-evenly nav-tabs fs-5 mb-3"> - <li class="nav-item"> - <router-link - class="nav-link" - aria-current="page" - :to="{ query: { tab: 'description' } }" - :class="{ active: props.activeTab === 'description' }" - >Description - </router-link> - </li> - <li class="nav-item"> - <router-link - class="nav-link" - :to="{ query: { tab: 'usage' } }" - :class="{ active: props.activeTab === 'usage' }" - >Usage - </router-link> - </li> - <li class="nav-item"> - <router-link - class="nav-link" - :to="{ query: { tab: 'parameters' } }" - :class="{ active: props.activeTab === 'parameters' }" - >Parameters - </router-link> - </li> - <li class="nav-item"> - <router-link - class="nav-link" - :to="{ query: { tab: 'output' } }" - :class="{ active: props.activeTab === 'output' }" - >Output - </router-link> - </li> - <li class="nav-item"> - <router-link - class="nav-link" - :to="{ query: { tab: 'changes' } }" - :class="{ active: props.activeTab === 'changes' }" - >Releases - </router-link> - </li> - </ul> - <div v-if="versionState.fileLoading"> - <p class="placeholder-glow mt-2 mb-4"> - <span class="placeholder col-7 fs-1"></span> - </p> - <p - v-for="n in 8" - :key="n" - class="placeholder-glow row ms-1" - :class="'my-' + Math.floor(Math.random() * 6)" - > - <span - class="placeholder" - :class="'col-' + Math.floor(Math.random() * 9 + 2)" - ></span> - </p> - </div> - <div v-else class="px-2"> - <p v-if="props.activeTab === 'description'"> - <markdown-renderer :markdown="versionState.descriptionMarkdown" /> - </p> - <parameter-schema-description-component - v-else-if="props.activeTab === 'parameters'" - :schema="versionState.parameterSchema" - /> - <p v-else-if="props.activeTab === 'changes'"> - <markdown-renderer :markdown="versionState.changelogMarkdown" /> - </p> - <p v-else-if="props.activeTab === 'output'"> - <markdown-renderer :markdown="versionState.outputMarkdown" /> - </p> - <p v-else-if="props.activeTab === 'usage'"> - <markdown-renderer :markdown="versionState.usageMarkdown" /> - </p> - </div> + <workflow-documentation-tabs + :parameter-schema="versionState.parameterSchema" + :loading="versionState.fileLoading" + :changelog-markdown="versionState.changelogMarkdown" + :description-markdown="versionState.descriptionMarkdown" + :usage-markdown="versionState.usageMarkdown" + /> </template> <style scoped></style>