diff --git a/package-lock.json b/package-lock.json index 1fbb529ab3d63262efcaf531b627c27aea02b72c..84cd347bf64f7a473aeb5d4b0b5f48aa394d2c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@popperjs/core": "^2.11.6", "ajv": "^8.12.0", "bootstrap": "^5.2.3", + "chart.js": "^4.2.1", + "chartjs-plugin-zoom": "^2.0.1", "dayjs": "^1.11.7", "dompurify": "^3.0.1", "filesize": "^10.0.6", @@ -1945,6 +1947,11 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2776,6 +2783,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz", + "integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": "^7.0.0" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz", + "integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==", + "dependencies": { + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3798,6 +3827,14 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", @@ -7467,6 +7504,11 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8079,6 +8121,22 @@ "supports-color": "^7.1.0" } }, + "chart.js": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.2.1.tgz", + "integrity": "sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chartjs-plugin-zoom": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz", + "integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==", + "requires": { + "hammerjs": "^2.0.8" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -8837,6 +8895,11 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==" + }, "handlebars": { "version": "4.7.7", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", diff --git a/package.json b/package.json index 73745f3dd2fc5d04bd052bceb91e5612c2c83859..79e413ff7c1567c8b64c05e1257278c7dad62d30 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@popperjs/core": "^2.11.6", "ajv": "^8.12.0", "bootstrap": "^5.2.3", + "chart.js": "^4.2.1", + "chartjs-plugin-zoom": "^2.0.1", "dayjs": "^1.11.7", "dompurify": "^3.0.1", "filesize": "^10.0.6", diff --git a/src/client/workflow/index.ts b/src/client/workflow/index.ts index c01584ca7ac167ea173c21d967c7f1bab95e8c83..cb6c872427f3cb7150fc2c4688b974cc40e20c42 100644 --- a/src/client/workflow/index.ts +++ b/src/client/workflow/index.ts @@ -17,6 +17,7 @@ export type { WorkflowExecutionIn } from './models/WorkflowExecutionIn'; export type { WorkflowExecutionOut } from './models/WorkflowExecutionOut'; export { WorkflowExecutionStatus } from './models/WorkflowExecutionStatus'; export type { WorkflowOut } from './models/WorkflowOut'; +export type { WorkflowStatistic } from './models/WorkflowStatistic'; export type { WorkflowVersionFull } from './models/WorkflowVersionFull'; export type { WorkflowVersionReduced } from './models/WorkflowVersionReduced'; export type { WorkflowVersionStatus } from './models/WorkflowVersionStatus'; diff --git a/src/client/workflow/models/WorkflowStatistic.ts b/src/client/workflow/models/WorkflowStatistic.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9211ec655b249f38790bed6eadf850e6465b7dc --- /dev/null +++ b/src/client/workflow/models/WorkflowStatistic.ts @@ -0,0 +1,15 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type WorkflowStatistic = { + /** + * Day of the datapoint + */ + day: string; + /** + * Number of started workflows on that day + */ + count: number; +}; + diff --git a/src/client/workflow/services/WorkflowService.ts b/src/client/workflow/services/WorkflowService.ts index d54923bfaedc72a2a47454d860926c0920b43bc6..45f9ffa369494568f828b205f6c156855d2096e4 100644 --- a/src/client/workflow/services/WorkflowService.ts +++ b/src/client/workflow/services/WorkflowService.ts @@ -5,6 +5,7 @@ import type { Body_Workflow_create_workflow } from '../models/Body_Workflow_crea import type { Body_Workflow_update_workflow } from '../models/Body_Workflow_update_workflow'; import type { Status } from '../models/Status'; import type { WorkflowOut } from '../models/WorkflowOut'; +import type { WorkflowStatistic } from '../models/WorkflowStatistic'; import type { WorkflowVersionFull } from '../models/WorkflowVersionFull'; import type { WorkflowVersionStatus } from '../models/WorkflowVersionStatus'; @@ -50,7 +51,7 @@ export class WorkflowService { /** * Create a new workflow * Create a new workflow. - * + * R * Permission "workflow:create" required. * @param formData * @returns WorkflowOut Successful Response @@ -132,6 +133,31 @@ export class WorkflowService { }); } + /** + * Get statistics for a workflow + * Get the number of started workflow per day. + * @param wid ID of a workflow + * @returns WorkflowStatistic Successful Response + * @throws ApiError + */ + public static workflowGetWorkflowStatistics( + wid: string, + ): CancelablePromise<Array<WorkflowStatistic>> { + return __request(OpenAPI, { + method: 'GET', + url: '/workflows/{wid}/statistics', + path: { + 'wid': wid, + }, + errors: { + 400: `Error decoding JWT Token`, + 403: `Not authenticated`, + 404: `Entity not Found`, + 422: `Validation Error`, + }, + }); + } + /** * Update a workflow * Create a new workflow version. diff --git a/src/components/workflows/WorkflowStatisticsChart.vue b/src/components/workflows/WorkflowStatisticsChart.vue new file mode 100644 index 0000000000000000000000000000000000000000..9572134c4e7c116b1c0e846ff53cefcf3fe6c140 --- /dev/null +++ b/src/components/workflows/WorkflowStatisticsChart.vue @@ -0,0 +1,274 @@ +<script setup lang="ts"> +import { + Chart, + BarElement, + BarController, + LinearScale, + TimeSeriesScale, + Tooltip, + LineController, + LineElement, + PointElement, +} from "chart.js"; +import "@/utils/DayjsAdapter"; +import type { Element as ChartElement, Point, ChartItem } from "chart.js"; +import { onMounted, ref, watch, computed } from "vue"; +import type { WorkflowStatistic } from "@/client/workflow"; +import dayjs from "dayjs"; + +const canvas = ref<ChartItem | undefined>(undefined); + +let chart: Chart | undefined; + +const props = defineProps<{ + stats: WorkflowStatistic[]; +}>(); + +const disableZoomReset = ref<boolean>(true); + +// register all relevant elements to enable tree shaking +Chart.register( + BarElement, + BarController, + LinearScale, + Tooltip, + TimeSeriesScale, + LineController, + LineElement, + PointElement +); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +Tooltip.positioners.top = (elements: ChartElement[], eventPosition: Point) => { + return { + x: eventPosition.x, + y: 0, + }; +}; + +function updateData(chart: Chart, newStats: WorkflowStatistic[]) { + // override old labels in the chart + chart.data.labels = newStats.map((stat) => stat.day); + // override old count per day in the chart + chart.data.datasets[0].data = newStats.map((stat) => stat.count); + // override old cumulative sum in the chart + chart.data.datasets[1].data = newStats.map( + ( + (sum) => (value) => + (sum += value.count) + )(0) + ); + // render new chart + chart.update(); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + chart.options.animation = true; // enable animations for zoom +} + +function resetZoom() { + if (chart) { + chart.resetZoom(); + } +} + +// pad or trim stats to one year +const paddedStats = computed<WorkflowStatistic[]>(() => { + const lastYear = dayjs().subtract(1, "year"); + // Find index in stats list where the stat is from a day after the same day last year + let statsIndex = props.stats.findIndex((stat) => + lastYear.isSameOrBefore(dayjs(stat.day), "day") + ); + if (statsIndex < 0) { + statsIndex = 1; + } + // Pad or trim workflow statistics to exactly one year + return Array.from({ length: 365 }, (e, i) => { + let count = 0; + const day = dayjs() + .subtract(365 - i, "day") + .format("YYYY-MM-DD"); + if ( + statsIndex < props.stats.length && + props.stats[statsIndex].day === day + ) { + count = props.stats[statsIndex].count; + statsIndex++; + } + return { + day: day, + count: count, + }; + }); +}); + +watch(paddedStats, (newStats) => { + if (chart) { + updateData(chart, newStats); + } +}); + +onMounted(() => { + if (canvas.value) { + chart = new Chart(canvas.value, { + type: "bar", + data: { + labels: [], // Days as lables + datasets: [ + { + // Workflow count per day + label: "Workflow Count", + data: [], + type: "bar", + yAxisID: "y1", + }, + { + // Cumulative Workflow count over the year + label: "Cumulative Workflow Usage", + data: [], + type: "line", + yAxisID: "y2", + cubicInterpolationMode: "monotone", + tension: 0.4, + pointStyle: false, + }, + ], + }, + options: { + animation: false, // disable animations + maintainAspectRatio: false, // fill all given space + responsive: true, // fill all given space + scales: { + // left scale for count per day + y1: { + position: "left", + grid: { + display: false, // disable grid + }, + ticks: { + precision: 0, // round ticks to zero decimal places + }, + // title options + title: { + color: "gray", + display: true, + text: "per Day", + }, + }, + y2: { + // right scale for cumulative count + position: "right", + grid: { + display: false, // disable grid + }, + ticks: { + precision: 0, // round ticks to zero decimal places + }, + // title options + title: { + color: "gray", + display: true, + text: "Cumulative", + }, + }, + x: { + // x scale as time-series + type: "timeseries", + time: { + // options for timescale + unit: "month", // display unit + parser: "YYYY-MM-DD", // format of the data to parse from + tooltipFormat: "MMM DD, YYYY", // format of date in tooltip + }, + ticks: { + align: "start", // align ticks labels + }, + grid: { + display: false, // disable grid + }, + }, + }, + plugins: { + zoom: { + pan: { + enabled: true, // enable panning + modifierKey: "ctrl", // press ctrl key to enable panning + mode: "x", // pan only in x direction + }, + zoom: { + drag: { + enabled: true, // enable zoom + }, + mode: "x", // zoom only in x direction + onZoomComplete: (tempChart: { chart: Chart }) => { + // update x axis label scale depending on zoom level + if (tempChart.chart.options.scales?.x) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + tempChart.chart.options.scales.x.time.unit = + tempChart.chart.getZoomLevel() > 3.5 ? "week" : "month"; + tempChart.chart.update(); + disableZoomReset.value = !tempChart.chart.isZoomedOrPanned(); + } + }, + }, + limits: { + // limit for zooming in x direction + x: { + minRange: 2419200000, // minimum range one month in milliseconds 1000*60*60*24*7*4 + min: "original", // min x value from original scale + max: "original", // max x value from original scale + }, + y2: { + min: "original", // min y value from original scale + max: "original", // max y value from original scale + }, + }, + }, + tooltip: { + enabled: true, // enable tooltips + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + position: "top", // position tooltip on top of canvas + backgroundColor: "rgba(108, 117, 125, 0.9)", // greyish background color + yAlign: "bottom", // Position of the tooltip caret in the Y direction + }, + }, + }, + }); + if (paddedStats.value.length > 0) { + updateData(chart, paddedStats.value); + } + } +}); +</script> + +<template> + <div class="w-100 row"> + <span class="mx-auto w-fit fw-bold">Workflow Statistics</span> + </div> + <div class="d-flex align-items-center mb-4"> + <div + class="d-flex flex-column justify-content-evenly align-items-center me-2 pb-2" + style="height: 150px" + > + <div> + {{ dayjs().subtract(1, "year").format("MMM YYYY") }} - + {{ dayjs().format("MMM YYYY") }} + </div> + <button + type="button" + class="btn btn-outline-secondary me-2" + @click="resetZoom" + :disabled="disableZoomReset" + > + Reset Zoom + </button> + </div> + <div style="max-height: 150px" class="flex-fill"> + <canvas ref="canvas"></canvas> + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/main.ts b/src/main.ts index d7f01ccf5b60a0385a844eaa5d182411de94200f..ccbaf6cb953600152661909bbee71d6490166e61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,12 +3,18 @@ import { createPinia } from "pinia"; import App from "./App.vue"; import router from "./router"; +import { Chart, Colors } from "chart.js"; +import zoomPlugin from "chartjs-plugin-zoom"; + +Chart.register(zoomPlugin, Colors); import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; // import plugin +import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import "dayjs/locale/en-gb"; dayjs.extend(relativeTime); // use plugin +dayjs.extend(isSameOrBefore); import "bootstrap/dist/css/bootstrap.css"; import "@fortawesome/fontawesome-free/css/fontawesome.css"; diff --git a/src/utils/DayjsAdapter.ts b/src/utils/DayjsAdapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..086b6d0b092545f86a362faad8ab98ae6c42d9f2 --- /dev/null +++ b/src/utils/DayjsAdapter.ts @@ -0,0 +1,99 @@ +import { _adapters } from "chart.js"; + +import dayjs from "dayjs"; +import type { QUnitType } from "dayjs"; + +import type { TimeUnit } from "chart.js"; + +// Needed to handle the custom parsing +import CustomParseFormat from "dayjs/plugin/customParseFormat.js"; + +// Needed to handle quarter format +import AdvancedFormat from "dayjs/plugin/advancedFormat.js"; + +// Needed to handle adding/subtracting quarter +import QuarterOfYear from "dayjs/plugin/quarterOfYear.js"; + +// Needed to handle localization format +import LocalizedFormat from "dayjs/plugin/localizedFormat.js"; + +import isoWeek from "dayjs/plugin/isoWeek.js"; + +dayjs.extend(AdvancedFormat); + +dayjs.extend(QuarterOfYear); + +dayjs.extend(LocalizedFormat); + +dayjs.extend(CustomParseFormat); + +dayjs.extend(isoWeek); + +const FORMATS = { + datetime: "MMM D, YYYY, h:mm:ss a", + millisecond: "h:mm:ss.SSS a", + second: "h:mm:ss a", + minute: "h:mm a", + hour: "hA", + day: "MMM D", + week: "ll", + month: "MMM YYYY", + quarter: "[Q]Q - YYYY", + year: "YYYY", +}; + +_adapters._date.override({ + //_id: 'dayjs', //DEBUG, + formats: () => FORMATS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parse: function (value: any, format?: TimeUnit) { + const valueType = typeof value; + + if (value === null || valueType === "undefined") { + return null; + } + + if (valueType === "string" && typeof format === "string") { + return dayjs(value, format).isValid() + ? dayjs(value, format).valueOf() + : null; + } else if (!(value instanceof dayjs)) { + return dayjs(value).isValid() ? dayjs(value).valueOf() : null; + } + return null; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + format: function (time: any, format: TimeUnit): string { + return dayjs(time).format(format); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + add: function (time: any, amount: number, unit: QUnitType & TimeUnit) { + return dayjs(time).add(amount, unit).valueOf(); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + diff: function (max: any, min: any, unit: TimeUnit) { + return dayjs(max).diff(dayjs(min), unit); + }, + startOf: function ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + time: any, + unit: (TimeUnit & QUnitType) | "isoWeek", + weekday?: number + ) { + if (unit === "isoWeek") { + // Ensure that weekday has a valid format + //const formattedWeekday + + const validatedWeekday: number = + typeof weekday === "number" && weekday > 0 && weekday < 7 ? weekday : 1; + + return dayjs(time).isoWeekday(validatedWeekday).startOf("day").valueOf(); + } + + return dayjs(time).startOf(unit).valueOf(); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + endOf: function (time: any, unit: TimeUnit & QUnitType) { + return dayjs(time).endOf(unit).valueOf(); + }, +}); diff --git a/src/views/workflows/WorkflowView.vue b/src/views/workflows/WorkflowView.vue index 583efb68d2fbec43f11752e7d306772c96613a1e..ad6394d5867275a4d771db3e4f44efd9353a253c 100644 --- a/src/views/workflows/WorkflowView.vue +++ b/src/views/workflows/WorkflowView.vue @@ -1,6 +1,11 @@ <script setup lang="ts"> import { computed, onMounted, reactive, watch } from "vue"; -import type { WorkflowOut, WorkflowVersionReduced } from "@/client/workflow"; +import type { + WorkflowOut, + WorkflowStatistic, + WorkflowVersionReduced, +} from "@/client/workflow"; +import WorkflowStatisticsChart from "@/components/workflows/WorkflowStatisticsChart.vue"; import { Status, WorkflowService, @@ -36,11 +41,13 @@ const workflowState = reactive<{ workflow?: WorkflowOut; activeVersionId: string; initialOpen: boolean; + stats: WorkflowStatistic[]; }>({ loading: true, workflow: undefined, activeVersionId: "", initialOpen: true, + stats: [], }); // Watchers @@ -140,6 +147,9 @@ function updateWorkflow(workflowId: string) { workflowState.loading = false; workflowState.initialOpen = false; }); + WorkflowService.workflowGetWorkflowStatistics(workflowId).then((stats) => { + workflowState.stats = stats; + }); } function deprecateCurrentWorkflowVersion() { @@ -186,7 +196,7 @@ onMounted(() => { <span class="placeholder col-3 mx-auto"></span> </div> </div> - <div v-else-if="workflowState.workflow != null"> + <div v-else-if="workflowState.workflow"> <div class="d-flex justify-content-between align-items-center"> <div class="fs-0 w-fit text-light"> {{ workflowState.workflow.name }} @@ -209,7 +219,7 @@ onMounted(() => { > This version can not be used. <router-link - v-if="latestVersion != null" + v-if="latestVersion" class="alert-link" :to="{ name: 'workflow-version', @@ -218,7 +228,7 @@ onMounted(() => { }, query: { tab: route.query.tab }, }" - >Try the latest version {{ latestVersion?.version }}.</router-link + >Try the latest version {{ latestVersion.version }}.</router-link > </div> <div class="row align-items-center"> @@ -248,7 +258,7 @@ onMounted(() => { <span class="align-middle">Launch {{ activeVersionString }}</span> </router-link> <div - v-if="latestVersion != null" + v-if="latestVersion" class="input-group w-fit position-absolute end-0" > <span class="input-group-text px-2" id="workflow-version-wrapping" @@ -261,9 +271,7 @@ onMounted(() => { v-model="workflowState.activeVersionId" > <option - v-for="version in sortedVersions( - workflowState.workflow?.versions - )" + v-for="version in sortedVersions(workflowState.workflow.versions)" :key="version.git_commit_hash" :value="version.git_commit_hash" > @@ -284,9 +292,13 @@ onMounted(() => { ></a > </div> + <workflow-statistics-chart + :stats="workflowState.stats" + v-if="workflowState.stats" + /> </template> </div> - <router-view v-if="workflowState.loading || workflowState.workflow != null" /> + <router-view v-if="workflowState.loading || workflowState.workflow" /> <div v-else class="text-center fs-1 mt-5"> <font-awesome-icon icon="fa-solid fa-magnifying-glass"