Skip to content
1 change: 1 addition & 0 deletions changes.d/2331.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved how task states are displayed in the sidebar.
64 changes: 64 additions & 0 deletions src/components/cylc/TaskStateBadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!--
Copyright (C) NIWA & British Crown (Met Office) & Contributors.

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->

<template>
<div
class="task-state-badge d-flex justify-center align-center px-1 font-weight-medium"
:class="state"
>
{{ value }}
<v-tooltip
location="top"
:open-delay="400"
>
{{ value }} {{ displayName }} task{{ value > 1 ? 's': '' }}.
<template v-if="latestTasks?.length">
Latest:
<span
v-for="task in latestTasks"
:key="task"
class="text-grey-lighten-1"
>
<br/>{{ task }}
</span>
</template>
</v-tooltip>
</div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
state: {
type: String,
required: true
},
value: {
type: Number,
required: true,
},
latestTasks: {
type: Array,
default: () => [],
},
})

const displayName = computed(
() => props.state === 'submitted' ? 'preparing/submitted' : props.state
)
</script>
4 changes: 1 addition & 3 deletions src/components/cylc/WarningIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.

<template>
<span
class="c-warn"
class="c-warn d-inline-flex"
:class="{'active': workflow.node.warningActive}"
style="display: inline-block;"
>
<v-tooltip
:activator="null"
Expand All @@ -45,7 +44,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="deactivate"
@click.prevent
style="
vertical-align: middle;
cursor: pointer;
"
:style="[workflow.node.logRecords?.length ? {opacity: 1} : {opacity: 0.3}]"
Expand Down
13 changes: 9 additions & 4 deletions src/components/cylc/table/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="{ 'flow-none': isFlowNone(item.task.node.flowNums) }"
:data-cy-task-name="item.task.name"
>
<div style="width: 2em;">
<div v-bind="jobIconParentProps">
<Task
v-command-menu="item.task"
:task="item.task.node"
:startTime="item.latestJob?.node?.startedTime"
/>
</div>
<div style="width: 2em;">
<div v-bind="jobIconParentProps">
<Job
v-if="item.latestJob"
v-command-menu="item.latestJob"
Expand Down Expand Up @@ -93,14 +93,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<td :colspan="3">
<div class="d-flex align-content-center flex-nowrap">
<div class="d-flex" style="margin-left: 2em;">
<div v-bind="jobIconParentProps" :style="{ marginLeft: jobIconParentProps.style.width }">
<Job
v-command-menu="job"
:key="`${job.id}-summary-${index}`"
:status="job.node.state"
/>
<span class="ml-2">#{{ job.node.submitNum }}</span>
</div>
<span>#{{ job.node.submitNum }}</span>
</div>
</td>
<td>{{ job.node.platform }}</td>
Expand Down Expand Up @@ -288,6 +288,11 @@ const taskRunTimes = computed(() => new Map(
])
))

const jobIconParentProps = {
class: ['d-flex', 'align-center'],
style: { width: '2em' },
}

