diff --git a/src/assets/main.css b/src/assets/main.css index 30edd8d3f5626056cc2b2d22a7310ba56a3b0457..35ee51cb9f2cd2f76ddd6fbd0e3aedcdda896a5e 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -19,12 +19,10 @@ body { cursor: pointer; } -.helpTextCode > pre { - border: thin solid var(--bs-secondary); - border-radius: var(--bs-border-radius); - background: var(--bs-dark); - filter: brightness(0.9); - padding: 1rem; - margin: 1em 2em; +pre { + backdrop-filter: brightness(1.4); + padding: .5rem; color: var(--bs-code-color); + width: fit-content; + min-width: 50%; } diff --git a/src/client/workflow/models/WorkflowExecutionOut.ts b/src/client/workflow/models/WorkflowExecutionOut.ts index aee95575a55bd45e99690ed1e13ccf7c4c4cb269..8561ab6dba292e57cb31a52c74997303d1e34ac5 100644 --- a/src/client/workflow/models/WorkflowExecutionOut.ts +++ b/src/client/workflow/models/WorkflowExecutionOut.ts @@ -33,5 +33,9 @@ export type WorkflowExecutionOut = { * Status of the workflow execution */ status: WorkflowExecutionStatus; + /** + * Id of the workflow + */ + workflow_id: string; }; diff --git a/src/components/FontAwesomeIcon.vue b/src/components/FontAwesomeIcon.vue index 6c0400dbe5accfc0550be51c54e8c110596252ba..97b3d09bf4f86298c168a006acdb444726a4bb1e 100644 --- a/src/components/FontAwesomeIcon.vue +++ b/src/components/FontAwesomeIcon.vue @@ -1,11 +1,11 @@ <template> - <div + <span class="align-middle" :class="icon" :style="{ color: props.fill, }" - ></div> + ></span> </template> <script setup lang="ts"> diff --git a/src/components/MarkdownRenderer.vue b/src/components/MarkdownRenderer.vue index 05bea01436d8a74b1dfe03ed8b73e2ffc84df890..1833b695aaad4dd7adb0e7968c22d9d6f0e78782 100644 --- a/src/components/MarkdownRenderer.vue +++ b/src/components/MarkdownRenderer.vue @@ -7,7 +7,11 @@ const props = defineProps<{ markdown: string; }>(); -const converter = new showdown.Converter(); +const converter = new showdown.Converter({ + tables: true, + ghCodeBLocks: false, +}); +converter.setFlavor("github"); const outputHtml = computed(() => { const dirtyHTML = converter.makeHtml(props.markdown); return DOMPurify.sanitize(dirtyHTML); @@ -18,4 +22,4 @@ const outputHtml = computed(() => { <div v-html="outputHtml"></div> </template> -<style scoped></style> +<style></style> diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue index 3bb37ed255eb482332b0ac89b7e9f841c4380cac..513a676a266db8a7fd1a621add16c88a224381b9 100644 --- a/src/components/NavbarTop.vue +++ b/src/components/NavbarTop.vue @@ -123,7 +123,11 @@ watch( > </li> <li> - <a class="dropdown-item" href="#">Executions</a> + <router-link + class="dropdown-item" + :to="{ name: 'workflow-executions' }" + >My Executions</router-link + > </li> <li v-if=" diff --git a/src/components/parameter-schema/ParameterSchemaFormComponent.vue b/src/components/parameter-schema/ParameterSchemaFormComponent.vue index 29ebd83f608caf4cf899159fd0380ce9a6301494..a11a9c5ccade26b8e95957fc9cc27463a91b64bd 100644 --- a/src/components/parameter-schema/ParameterSchemaFormComponent.vue +++ b/src/components/parameter-schema/ParameterSchemaFormComponent.vue @@ -8,6 +8,7 @@ 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 // ============================================================================= @@ -21,6 +22,8 @@ const props = defineProps({ }, }); +const router = useRouter(); + // Bootstrap Elements // ============================================================================= let errorToast: Toast | null = null; @@ -142,7 +145,9 @@ function startWorkflow() { report_output_bucket: formState.report_bucket, }) .then(() => { - console.log("Started Workflow"); + router.push({ + name: "workflow-executions", + }); }) .catch((err: ApiError) => { console.error(err); @@ -354,4 +359,8 @@ onMounted(() => { </div> </template> -<style scoped></style> +<style scoped> +div.card-body { + backdrop-filter: brightness(1.2); +} +</style> diff --git a/src/components/parameter-schema/description-mode/ParameterDescription.vue b/src/components/parameter-schema/description-mode/ParameterDescription.vue index 0c2371d0f042e31b87d79e416b5c94bad70fad70..ab9b7f58d1fd110a7c3952affacd469842080dc8 100644 --- a/src/components/parameter-schema/description-mode/ParameterDescription.vue +++ b/src/components/parameter-schema/description-mode/ParameterDescription.vue @@ -131,6 +131,6 @@ li:hover { background: var(--bs-secondary); } a:hover { - filter: brightness(0.8); + filter: brightness(1.2); } </style> diff --git a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue index c68eb662a236b5fbaf1583530267492f189cb7ed..f399ddb88d4ab8f578d96cbae02cd4bbcef903a6 100644 --- a/src/components/parameter-schema/form-mode/ParameterGroupForm.vue +++ b/src/components/parameter-schema/form-mode/ParameterGroupForm.vue @@ -176,7 +176,7 @@ watch( <style scoped> div.card-body { - filter: brightness(0.9); + backdrop-filter: brightness(1.2); } span.cursor-pointer:hover { color: var(--bs-info); diff --git a/src/router/index.ts b/src/router/index.ts index c52fe14c0f359ba4979f022292602f2ead1aab12..7b9d1ff72eb4e38b6d9cf9e8156bd2af77f75545 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -28,6 +28,12 @@ const router = createRouter({ name: "s3_keys", component: () => import("../views/object-storage/S3KeysView.vue"), }, + { + path: "workflow-executions", + name: "workflow-executions", + component: () => + import("../views/workflows/ListWorkflowExecutionsView.vue"), + }, { path: "workflows", name: "workflows", diff --git a/src/views/workflows/ListWorkflowExecutionsView.vue b/src/views/workflows/ListWorkflowExecutionsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..2ad5c43b8807c70c3e81721582da093f4b42303d --- /dev/null +++ b/src/views/workflows/ListWorkflowExecutionsView.vue @@ -0,0 +1,367 @@ +<script setup lang="ts"> +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import { onMounted, reactive, computed } from "vue"; +import type { WorkflowExecutionOut } from "@/client/workflow"; +import { useAuthStore } from "@/stores/auth"; +import { + WorkflowExecutionService, + WorkflowExecutionStatus, + WorkflowService, + WorkflowVersionService, +} from "@/client/workflow"; +import dayjs from "dayjs"; +import DeleteModal from "@/components/modals/DeleteModal.vue"; + +const userStore = useAuthStore(); + +const executionsState = reactive<{ + workflowMapping: Record<string, string>; + versionMapping: Record<string, string>; + workflowExecutions: WorkflowExecutionOut[]; + loading: boolean; + mappingLoading: boolean; + executionToDelete?: WorkflowExecutionOut; +}>({ + workflowMapping: {}, + versionMapping: {}, + workflowExecutions: [], + loading: true, + mappingLoading: true, + executionToDelete: undefined, +}); + +const statusToColorMapping = { + PENDING: "bg-warning", + SCHEDULED: "bg-warning", + RUNNING: "bg-info", + CANCELED: "bg-danger", + SUCCESS: "bg-success", + ERROR: "bg-danger", +}; + +const statusToIconMapping = { + PENDING: "fa-solid fa-circle-pause", + SCHEDULED: "fa-solid fa-circle-pause", + RUNNING: "fa-solid fa-circle-play", + CANCELED: "fa-solid fa-circle-xmark", + SUCCESS: "fa-solid fa-circle-check", + ERROR: "fa-solid fa-circle-xmark", +}; + +const sortedExecutions = computed<WorkflowExecutionOut[]>(() => { + const tempList = [...executionsState.workflowExecutions]; + tempList.sort((a, b) => { + // sort by start time descending + return dayjs(a.start_time).isBefore(dayjs(b.start_time)) ? 1 : -1; + }); + return tempList; +}); + +const deleteModalString = computed<string>(() => { + if (executionsState.executionToDelete === undefined) { + return ""; + } else if ( + !executionsState.executionToDelete.workflow_version_id || + !executionsState.executionToDelete.workflow_id + ) { + return `Workflow Execution from ${dayjs( + executionsState.executionToDelete.start_time + ).format("DD.MM.YYYY HH:mm")}`; + } else { + return `Workflow Execution ${ + executionsState.workflowMapping[ + executionsState.executionToDelete.workflow_id + ] + }@${ + executionsState.versionMapping[ + executionsState.executionToDelete.workflow_version_id + ] + } from ${dayjs(executionsState.executionToDelete.start_time).format( + "DD.MM.YYYY HH:mm" + )}`; + } +}); + +// Functions +// ----------------------------------------------------------------------------- +function updateExecutions() { + const listExecutionsPromise = + WorkflowExecutionService.workflowExecutionListWorkflowExecutions( + userStore.currentUID + ) + .then((executions) => { + executionsState.workflowExecutions = executions; + return executions; + }) + .finally(() => { + executionsState.loading = false; + }); + listExecutionsPromise // construct mapping from workflow id to workflow name + .then((executions) => + Promise.all( + // group all calls to the API + executions + .filter((execution) => execution.workflow_id) // filter undefined workflows + .filter( + (execution) => + !executionsState.workflowMapping[execution.workflow_id] + ) + .filter( + // filter unique workflows + (execution, index, array) => + array.findIndex( + (val) => val.workflow_id === execution.workflow_id + ) === index + ) + .map((execution) => + WorkflowService.workflowGetWorkflow(execution.workflow_id) + ) + ) + ) + .then((workflows) => + workflows.forEach((workflow) => { + executionsState.workflowMapping[workflow.workflow_id] = workflow.name; + }) + ) + .finally(() => { + executionsState.mappingLoading = false; + }); + listExecutionsPromise // construct mapping from version id to clear text version + .then((executions) => + Promise.all( + // group all calls to the API + executions + .filter( + // filter undefined workflow versions + (execution) => + execution.workflow_id && execution.workflow_version_id + ) + .filter( + // filter already seen workflow versions + (version) => !executionsState.versionMapping[version.workflow_id] + ) + .filter( + // filter unique workflow versions + (execution, index, array) => + array.findIndex( + (val) => + val.workflow_version_id === execution.workflow_version_id + ) === index + ) + .map((execution) => + WorkflowVersionService.workflowVersionGetWorkflowVersion( + execution.workflow_version_id, + execution.workflow_id + ) + ) + ) + ) + .then((versions) => + versions.forEach((version) => { + executionsState.versionMapping[version.git_commit_hash] = + version.version; + }) + ); +} + +function workflowExecutionDeletable(status: WorkflowExecutionStatus): boolean { + return [ + WorkflowExecutionStatus.ERROR, + WorkflowExecutionStatus.CANCELED, + WorkflowExecutionStatus.SUCCESS, + ].includes(status); +} +function workflowExecutionCancable(status: WorkflowExecutionStatus): boolean { + return [ + WorkflowExecutionStatus.RUNNING, + WorkflowExecutionStatus.PENDING, + WorkflowExecutionStatus.SCHEDULED, + ].includes(status); +} + +function deleteWorkflowExecution(executionId?: string) { + if (executionId) { + WorkflowExecutionService.workflowExecutionDeleteWorkflowExecution( + executionId + ).then(() => { + executionsState.workflowExecutions = + executionsState.workflowExecutions.filter( + (execution) => execution.execution_id !== executionId + ); + }); + } +} + +function cancelWorkflowExecution(executionId: string) { + WorkflowExecutionService.workflowExecutionCancelWorkflowExecution( + executionId + ).then(() => { + const index = executionsState.workflowExecutions.findIndex( + (execution) => execution.execution_id === executionId + ); + if (index > -1) { + executionsState.workflowExecutions[index].status = + WorkflowExecutionStatus.CANCELED; + executionsState.workflowExecutions[index].end_time = + dayjs().toISOString(); + } + }); +} + +onMounted(() => { + updateExecutions(); +}); +</script> + +<template> + <delete-modal + modal-i-d="deleteWorkflowExecutionModal" + :object-name-delete="deleteModalString" + @confirm-delete=" + deleteWorkflowExecution(executionsState.executionToDelete?.execution_id) + " + /> + <div + class="row m-2 border-bottom border-light mb-4 justify-content-between align-items-center" + > + <h1 class="mb-2 text-light w-fit">My Workflow Executions</h1> + <router-link :to="{ name: 'workflows' }" class="btn btn-primary w-fit" + >Start Workflow Execution</router-link + > + </div> + <table + class="table table-dark table-striped table-hover caption-top align-middle" + > + <caption> + Displaying + {{ + executionsState.workflowExecutions.length + }} + Workflow Execution + </caption> + <thead> + <tr> + <th scope="col">Workflow</th> + <th scope="col">Status</th> + <th scope="col">Started</th> + <th scope="col">Ended</th> + <th scope="col"></th> + </tr> + </thead> + <tbody> + <template v-if="executionsState.loading"> + <tr v-for="n in 5" :key="n"> + <td class="placeholder-glow w-25"> + <span class="placeholder col-6"></span> + </td> + <td class="placeholder-glow" style="width: 20%"> + <span class="placeholder col-4"></span> + </td> + <td class="placeholder-glow" style="width: 20%"> + <span class="placeholder col-6"></span> + </td> + <td class="placeholder-glow" style="width: 20%"> + <span class="placeholder col-6"></span> + </td> + <td class="text-end"> + <div + class="btn-group btn-group-sm dropdown-center dropdown-menu-start" + > + <button type="button" class="btn btn-secondary" disabled> + Details + </button> + <button + type="button" + class="btn btn-secondary dropdown-toggle dropdown-toggle-split" + disabled + > + <span class="visually-hidden">Toggle Dropdown</span> + </button> + </div> + </td> + </tr> + </template> + <template v-else-if="executionsState.workflowExecutions.length > 0"> + <tr v-for="execution in sortedExecutions" :key="execution.execution_id"> + <td + v-if="executionsState.mappingLoading" + class="placeholder-glow w-25" + > + <span class="placeholder col-6"></span> + </td> + <td v-else> + <span>{{ + executionsState.workflowMapping[execution.workflow_id] + }}</span> + <span + >@{{ + executionsState.versionMapping[execution.workflow_version_id] + }}</span + > + </td> + <td> + <span + class="rounded-pill py-1 px-2" + :class="statusToColorMapping[execution.status]" + ><font-awesome-icon + class="me-2" + :icon="statusToIconMapping[execution.status]" + />{{ execution.status }}</span + > + </td> + <td>{{ dayjs(execution.start_time).format("DD.MM.YYYY HH:mm") }}</td> + <td> + <template v-if="execution.end_time"> + {{ dayjs(execution.end_time).format("DD.MM.YYYY HH:mm") }} + </template> + <template v-else> - </template> + </td> + <td class="text-end"> + <div + class="btn-group btn-group-sm dropdown-center dropdown-menu-start" + > + <button type="button" class="btn btn-secondary">Details</button> + <button + type="button" + class="btn btn-secondary dropdown-toggle dropdown-toggle-split" + data-bs-toggle="dropdown" + aria-expanded="false" + > + <span class="visually-hidden">Toggle Dropdown</span> + </button> + <ul class="dropdown-menu dropdown-menu-dark"> + <li v-if="workflowExecutionCancable(execution.status)"> + <button + class="dropdown-item text-danger align-middle" + type="button" + @click="cancelWorkflowExecution(execution.execution_id)" + > + <font-awesome-icon icon="fa-solid fa-ban" /> + <span class="ms-1">Cancel</span> + </button> + </li> + <li v-if="workflowExecutionDeletable(execution.status)"> + <button + class="dropdown-item text-danger align-middle" + type="button" + data-bs-toggle="modal" + data-bs-target="#deleteWorkflowExecutionModal" + @click="executionsState.executionToDelete = execution" + > + <font-awesome-icon icon="fa-solid fa-trash" /> + <span class="ms-1">Delete</span> + </button> + </li> + </ul> + </div> + </td> + </tr> + </template> + <tr v-else> + <td colspan="5" class="text-center"><i>No workflow executions</i></td> + </tr> + </tbody> + </table> +</template> + +<style scoped></style>