diff --git a/src/components/tasks/BaseTaskFilterControl.vue b/src/components/tasks/BaseTaskFilterControl.vue
new file mode 100644
index 000000000..d10bd52a5
--- /dev/null
+++ b/src/components/tasks/BaseTaskFilterControl.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+ {{ label }}
+ mdi-chevron-down
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/tasks/PeriodFilterControl.vue b/src/components/tasks/PeriodFilterControl.vue
new file mode 100644
index 000000000..1a43dd440
--- /dev/null
+++ b/src/components/tasks/PeriodFilterControl.vue
@@ -0,0 +1,70 @@
+
+
+
+
diff --git a/src/components/tasks/TaskRunProgress.vue b/src/components/tasks/TaskRunProgress.vue
new file mode 100644
index 000000000..9ea311984
--- /dev/null
+++ b/src/components/tasks/TaskRunProgress.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+ {{ details }}
+
+
+
diff --git a/src/components/tasks/TaskRunSummary.vue b/src/components/tasks/TaskRunSummary.vue
new file mode 100644
index 000000000..75affc40a
--- /dev/null
+++ b/src/components/tasks/TaskRunSummary.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+ {{ workflow.name }}
+
+
Scheduled
+
{{ task.userId }}
+
+
+
+ {{ task.description }}
+
+
+
+
+ {{ statusString }}
+
+
+
+
+
+
T0
+
{{ timeZeroString }}
+
+
+
Dispatched
+
{{ dispatchTimeString }}
+
+
+
Expected completion
+
{{ expectedCompletionTimeString }}
+
+
+
Completed
+
{{ completionTimeString }}
+
+
+
+
+
+
+
diff --git a/src/components/tasks/TaskRunsPanel.vue b/src/components/tasks/TaskRunsPanel.vue
new file mode 100644
index 000000000..6bebc9957
--- /dev/null
+++ b/src/components/tasks/TaskRunsPanel.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ No tasks available
+
+
+
+
+
+
+
Last updated: {{ lastUpdatedString }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/tasks/TaskStatusFilterControl.vue b/src/components/tasks/TaskStatusFilterControl.vue
new file mode 100644
index 000000000..cdf9ec34c
--- /dev/null
+++ b/src/components/tasks/TaskStatusFilterControl.vue
@@ -0,0 +1,92 @@
+
+
+
+
+
+ {{ getTaskStatusCategoryName(category) }}
+
+
+
+
+
+
diff --git a/src/components/tasks/WorkflowFilterControl.vue b/src/components/tasks/WorkflowFilterControl.vue
new file mode 100644
index 000000000..73d97d5e0
--- /dev/null
+++ b/src/components/tasks/WorkflowFilterControl.vue
@@ -0,0 +1,31 @@
+
+
+
+
diff --git a/src/layouts/DefaultLayout.vue b/src/layouts/DefaultLayout.vue
index e2e74376b..73434d2cf 100644
--- a/src/layouts/DefaultLayout.vue
+++ b/src/layouts/DefaultLayout.vue
@@ -11,6 +11,7 @@
+
@@ -144,9 +145,14 @@
-
-
-
+
current.category === category,
+ )
+ if (!definition) {
+ throw new Error(`No definition for task status category "${category}".`)
+ }
+ return definition.name
+}
+
+export function getTaskStatusCategory(status: TaskStatus): TaskStatusCategory {
+ const definition = STATUS_CATEGORIES.find((current) =>
+ current.statuses.includes(status),
+ )
+ if (!definition) {
+ throw new Error(
+ `Task status "${status}" is not in any task status category.`,
+ )
+ }
+ return definition.category
+}
+
+export function getCompleteTaskStatusCategories(
+ statuses: TaskStatus[],
+): TaskStatusCategory[] {
+ const completeCategories: TaskStatusCategory[] = []
+ for (const category of STATUS_CATEGORIES) {
+ // If all statuses in this category are in the specified array, the category
+ // is defined "complete" and included.
+ if (category.statuses.every((status) => statuses.includes(status))) {
+ completeCategories.push(category.category)
+ }
+ }
+ return completeCategories
+}
+
+export function getTaskStatusesForCategory(
+ selectedCategory: TaskStatusCategory,
+): TaskStatus[] {
+ const category = STATUS_CATEGORIES.find(
+ (current) => current.category === selectedCategory,
+ )
+ return category?.statuses ?? []
+}
diff --git a/src/lib/taskruns/types.ts b/src/lib/taskruns/types.ts
new file mode 100644
index 000000000..239f1a1eb
--- /dev/null
+++ b/src/lib/taskruns/types.ts
@@ -0,0 +1,32 @@
+export enum TaskStatus {
+ Invalid = 'invalid',
+ Pending = 'pending',
+ Terminated = 'terminated',
+ Running = 'running',
+ Failed = 'failed',
+ CompletedFullySuccessful = 'completed fully successful',
+ CompletePartlySuccessful = 'completed partly successful',
+ Approved = 'approved',
+ ApprovedPartlySuccessful = 'approved partly successful',
+ Amalgamated = 'amalgamated',
+ PartlyCompleted = 'partly completed',
+}
+
+export enum TaskStatusCategory {
+ Pending = 'pending',
+ Running = 'running',
+ Completed = 'completed',
+ Failed = 'failed',
+}
+
+export interface TaskRun {
+ taskId: string
+ workflowId: string
+ status: TaskStatus
+ description: string | null
+ timeZeroTimestamp: number
+ dispatchTimestamp: number | null
+ completionTimestamp: number | null
+ userId: string | null
+ isScheduled: boolean
+}
diff --git a/src/lib/timer/create.ts b/src/lib/timer/create.ts
new file mode 100644
index 000000000..0fdfa918c
--- /dev/null
+++ b/src/lib/timer/create.ts
@@ -0,0 +1,44 @@
+import { Timer } from './types'
+
+export function createTimer(
+ callback: () => void,
+ intervalSeconds: number,
+ immediate: boolean,
+): Timer {
+ // Evaluate callback immediately, if so requested.
+ if (immediate) {
+ callback()
+ }
+
+ const createInterval = () => setInterval(callback, intervalSeconds * 1000)
+ let id = createInterval()
+
+ // Intervals are paused when a browser tab/window is inactive, so we add a
+ // listener to the window focus event to check whether a refresh is overdue
+ // if the window becomes active again.
+ let lastUpdatedTimestamp: number = Date.now()
+ const onFocus = () => {
+ const now = Date.now()
+ if (now - lastUpdatedTimestamp > intervalSeconds * 1000) {
+ // Call callback and update last updated time.
+ callback()
+ lastUpdatedTimestamp = now
+ // Reset interval so it starts counting from now, otherwise we may
+ // update too often.
+ clearInterval(id)
+ id = createInterval()
+ }
+ }
+ window.addEventListener('focus', onFocus)
+
+ // Create function to deactivate the current timer. It cannot be reactivated,
+ // only by creating a new timer.
+ const deactivate = () => {
+ clearInterval(id)
+ window.removeEventListener('focus', onFocus)
+ }
+
+ return {
+ deactivate,
+ }
+}
diff --git a/src/lib/timer/index.ts b/src/lib/timer/index.ts
new file mode 100644
index 000000000..e94961485
--- /dev/null
+++ b/src/lib/timer/index.ts
@@ -0,0 +1,2 @@
+export * from './create'
+export * from './types'
diff --git a/src/lib/timer/types.ts b/src/lib/timer/types.ts
new file mode 100644
index 000000000..c891cc2cd
--- /dev/null
+++ b/src/lib/timer/types.ts
@@ -0,0 +1,3 @@
+export interface Timer {
+ deactivate: () => void
+}
diff --git a/src/services/useTasksRuns/index.ts b/src/services/useTasksRuns/index.ts
new file mode 100644
index 000000000..26d20dd5b
--- /dev/null
+++ b/src/services/useTasksRuns/index.ts
@@ -0,0 +1,126 @@
+import {
+ DocumentFormat,
+ PiWebserviceProvider,
+ TaskStatus as TaskStatusId,
+ TaskRun as FewsPiTaskRun,
+} from '@deltares/fews-pi-requests'
+import {
+ computed,
+ MaybeRefOrGetter,
+ onUnmounted,
+ ref,
+ toValue,
+ watch,
+} from 'vue'
+
+import { convertTimestampToFewsPiParameter } from '@/lib/date'
+import { RelativePeriod } from '@/lib/period'
+import { createTransformRequestFn } from '@/lib/requests/transformRequest'
+import {
+ convertFewsPiTaskRunToTaskRun,
+ TaskRun,
+ TaskStatus,
+} from '@/lib/taskruns'
+import { createTimer } from '@/lib/timer'
+
+import { configManager } from '../application-config'
+import { convertRelativeToAbsolutePeriod } from '@/lib/period/convert'
+
+export function useTaskRuns(
+ refreshIntervalSeconds: number,
+ dispatchPeriod: MaybeRefOrGetter,
+ workflowIds: MaybeRefOrGetter,
+ statuses: MaybeRefOrGetter,
+) {
+ const baseUrl = configManager.get('VITE_FEWS_WEBSERVICES_URL')
+ const piProvider = new PiWebserviceProvider(baseUrl, {
+ transformRequestFn: createTransformRequestFn(),
+ })
+
+ const isLoading = ref(false)
+ const lastUpdatedTimestamp = ref(null)
+ const allTaskRuns = ref([])
+ const filteredTaskRuns = computed(filterTasks)
+
+ const timer = createTimer(
+ () => {
+ fetch().catch((error) => console.error(`Failed to fetch tasks: ${error}`))
+ },
+ refreshIntervalSeconds,
+ true,
+ )
+ onUnmounted(() => timer.deactivate())
+
+ // Fetch taskruns if a new dispatch period is selected.
+ watch(() => toValue(dispatchPeriod), fetch)
+
+ async function fetch(): Promise {
+ const _dispatchPeriod = toValue(dispatchPeriod)
+ const hasPeriod = _dispatchPeriod !== null
+
+ isLoading.value = true
+ const fetchedTaskRuns = hasPeriod
+ ? await fetchTaskRunsForPeriod(_dispatchPeriod)
+ : await fetchAllTaskRuns()
+ isLoading.value = false
+
+ allTaskRuns.value = fetchedTaskRuns.map(convertFewsPiTaskRunToTaskRun)
+ lastUpdatedTimestamp.value = Date.now()
+ }
+
+ async function fetchAllTaskRuns(): Promise {
+ const response = await piProvider.getTaskRuns({
+ documentFormat: DocumentFormat.PI_JSON,
+ })
+ return response.taskRuns
+ }
+
+ async function fetchTaskRunsForPeriod(
+ dispatchPeriod: RelativePeriod,
+ ): Promise {
+ const period = convertRelativeToAbsolutePeriod(dispatchPeriod)
+ const startDispatchTime = convertTimestampToFewsPiParameter(
+ period.startTimestamp,
+ )
+ const endDispatchTime = convertTimestampToFewsPiParameter(
+ period.endTimestamp,
+ )
+ // FIXME: it is documented that tasks can only be fetched for a specific
+ // workflowId (i.e. that that is a required parameter), but that
+ // seems to be untrue.
+ // Fetch pending tasks separately; they are not returned when we specify
+ // a dispatch period, as they have not yet been dispatched. We always return
+ // all pending tasks.
+ const [pendingTasksResponse, tasksResponse] = await Promise.all([
+ piProvider.getTaskRuns({
+ documentFormat: DocumentFormat.PI_JSON,
+ taskRunStatusIds: TaskStatusId.P,
+ }),
+ piProvider.getTaskRuns({
+ documentFormat: DocumentFormat.PI_JSON,
+ startDispatchTime,
+ endDispatchTime,
+ }),
+ ])
+
+ return [...pendingTasksResponse.taskRuns, ...tasksResponse.taskRuns]
+ }
+
+ function filterTasks(): TaskRun[] {
+ const _workflowIds = toValue(workflowIds)
+ const _statuses = toValue(statuses)
+ return allTaskRuns.value.filter(
+ (task) =>
+ _workflowIds.includes(task.workflowId) &&
+ _statuses.includes(task.status),
+ )
+ }
+
+ return {
+ allTaskRuns,
+ filteredTaskRuns,
+ lastUpdatedTimestamp,
+ isLoading,
+ fetch,
+ }
+}
diff --git a/src/views/TopologyDisplayView.vue b/src/views/TopologyDisplayView.vue
index 48efea029..f40755932 100644
--- a/src/views/TopologyDisplayView.vue
+++ b/src/views/TopologyDisplayView.vue
@@ -97,7 +97,7 @@ import { useWorkflowsStore } from '@/stores/workflows'
import type { TopologyNode } from '@deltares/fews-pi-requests'
import type { WebOcTopologyDisplayConfig } from '@deltares/fews-pi-requests'
-import { computed, ref, StyleValue, watch, watchEffect } from 'vue'
+import { computed, onUnmounted, ref, StyleValue, watch, watchEffect } from 'vue'
import {
onBeforeRouteUpdate,
RouteLocationNormalized,
@@ -115,6 +115,7 @@ import {
type DisplayTab,
} from '@/lib/topology/displayTabs.js'
import { useTopologyNodesStore } from '@/stores/topologyNodes'
+import { useAvailableWorkflowsStore } from '@/stores/availableWorkflows'
interface Props {
topologyId?: string
@@ -131,6 +132,7 @@ const props = defineProps()
const configStore = useConfigStore()
const settings = useUserSettingsStore()
const workflowsStore = useWorkflowsStore()
+const availableWorkflowsStore = useAvailableWorkflowsStore()
const menuType = computed(() => {
const configured = settings.get('ui.hierarchical-menu-style')?.value as string
@@ -159,6 +161,26 @@ const activeNode = computed(() => {
return node
})
+// Set preferred workflow IDs for the running tasks menu, if this node has
+// associated workflows.
+watch(
+ activeNode,
+ (node) => {
+ const primaryWorkflowId = node?.workflowId ? [node.workflowId] : []
+ const secondaryWorkflowIds =
+ node?.secondaryWorkflows?.map(
+ (workflow) => workflow.secondaryWorkflowId,
+ ) ?? []
+ // Note: this list of workflow IDs may be empty, in which case we have no
+ // preferred workflow.
+ const workflowIds = [...primaryWorkflowId, ...secondaryWorkflowIds]
+ availableWorkflowsStore.setPreferredWorkflowIds(workflowIds)
+ },
+ { immediate: true },
+)
+// Clear the preferred workflow IDs when we unmount.
+onUnmounted(() => availableWorkflowsStore.clearPreferredWorkflowIds())
+
const secondaryWorkflows = computed(() => {
if (!activeNode.value?.secondaryWorkflows) return null
return activeNode.value.secondaryWorkflows