const itemsPerPageOptions = [
{ value: 10, title: '10' },
{ value: 20, title: '20' },
Expand Down
210 changes: 85 additions & 125 deletions src/components/cylc/tree/GScanTreeItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<TreeItem
v-bind="{ node, depth, filteredOutNodesCache, hoverable }"
:auto-expand-types="$options.nodeTypes"
:auto-expand-types="nodeTypes"
:render-expand-collapse-btn="node.type !== 'workflow'"
ref="treeItem"
>
Expand Down Expand Up @@ -46,37 +46,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</v-tooltip>
</span>
</div>
<div class="d-flex text-right c-gscan-workflow-states flex-grow-0">
<!-- task summary tooltips -->
<!-- a v-tooltip does not work directly set on Cylc job component, so we use a div to wrap it -->
<div
class="ma-0 pa-0"
min-width="0"
min-height="0"
style="font-size: 120%; width: auto;"
>
<WarningIcon v-if="workflowWarnings" :workflow="node" />
</div>
<div
v-for="[state, tasks] in Object.entries(descendantTaskInfo.latestTasks)"
:key="`${node.id}-${state}`"
:class="getTaskStateClass(descendantTaskInfo.stateTotals, state)"
class="ma-0 pa-0"
min-width="0"
min-height="0"
style="font-size: 120%; width: auto;"
>
<Job :status="state" />
<v-tooltip location="top">
<!-- tooltip text -->
<div class="text-grey-lighten-1">
{{ descendantTaskInfo.stateTotals[state] ?? 0 }} {{ state }}. Recent {{ state }} tasks:
</div>
<div v-for="(task, index) in tasks.slice(0, $options.maxTasksDisplayed)" :key="index">
{{ task }}<br v-if="index !== tasks.length - 1" />
</div>
</v-tooltip>
</div>
<div class="d-flex c-gscan-workflow-states flex-grow-0">
<TaskStateBadge
v-for="(value, state) in statesInfo.stateTotals"
:key="state"
v-bind="{ state, value }"
:latest-tasks="statesInfo.latestTasks[state]"
/>
<WarningIcon
v-if="workflowWarnings"
:workflow="node"
class="ml-1"
style="font-size: 120%;"
/>
</div>
</div>
</v-list-item>
Expand All @@ -94,125 +76,103 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</TreeItem>
</template>

<script>
import Job from '@/components/cylc/Job.vue'
<script setup>
import { computed } from 'vue'
import TaskStateBadge from '@/components/cylc/TaskStateBadge.vue'
import WorkflowIcon from '@/components/cylc/gscan/WorkflowIcon.vue'
import TreeItem from '@/components/cylc/tree/TreeItem.vue'
import WarningIcon from '@/components/cylc/WarningIcon.vue'
import { JobStateNames } from '@/model/JobState.model'
import TaskState from '@/model/TaskState.model'
import { WorkflowState } from '@/model/WorkflowState.model'
import { useWorkflowWarnings } from '@/composables/localStorage'

const nodeTypes = ['workflow-part', 'workflow']

/** Display order in sidebar */
const taskStatesOrdered = [
TaskState.FAILED.name,
TaskState.SUBMIT_FAILED.name,
TaskState.SUBMITTED.name,
TaskState.RUNNING.name,
]

/**
* Get aggregated task state totals and latest task states for all descendents of a node.
* Get aggregated task state totals for all descendents of a node.
*
* Also get latest state tasks for workflow nodes.
*
* @param {Object} node
* @param {Record<string, number>} stateTotals
* @param {Record<string, string[]>} latestTasks
* @param {Boolean} topLevel - true if the traversal depth is 0, else false.
* @param {Record<string, number>} stateTotals - Accumulator for state totals.
*/
function traverseChildren (node, stateTotals = {}, latestTasks = {}, topLevel = true) {
function getStatesInfo (node, stateTotals = {}) {
const latestTasks = {}
// if we aren't at the end of the node tree, continue recurse until we hit something other then a workflow part
if (node.type === 'workflow-part' && node.children) {
// at every branch, recurse all child nodes
// at every branch, recurse all child nodes except stopped workflows
for (const child of node.children) {
traverseChildren(child, stateTotals, latestTasks, false)
if (child.node.status !== WorkflowState.STOPPED.name) {
getStatesInfo(child, stateTotals, latestTasks)
}
}
} else if (node.type === 'workflow' && node.node.stateTotals) {
// if we are at the end of a node (or at least, hit a workflow node), stop and merge state

// the latest state tasks from this node with all the others from the tree
for (const [state, totals] of Object.entries(node.node.stateTotals)) {
if (
// filter only valid states
JobStateNames.includes(state) &&
// omit state totals from stopped workflows
(topLevel || node.node.status !== 'stopped')
) {
// (cast as numbers so they dont get concatenated as strings)
stateTotals[state] = (stateTotals[state] ?? 0) + parseInt(totals)
// if we hit a workflow node, stop and merge state

// the non-zero state totals from this node with all the others from the tree
for (const state of taskStatesOrdered) {
let nodeTotal = node.node.stateTotals[state]
let nodeLatestTasks = node.node.latestStateTasks?.[state] ?? []
if (state === TaskState.SUBMITTED.name) { // include preparing tasks
nodeTotal += node.node.stateTotals.preparing
nodeLatestTasks = [
...nodeLatestTasks,
...(node.node.latestStateTasks?.preparing ?? []),
].slice(0, 5) // limit to 5 latest (submitted tasks take priority)
}
}
for (const [state, taskNames] of Object.entries(node.node.latestStateTasks)) {
if (JobStateNames.includes(state)) {
// concat the new tasks in where they don't already exist
latestTasks[state] = [
...(latestTasks[state] ?? []),
...taskNames,
].sort().reverse() // cycle point descending order
if (nodeTotal) {
stateTotals[state] = (stateTotals[state] ?? 0) + nodeTotal
}
if (nodeLatestTasks.length) {
latestTasks[state] = nodeLatestTasks
}
}
}
return { stateTotals, latestTasks }
}

export default {
name: 'GScanTreeItem',
const workflowWarnings = useWorkflowWarnings()

components: {
Job,
TreeItem,
WarningIcon,
WorkflowIcon,
const props = defineProps({
node: {
type: Object,
required: true
},

data: () => ({
workflowWarnings: useWorkflowWarnings()
}),

props: {
node: {
type: Object,
required: true
},
depth: {
type: Number,
default: 0
},
filteredOutNodesCache: {
type: WeakMap,
required: true,
},
hoverable: {
type: Boolean,
},
depth: {
type: Number,
default: 0
},

computed: {
workflowLink () {
return this.node.type === 'workflow'
? `/workspace/${ this.node.tokens.workflow }`
: ''
},

/** Task state totals and latest states for all descendents of this node. */
descendantTaskInfo () {
return traverseChildren(this.node)
},

nodeChildren () {
return this.node.type === 'workflow'
? []
: this.node.children
},

nodeClass () {
return {
'c-workflow-stopped': this.node.node?.status === WorkflowState.STOPPED.name,
}
}
filteredOutNodesCache: {
type: WeakMap,
required: true,
},

methods: {
getTaskStateClass (stateTotals, state) {
return {
'empty-state': !stateTotals[state]
}
},
hoverable: {
type: Boolean,
},
})

nodeTypes: ['workflow-part', 'workflow'],
maxTasksDisplayed: 5,
WorkflowState,
}
const workflowLink = computed(
() => props.node.type === 'workflow'
? `/workspace/${ props.node.tokens.workflow }`
: ''
)

/** Task state totals for all descendents of this node. */
const statesInfo = computed(() => getStatesInfo(props.node))

const nodeChildren = computed(
() => props.node.type === 'workflow' ? [] : props.node.children
)

const nodeClass = computed(() => ({
'c-workflow-stopped': props.node.node?.status === WorkflowState.STOPPED.name,
}))
</script>
2 changes: 1 addition & 1 deletion src/components/cylc/workspace/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.

<!-- control bar elements displayed only when there is a current workflow in the store -->
<template v-if="currentWorkflow">
<div class="c-workflow-controls flex-shrink-0">
<div class="c-workflow-controls d-flex align-center flex-shrink-0">
<WarningIcon
:workflow="currentWorkflow"
style="font-size: 120%; padding-right: 0.3em;"
Expand Down
Loading