From 24058ed3011359e8e1af158d0dc7102429b3f332 Mon Sep 17 00:00:00 2001 From: "Bruno P. Kinoshita" Date: Mon, 30 Nov 2020 11:12:03 +1300 Subject: [PATCH 01/13] Use Deltas in GScan. --- CHANGES.md | 2 + src/components/cylc/Drawer.vue | 21 +- src/components/cylc/common/merge.js | 52 +++++ src/components/cylc/gscan/GScan.vue | 186 ++++++++------- src/components/cylc/gscan/WorkflowIcon.vue | 22 +- src/components/cylc/tree/cylc-tree.js | 31 +-- src/graphql/queries.js | 211 +++++++++++------- src/services/mock/generate | 6 +- src/services/workflow.service.js | 12 +- src/views/Dashboard.vue | 28 +-- src/views/Tree.vue | 19 +- src/views/Workflow.vue | 17 +- tests/e2e/specs/gscan.js | 2 +- tests/e2e/specs/layout.js | 5 +- tests/e2e/specs/tree.js | 24 +- tests/e2e/specs/userprofile.js | 39 ++-- tests/e2e/specs/workflowservice.js | 5 +- .../components/cylc/core/alert.vue.spec.js | 10 + .../components/cylc/gscan/gscan.vue.spec.js | 143 +++++------- 19 files changed, 467 insertions(+), 368 deletions(-) create mode 100644 src/components/cylc/common/merge.js diff --git a/CHANGES.md b/CHANGES.md index 79ccfc057..dda95b3e7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,8 @@ a shadow underneath, for the Job component. [#658](https://github.com/cylc/cylc-ui/pull/658) - Allow user to set the order of cycle points. +[#543](https://github.com/cylc/cylc-ui/pull/543) - Use deltas in GScan. + ### Fixes [#691](https://github.com/cylc/cylc-ui/pull/691) - diff --git a/src/components/cylc/Drawer.vue b/src/components/cylc/Drawer.vue index bde32630a..0a50e29e1 100644 --- a/src/components/cylc/Drawer.vue +++ b/src/components/cylc/Drawer.vue @@ -40,9 +40,9 @@ along with this program. If not, see . {{ svgPaths.home }} @@ -50,9 +50,9 @@ along with this program. If not, see . Dashboard {{ svgPaths.graphql }} @@ -61,15 +61,13 @@ along with this program. If not, see . Workflows - + @@ -101,7 +99,6 @@ export default { } }, computed: { - ...mapState('workflows', ['workflows']), ...mapState('user', ['user']), drawer: { get: function () { diff --git a/src/components/cylc/common/merge.js b/src/components/cylc/common/merge.js new file mode 100644 index 000000000..6d81f0816 --- /dev/null +++ b/src/components/cylc/common/merge.js @@ -0,0 +1,52 @@ +/** + * 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 . + */ + +import Vue from 'vue' + +/** + * Only effectively used if we return something. Otherwise Lodash will use its default merge + * function. We use it here not to mutate objects, but to check that we are not losing + * reactivity in Vue by adding a non-reactive property into an existing object (which should + * be reactive and used in the node tree component). + * + * @see https://docs-lodash.com/v4/merge-with/ + * @param {?*} objValue - destination value in the existing object (same as object[key]) + * @param {?*} srcValue - source value from the object with new values to be merged + * @param {string} key - name of the property being merged (used to access object[key]) + * @param {*} object - the object being mutated (original, destination, the value is retrieved with object[key]) + * @param {*} source - the source object + */ +function mergeWithCustomizer (objValue, srcValue, key, object, source) { + if (srcValue !== undefined) { + // 1. object[key], or objValue, is undefined + // meaning the destination object does not have the property + // so let's add it with reactivity! + if (objValue === undefined) { + Vue.set(object, `${key}`, srcValue) + } + // 2. object[key], or objValue, is defined but without reactivity + // this means somehow the object got a new property that is not reactive + // so let's now make it reactive with the new value! + if (object[key] && !object[key].__ob__) { + Vue.set(object, `${key}`, srcValue) + } + } +} + +export { + mergeWithCustomizer +} diff --git a/src/components/cylc/gscan/GScan.vue b/src/components/cylc/gscan/GScan.vue index d5286bdc5..0f3f51c88 100644 --- a/src/components/cylc/gscan/GScan.vue +++ b/src/components/cylc/gscan/GScan.vue @@ -122,7 +122,6 @@ along with this program. If not, see . . /> - {{ scope.node.node.name }} + {{ scope.node.node.name || scope.node.id }} + @@ -175,7 +175,7 @@ along with this program. If not, see . - + @@ -190,7 +190,7 @@ along with this program. If not, see . - * - * At its first level, there is a single root node, the Workflow node. - * Workflows have cycle points as children. - * - * A cycle point, in its turn, has family proxies children. - * - * A family proxy can have either other family proxies as children, or - * task proxies. - * - * Task proxies have jobs as children. - * - * And jobs, finally, have job details as children. - * - * - * - * This data structure also keeps a lookup `Map` object. This map - * contains the ID of each node as key, and the node itself as object - * reference. - * - * It means that you can easily access any element in the tree, without - * having to iterate and visit each of its parents. - * - * - * - * Finally, this data structure class contains methods to: - * - * - add a node of any type - * - update a node of any type - * - remove a node of any type - * - * When a node is added, it gets added to the hierarchical tree (i.e. it - * will be added as child of some other node), and also added to the - * lookup map. - * - * When a node is updated, the values of the given node argument in the - * function replace the values in the existing element of the map. i.e. - * we will find the object in the lookup map, and use Lodash's `merge`. - * - * And when a node is removed, it gets removed from its parent's `.children` - * array, and the node and each of its children get removed from the - * lookup map as well. - * - * @class - */ -class CylcTree { - static DEFAULT_CYCLE_POINTS_ORDER_DESC = true - /** - * Create a tree with an initial root node, representing - * a workflow in Cylc. - * - * @param {?WorkflowNode} workflow - * @param {*} options - */ - constructor (workflow, options) { - const defaults = { - cyclePointsOrderDesc: CylcTree.DEFAULT_CYCLE_POINTS_ORDER_DESC - } - this.options = Object.assign(defaults, options) - this.lookup = new Map() - if (!workflow) { - this.root = { - id: '', - node: {}, - children: [] - } - } else { - this.root = workflow - this.lookup.set(this.root.id, this.root) - } - } - - /** - * @param {WorkflowNode} workflow - */ - setWorkflow (workflow) { - if (!workflow) { - throw new Error('You must provide a valid workflow!') - } - this.root = workflow - this.lookup.set(workflow.id, workflow) - } - - clear () { - this.lookup.clear() - this.root = { - id: '', - node: {}, - children: [] - } - } - - /** - * @returns {boolean} - */ - isEmpty () { - return this.lookup.size === 0 - } - - /** - * @param {TreeNode} node - */ - recursivelyRemoveNode (node) { - const stack = [node] - while (stack.length > 0) { - const n = stack.pop() - this.lookup.delete(n.id) - if (n.children && n.children.length > 0) { - stack.push(...n.children) - } - } - } - - // --- Cycle points - - /** - * @param {CyclePointNode} cyclePoint - */ - addCyclePoint (cyclePoint) { - if (!this.lookup.has(cyclePoint.id)) { - this.lookup.set(cyclePoint.id, cyclePoint) - const parent = this.root - // when DESC mode, reverse to put cyclepoints in ascending order (i.e. 1, 2, 3) - const cyclePoints = this.options.cyclePointsOrderDesc ? [...parent.children].reverse() : parent.children - const insertIndex = sortedIndexBy( - cyclePoints, - cyclePoint, - (c) => c.node.name - ) - if (this.options.cyclePointsOrderDesc) { - parent.children.splice(parent.children.length - insertIndex, 0, cyclePoint) - } else { - parent.children.splice(insertIndex, 0, cyclePoint) - } - } - } - - /** - * @param {CyclePointNode} cyclePoint - */ - updateCyclePoint (cyclePoint) { - const node = this.lookup.get(cyclePoint.id) - if (node) { - mergeWith(node, cyclePoint, mergeWithCustomizer) - } - } - - /** - * @param {string} cyclePointId - */ - removeCyclePoint (cyclePointId) { - const node = this.lookup.get(cyclePointId) - if (node) { - this.recursivelyRemoveNode(node) - this.root.children.splice(this.root.children.indexOf(node), 1) - } - } - - tallyCyclePointStates () { - // calculate cycle point states - computeCyclePointsStates(this.root.children) - } - - // --- Family proxies - - /** - * @param {FamilyProxyNode} familyProxy - */ - addFamilyProxy (familyProxy) { - // When we receive the families from the GraphQL endpoint, we are sorting by their - // firstParent's. However, you may get family proxies out of order when iterating - // them. When that happens, you may add a family proxy to the lookup, and only - // append it to the parent later. - // ignore the root family - if (familyProxy.id.endsWith(`|${FAMILY_ROOT}`)) { - return - } - // add if not in the lookup already - const existingFamilyProxy = this.lookup.get(familyProxy.id) - if (!existingFamilyProxy) { - this.lookup.set(familyProxy.id, familyProxy) - } else { - // We may get a family proxy added twice. The first time is when it is the parent of another - // family proxy. In that case, we create an orphan node in the lookup table. - // The second time will be node with more information, such as .firstParent {}. When this happens, - // we must remember to merge the objects. - mergeWith(existingFamilyProxy, familyProxy, mergeWithCustomizer) - this.lookup.set(existingFamilyProxy.id, existingFamilyProxy) - // NOTE: important, replace the version so that we use the existing one - // when linking with the parent node in the tree, not the new GraphQL data - familyProxy = existingFamilyProxy - } - // See comment above in the else block. When we get family proxies out of order, we create the parent - // nodes if they don't exist in the tree yet, so that we can create the correct hierarchy. Later, we - // merge the data of the node. But for a while, the family proxy that we create won't have a state (as - // the state is given in the deltas data, and is not available in the `node.firstParent { id }`. - if (!familyProxy.node.state) { - familyProxy.node.state = '' - } - - // if we got the parent, let's link parent and child - if (familyProxy.node.firstParent) { - let parent - if (familyProxy.node.firstParent.name === FAMILY_ROOT) { - // if the parent is root, we use the cyclepoint as the parent - const cyclePointId = getCyclePointId(familyProxy) - parent = this.lookup.get(cyclePointId) - } else if (this.lookup.has(familyProxy.node.firstParent.id)) { - // if its parent is another family proxy node and must already exist - parent = this.lookup.get(familyProxy.node.firstParent.id) - } else { - // otherwise we create it so task proxies can be added to it as a child - parent = createFamilyProxyNode(familyProxy.node.firstParent) - this.lookup.set(parent.id, parent) - } - // since this method may be called several times for the same family proxy (see comments above), it means - // the parent-child could end up repeated by accident; it means we must make sure to create this relationship - // exactly once. - if (parent.children.length === 0 || !parent.children.find(child => child.id === familyProxy.id)) { - const sortedIndex = sortedIndexBy( - parent.children, - familyProxy, - (f) => f.node.name, - sortTaskProxyOrFamilyProxy - ) - parent.children.splice(sortedIndex, 0, familyProxy) - } - } - } - - /** - * @param {FamilyProxyNode} familyProxy - */ - updateFamilyProxy (familyProxy) { - const node = this.lookup.get(familyProxy.id) - if (node) { - mergeWith(node, familyProxy, mergeWithCustomizer) - if (!node.node.state) { - node.node.state = '' - } - } - } - - /** - * @param {string} familyProxyId - */ - removeFamilyProxy (familyProxyId) { - let node - let nodeId - let parentId - // NOTE: when deleting the root family, we can also remove the entire cycle point - if (familyProxyId.endsWith('|root')) { - // 0 has the owner, 1 has the workflow Id, 2 has the cycle point, and 3 the family name - const [owner, workflowId] = familyProxyId.split('|') - nodeId = getCyclePointId({ id: familyProxyId }) - node = this.lookup.get(nodeId) - parentId = `${owner}|${workflowId}` - } else { - nodeId = familyProxyId - node = this.lookup.get(nodeId) - if (node && node.node && node.node.firstParent) { - if (node.node.firstParent.name === FAMILY_ROOT) { - parentId = getCyclePointId(node) - } else { - parentId = node.node.firstParent.id - } - } - } - if (node) { - this.recursivelyRemoveNode(node) - const parent = this.lookup.get(parentId) - // If the parent has already been removed from the lookup map, there won't be any parent here - if (parent) { - parent.children.splice(parent.children.indexOf(node), 1) - } - } - } - - // --- Task proxies - - /** - * Return a task proxy parent, which may be a family proxy, - * or a cycle point (if the parent family is ROOT). - * - * @private - * @param {TaskProxyNode} taskProxy - * @return {?TaskProxyNode} - */ - findTaskProxyParent (taskProxy) { - if (taskProxy.node.firstParent.name === FAMILY_ROOT) { - // if the parent is root, we must instead attach this node to the cyclepoint! - const cyclePointId = getCyclePointId(taskProxy) - return this.lookup.get(cyclePointId) - } - // otherwise its parent **MAY** already exist - return this.lookup.get(taskProxy.node.firstParent.id) - } - - /** - * @param {TaskProxyNode} taskProxy - */ - addTaskProxy (taskProxy) { - if (!this.lookup.has(taskProxy.id)) { - // progress starts at 0 - taskProxy.node.progress = 0 - // A TaskProxy could be a ghost node, which doesn't have a state/status yet. - // Note that we cannot have this if-check in `createTaskProxyNode`, as an - // update-delta might not have a state, and we don't want to merge - // { state: "" } with an object that contains { state: "running" }, for - // example. - if (!taskProxy.node.state) { - taskProxy.node.state = '' - } - this.lookup.set(taskProxy.id, taskProxy) - if (taskProxy.node.firstParent) { - const parent = this.findTaskProxyParent(taskProxy) - if (!parent) { - // eslint-disable-next-line no-console - console.error(`Missing parent ${taskProxy.node.firstParent.id}`) - } else { - const sortedIndex = sortedIndexBy( - parent.children, - taskProxy, - (t) => t.node.name, - sortTaskProxyOrFamilyProxy - ) - parent.children.splice(sortedIndex, 0, taskProxy) - } - } - } - } - - /** - * @param {TaskProxyNode} taskProxy - */ - updateTaskProxy (taskProxy) { - const node = this.lookup.get(taskProxy.id) - if (node) { - mergeWith(node, taskProxy, mergeWithCustomizer) - } - } - - /** - * @param {string} taskProxyId - */ - removeTaskProxy (taskProxyId) { - const taskProxy = this.lookup.get(taskProxyId) - if (taskProxy) { - this.recursivelyRemoveNode(taskProxy) - // Remember that we attach task proxies children of 'root' directly to a cycle point! - if (taskProxy.node.firstParent) { - const parent = this.findTaskProxyParent(taskProxy) - parent.children.splice(parent.children.indexOf(taskProxy), 1) - } - } - } - - // --- Jobs - - /** - * @param {JobNode} job - */ - addJob (job) { - if (!this.lookup.has(job.id)) { - this.lookup.set(job.id, job) - if (job.node.firstParent) { - const parent = this.lookup.get(job.node.firstParent.id) - const insertIndex = sortedIndexBy( - parent.children, - job, - (j) => `${j.node.submitNum}`) - parent.children.splice(parent.children.length - insertIndex, 0, job) - } - } - } - - /** - * @param {JobNode} job - */ - updateJob (job) { - const node = this.lookup.get(job.id) - if (node) { - mergeWith(node, job, mergeWithCustomizer) - } - } - - /** - * @param {string} jobId - */ - removeJob (jobId) { - const job = this.lookup.get(jobId) - if (job) { - this.recursivelyRemoveNode(job) - if (job.node.firstParent) { - const parent = this.lookup.get(job.node.firstParent.id) - // prevent runtime error in case the parent was already removed - if (parent) { - // re-calculate the job's task progress - parent.children.splice(parent.children.indexOf(job), 1) - } - } - } - } -} - -export default CylcTree diff --git a/src/components/cylc/tree/deltas.js b/src/components/cylc/tree/deltas.js index b953f9ce1..e51a0d64e 100644 --- a/src/components/cylc/tree/deltas.js +++ b/src/components/cylc/tree/deltas.js @@ -15,96 +15,62 @@ * along with this program. If not, see . */ +import isArray from 'lodash/isArray' import { + createWorkflowNode, createCyclePointNode, createFamilyProxyNode, createJobNode, createTaskProxyNode -} from '@/components/cylc/tree/tree-nodes' -import { populateTreeFromGraphQLData } from '@/components/cylc/tree/index' -import store from '@/store/index' -import AlertModel from '@/model/Alert.model' - -/** - * Helper object used to iterate pruned deltas data. - */ -const PRUNED = { - jobs: 'removeJob', - taskProxies: 'removeTaskProxy', - familyProxies: 'removeFamilyProxy' -} - -/** - * @typedef {Object} DeltasPruned - * @property {Array} taskProxies - IDs of task proxies removed - * @property {Array} familyProxies - IDs of family proxies removed - * @property {Array} jobs - IDs of jobs removed - */ - -/** - * Deltas pruned. - * - * @param {DeltasPruned} pruned - deltas pruned - * @param {CylcTree} tree - */ -function applyDeltasPruned (pruned, tree) { - Object.keys(PRUNED).forEach(prunedKey => { - if (pruned[prunedKey]) { - for (const id of pruned[prunedKey]) { - try { - tree[PRUNED[prunedKey]](id) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error applying pruned-delta, will continue processing the remaining data', error, id) - store.dispatch('setAlert', new AlertModel('Error applying pruned-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', null, 'error')) - } - } - } - }) -} +} from '@/components/cylc/tree/nodes' +import * as CylcTree from '@/components/cylc/tree/index' /** * Helper object used to iterate added deltas data. */ const ADDED = { + workflow: [createWorkflowNode, 'addWorkflow'], cyclePoints: [createCyclePointNode, 'addCyclePoint'], familyProxies: [createFamilyProxyNode, 'addFamilyProxy'], taskProxies: [createTaskProxyNode, 'addTaskProxy'], jobs: [createJobNode, 'addJob'] } -/** - * @typedef {Object} DeltasAdded - * @property {Object} workflow - * @property {Array} cyclePoints - * @property {Array} familyProxies - * @property {Array} taskProxies - * @property {Array} jobs - */ - /** * Deltas added. * * @param {DeltasAdded} added - * @param {CylcTree} tree + * @param {Workflow} workflow + * @param {Lookup} lookup + * @param {*} options */ -function applyDeltasAdded (added, tree) { +function applyDeltasAdded (added, workflow, lookup, options) { + const result = { + errors: [] + } Object.keys(ADDED).forEach(addedKey => { if (added[addedKey]) { - added[addedKey].forEach(addedData => { + const items = isArray(added[addedKey]) ? added[addedKey] : [added[addedKey]] + items.forEach(addedData => { try { + const existingData = lookup[addedData.id] const createNodeFunction = ADDED[addedKey][0] const treeFunction = ADDED[addedKey][1] - const node = createNodeFunction(addedData) - tree[treeFunction](node) + const node = createNodeFunction(existingData) + CylcTree[treeFunction](node, workflow, options) } catch (error) { - // eslint-disable-next-line no-console - console.error('Error applying added-delta, will continue processing the remaining data', error, addedData) - store.dispatch('setAlert', new AlertModel('Error applying added-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', null, 'error')) + result.errors.push([ + 'Error applying added-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', + error, + addedData, + workflow, + lookup + ]) } }) } }) + return result } /** @@ -116,60 +82,96 @@ const UPDATED = { jobs: [createJobNode, 'updateJob'] } -/** - * @typedef {Object} DeltasUpdated - * @property {Array} familyProxies - * @property {Array} taskProxies - * @property {Array} jobs - */ - /** * Deltas updated. * * @param updated {DeltasUpdated} updated - * @param {CylcTree} tree + * @param {Workflow} workflow + * @param {Lookup} lookup + * @param {*} options */ -function applyDeltasUpdated (updated, tree) { +function applyDeltasUpdated (updated, workflow, lookup, options) { + const result = { + errors: [] + } Object.keys(UPDATED).forEach(updatedKey => { if (updated[updatedKey]) { updated[updatedKey].forEach(updatedData => { try { - const updateNodeFunction = UPDATED[updatedKey][0] - const treeFunction = UPDATED[updatedKey][1] - const node = updateNodeFunction(updatedData) - tree[treeFunction](node) + const existingData = lookup[updatedData.id] + if (!existingData) { + result.errors.push([ + `Updated node [${updatedData.id}] not found in workflow lookup`, + updatedData, + workflow, + lookup + ]) + } else { + const updateNodeFunction = UPDATED[updatedKey][0] + const treeFunction = UPDATED[updatedKey][1] + const node = updateNodeFunction(existingData) + CylcTree[treeFunction](node, workflow, options) + } } catch (error) { - // eslint-disable-next-line no-console - console.error('Error applying updated-delta, will continue processing the remaining data', error, updatedData) - store.dispatch('setAlert', new AlertModel('Error applying updated-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', null, 'error')) + result.errors.push([ + 'Error applying added-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', + error, + updatedData, + workflow, + lookup + ]) } }) } }) + return result } /** - * @typedef {Object} Deltas - * @property {string} id - * @property {boolean} shutdown - * @property {?DeltasAdded} added - * @property {?DeltasUpdated} updated - * @property {?DeltasPruned} pruned + * Helper object used to iterate pruned deltas data. */ +const PRUNED = { + jobs: 'removeJob', + taskProxies: 'removeTaskProxy', + familyProxies: 'removeFamilyProxy' +} /** - * Handle the initial data burst of deltas. Should create a tree given a workflow from the GraphQL - * data. This tree contains the base structure to which the deltas are applied to. + * Deltas pruned. * - * @param {Deltas} deltas - GraphQL deltas - * @param {CylcTree} tree - Tree object backed by an array and a Map + * @param {DeltasPruned} pruned - deltas pruned + * @param {Workflow} workflow + * @param {Lookup} lookup + * @param {*} options */ -function handleInitialDataBurst (deltas, tree) { - const workflow = deltas.added.workflow - // A workflow (e.g. five) may not have any families as 'root' is filtered - workflow.familyProxies = workflow.familyProxies || [] - populateTreeFromGraphQLData(tree, workflow) - tree.tallyCyclePointStates() +function applyDeltasPruned (pruned, workflow, lookup, options) { + const result = { + errors: [] + } + Object.keys(PRUNED).forEach(prunedKey => { + if (pruned[prunedKey]) { + for (const id of pruned[prunedKey]) { + try { + CylcTree[PRUNED[prunedKey]](id, workflow, options) + } catch (error) { + result.errors.push([ + 'Error applying pruned-delta, see browser console logs for more. Please reload your browser tab to retrieve the full flow state', + error, + prunedKey, + workflow, + lookup + ]) + } + } + } + }) + return result +} + +const DELTAS = { + added: applyDeltasAdded, + updated: applyDeltasUpdated, + pruned: applyDeltasPruned } /** @@ -181,71 +183,85 @@ function handleInitialDataBurst (deltas, tree) { * the first family from the top of the hierarchy in the deltas. *l * @param {Deltas} deltas - GraphQL deltas - * @param {CylcTree} tree - Tree object backed by an array and a Map + * @param {Workflow} workflow - Tree object + * @param {Lookup} lookup + * @param {*} options */ -function handleDeltas (deltas, tree) { - if (deltas.pruned) { - applyDeltasPruned(deltas.pruned, tree) - } - if (deltas.added) { - applyDeltasAdded(deltas.added, tree) - } - if (deltas.updated) { - applyDeltasUpdated(deltas.updated, tree) - } +function handleDeltas (deltas, workflow, lookup, options) { + const errors = [] + Object.keys(DELTAS).forEach(key => { + if (deltas[key]) { + const handlingFunction = DELTAS[key] + const result = handlingFunction(deltas[key], workflow, lookup, options) + errors.push(...result.errors) + } + }) // if added, removed, or updated deltas, we want to re-calculate the cycle point states now if (deltas.pruned || deltas.added || deltas.updated) { - tree.tallyCyclePointStates() + CylcTree.tallyCyclePointStates(workflow) + } + return { + errors } } /** - * @param {?Deltas} deltas - * @param {?CylcTree} tree + * @param {GraphQLResponseData} data + * @param {Workflow} workflow + * @param {Lookup} lookup + * @param {*} options */ -export function applyDeltas (deltas, tree) { - if (deltas && tree) { - // first we check whether it is a shutdown response - if (deltas.shutdown) { - tree.clear() - return +export default function (data, workflow, lookup, options) { + const deltas = data.deltas + // first we check whether it is a shutdown response + if (deltas.shutdown) { + CylcTree.clear(workflow) + return { + errors: [] } - if (tree.isEmpty()) { - // When the tree is null, we have two possible scenarios: - // 1. This means that we will receive our initial data burst in deltas.added.workflow - // which we can use to create the tree structure. - // 2. Or this means that after the shutdown (when we delete the tree), we received a delta. - // In this case we don't really have any way to fix the tree. - // In both cases, actually, the user has little that s/he could do, besides refreshing the - // page. So we fail silently and wait for a request with the initial data. - if (!deltas.added || !deltas.added.workflow) { - // eslint-disable-next-line no-console - console.error('Received a delta before the workflow initial data burst') - store.dispatch('setAlert', new AlertModel('Received a delta before the workflow initial data burst. Please reload your browser tab to retrieve the full flow state', null, 'error')) - return - } - try { - handleInitialDataBurst(deltas, tree) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error applying initial data burst for deltas', error, deltas) - store.dispatch('setAlert', new AlertModel('Error applying initial data burst for deltas. Please reload your browser tab to retrieve the full flow state', null, 'error')) - throw error - } - } else { - // the tree was created, and now the next messages should contain - // 1. new data added under deltas.added (but not in deltas.added.workflow) - // 2. data updated in deltas.updated - // 3. data pruned in deltas.pruned - try { - handleDeltas(deltas, tree) - } catch (error) { - // eslint-disable-next-line no-console - console.error('Unexpected error applying deltas', error, deltas) - throw error + } + // Safe check in case the tree is empty. + if (CylcTree.isEmpty(workflow)) { + // When the tree is empty, we have two possible scenarios: + // 1. This means that we will receive our initial data burst in deltas.added + // which we can use to create the tree structure. + // 2. Or this means that after the shutdown (when we delete the tree), we received a delta. + // In this case we don't really have any way to fix the tree. + // In both cases, actually, the user has little that s/he could do, besides refreshing the + // page. So we fail silently and wait for a request with the initial data. + // + // We need at least a deltas.added.workflow in the deltas data, since it is the root node. + if (!deltas.added || !deltas.added.workflow) { + return { + errors: [ + [ + 'Received a delta before the workflow initial data burst', + deltas.added, + workflow, + lookup + ] + ] } } - } else { - throw Error('Workflow tree subscription did not return data.deltas') + } + // the tree was created, and now the next messages should contain + // 1. data added in deltas.added + // 2. data updated in deltas.updated + // 3. data pruned in deltas.pruned + // 4. a delta with some data, and the .shutdown flag telling us the workflow has stopped + try { + return handleDeltas(deltas, workflow, lookup, options) + } catch (error) { + return { + errors: [ + [ + 'Unexpected error applying deltas', + error, + deltas, + workflow, + lookup + ] + ] + } } } diff --git a/src/components/cylc/tree/index.js b/src/components/cylc/tree/index.js index b84a4b9d2..573c06f16 100644 --- a/src/components/cylc/tree/index.js +++ b/src/components/cylc/tree/index.js @@ -14,58 +14,548 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +import { extractGroupState } from '@/utils/tasks' +import { mergeWith } from 'lodash' +import { createFamilyProxyNode, getCyclePointId } from '@/components/cylc/tree/nodes' +import Vue from 'vue' +import { mergeWithCustomizer } from '@/components/cylc/common/merge' -// eslint-disable-next-line no-unused-vars -import CylcTree from '@/components/cylc/tree/cylc-tree' -import { - containsTreeData, - createWorkflowNode, - createCyclePointNode, - createFamilyProxyNode, - createTaskProxyNode, - createJobNode -} from '@/components/cylc/tree/tree-nodes' +export const FAMILY_ROOT = 'root' + +export const DEFAULT_CYCLE_POINTS_ORDER_DESC = true + +/** + * @typedef {Object} Workflow + * @property {Lookup} lookup + * @property {Tree} tree + */ + +/** + * @typedef {Object} Lookup + */ + +/** + * @typedef {Object} Tree + */ + +/** + * Compute the state of each cycle point node in the list given. + * + * The formula used to compute each cycle point state is the same as in Cylc 7, using an enum of task types. + * + * After the state is successfully computed, each cycle point node gets an additional property `state` + * with type string, representing the cycle point state. + * + * @param {Array} cyclePointNodes list of cycle point nodes. + */ +function computeCyclePointsStates (cyclePointNodes) { + for (const cyclePointNode of cyclePointNodes) { + const childStates = [] + for (const child of cyclePointNode.children) { + childStates.push(child.node.state) + } + const cyclePointState = extractGroupState(childStates, false) + // Initially the .node object retrieved from the GraphQL endpoint does + // not have the .state property. So we need to ask Vue to make it reactive. + Vue.set(cyclePointNode.node, 'state', cyclePointState) + } +} + +/** + * The default comparator used to compare strings for cycle points, family proxies names, + * task proxies names, and jobs. + * + * @param left {string} + * @param right {string} + * @returns {number} + * @constructor + */ +const DEFAULT_COMPARATOR = (left, right) => { + return left.toLowerCase() + .localeCompare( + right.toLowerCase(), + undefined, + { + numeric: true, + sensitivity: 'base' + } + ) +} + +/** + * Declare function used in sortedIndexBy as a comparator. + * + * @private + * @callback SortedIndexByComparator + * @param {object} leftObject - left parameter object + * @param {string} leftValue - left parameter value + * @param {object} rightObject - right parameter object + * @param {string} rightValue - right parameter value + * @returns {boolean} - true if leftValue is higher than rightValue + */ + +/** + * @private + * @typedef {SortedIndexByComparator} SortTaskProxyOrFamilyProxyComparator + * @param {TaskProxyNode|FamilyProxyNode} leftObject + * @param {string} leftValue + * @param {TaskProxyNode|FamilyProxyNode} rightObject + * @param {string} rightValue + * @returns {boolean} + */ +function sortTaskProxyOrFamilyProxy (leftObject, leftValue, rightObject, rightValue) { + // sort cycle point children (family-proxies, and task-proxies) + // first we sort by type ascending, so 'family-proxy' types come before 'task-proxy' + // then we sort by node name ascending, so 'bar' comes before 'foo' + // node type + if (leftObject.type < rightObject.type) { + return -1 + } + if (leftObject.type > rightObject.type) { + return 1 + } + // name + return DEFAULT_COMPARATOR(leftValue, rightValue) > 0 +} + +/** + * Declare function used in sortedIndexBy for creating the iteratee. + * + * @callback SortedIndexByIteratee + * @param {Object} value - any object + * @returns {string} + */ /** - * Populate the given tree using the also provided GraphQL workflow object. + * Given a list of elements, and a value to be added to the list, we + * perform a simple binary search of the list to determine the next + * index where the value can be inserted, so that the list remains + * sorted. * - * Every node has data, and a .name property used to display the node in the tree in the UI. + * This function uses localeCompare, which will respect the numeric + * collation. * - * @param tree {null|CylcTree} - A hierarchical tree - * @param workflow {null|Object} - GraphQL workflow object - * @throws {Error} - If the workflow or tree are either null or invalid (e.g. missing data) - */ -function populateTreeFromGraphQLData (tree, workflow) { - if (!tree || !workflow || !containsTreeData(workflow)) { - // throw new Error('You must provide valid data to populate the tree!') - // a stopped workflow is valid, but won't have anything that we can use - // to populate the tree, only workflow data and empty families + * This is a simplified version of lodash's function with the same + * name, but that respects natural order for numbers, i.e. [1, 2, 10]. + * Not [1, 10, 2]. + * + * @private + * @param array {Array} - list of string values, or of objects with string values + * @param value {object} - a value to be inserted in the list, or an object wrapping the value (see iteratee) + * @param iteratee {SortedIndexByIteratee=} - an optional function used to return the value of the element of the list} + * @param comparator {SortedIndexByComparator=} - function used to compare the newValue with otherValues in the list + */ +function sortedIndexBy (array, value, iteratee, comparator) { + if (array.length === 0) { + return 0 + } + // If given a function, use it. Otherwise, simply use identity function. + const iterateeFunction = iteratee || ((value) => value) + // If given a function, use it. Otherwise, simply use locale sort with numeric enabled + const comparatorFunction = comparator || ((leftObject, leftValue, rightObject, rightValue) => DEFAULT_COMPARATOR(leftValue, rightValue) > 0) + let low = 0 + let high = array.length + + const newValue = iterateeFunction(value) + + while (low < high) { + const mid = Math.floor((low + high) / 2) + const midValue = iterateeFunction(array[mid]) + const higher = comparatorFunction(value, newValue, array[mid], midValue) + if (higher) { + low = mid + 1 + } else { + high = mid + } + } + return high +} + +/** + * @param {Workflow} workflow + */ +function clear (workflow) { + ['tree', 'lookup'].forEach(each => { + Object.keys(workflow[each]).forEach(key => { + Vue.delete(workflow[each], key) + }) + }) +} + +/** + * @param {Workflow} workflow + */ +function isEmpty (workflow) { + return Object.keys(workflow.lookup).length === 0 +} + +/** + * @private + * @param {TreeNode} node + * @param {Lookup} lookup + */ +function recursivelyRemoveNode (node, lookup) { + const stack = [node] + while (stack.length > 0) { + const n = stack.pop() + Vue.delete(lookup, n.id) + if (n.children && n.children.length > 0) { + stack.push(...n.children) + } + } +} + +// --- Workflow + +function addWorkflow (workflowNode, workflow, options) { + if (!workflow.lookup[workflowNode.id]) { + mergeWith(workflow.tree, workflowNode, mergeWithCustomizer) + Vue.set(workflow.lookup, workflowNode.id, workflow.tree) + } +} + +// --- Cycle points + +/** + * @param {Workflow} workflow + * @param {CyclePointNode} cyclePoint + * @param {*} options + */ +function addCyclePoint (cyclePoint, workflow, options) { + const cyclePointsOrderDesc = options.cyclePointsOrderDesc !== undefined + ? options.cyclePointsOrderDesc + : DEFAULT_CYCLE_POINTS_ORDER_DESC + if (!workflow.lookup[cyclePoint.id]) { + Vue.set(workflow.lookup, cyclePoint.id, cyclePoint) + const parent = workflow.tree + // when DESC mode, reverse to put cyclepoints in ascending order (i.e. 1, 2, 3) + const cyclePoints = cyclePointsOrderDesc ? [...parent.children].reverse() : parent.children + const insertIndex = sortedIndexBy( + cyclePoints, + cyclePoint, + (c) => c.node.name + ) + if (cyclePointsOrderDesc) { + parent.children.splice(parent.children.length - insertIndex, 0, cyclePoint) + } else { + parent.children.splice(insertIndex, 0, cyclePoint) + } + } +} + +/** + * @param {CyclePointNode} cyclePoint + * @param {Workflow} workflow + * @param {*} options + */ +function updateCyclePoint (cyclePoint, workflow, options) { + const node = workflow.lookup[cyclePoint.id] + if (node) { + mergeWith(node, cyclePoint, mergeWithCustomizer) + } +} + +/** + * @param {String} cyclePointId + * @param {Workflow} workflow + * @param {*} options + */ +function removeCyclePoint (cyclePointId, workflow, options) { + const node = workflow.lookup[cyclePointId] + if (node) { + recursivelyRemoveNode(node, workflow.lookup) + workflow.tree.children.splice(workflow.tree.children.indexOf(node), 1) + Vue.delete(workflow.lookup, cyclePointId) + } +} + +/** + * @param {Workflow} workflow + */ +function tallyCyclePointStates (workflow) { + if (workflow && workflow.tree && workflow.tree.children) { + // calculate cycle point states + computeCyclePointsStates(workflow.tree.children) + } +} + +// --- Family proxies + +/** + * @param {FamilyProxyNode} familyProxy + * @param {Workflow} workflow + * @param {*} options + */ +function addFamilyProxy (familyProxy, workflow, options) { + // When we receive the families from the GraphQL endpoint, we are sorting by their + // firstParent's. However, you may get family proxies out of order when iterating + // them. When that happens, you may add a family proxy to the lookup, and only + // append it to the parent later. + // ignore the root family + if (familyProxy.id.endsWith(`|${FAMILY_ROOT}`)) { return } - // the workflow object gets augmented to become a valid node for the tree - const rootNode = createWorkflowNode(workflow) - tree.setWorkflow(rootNode) - for (const cyclePoint of workflow.cyclePoints) { - const cyclePointNode = createCyclePointNode(cyclePoint) - tree.addCyclePoint(cyclePointNode) - } - for (const familyProxy of workflow.familyProxies) { - const familyProxyNode = createFamilyProxyNode(familyProxy) - tree.addFamilyProxy(familyProxyNode) - } - for (const taskProxy of workflow.taskProxies) { - const taskProxyNode = createTaskProxyNode(taskProxy) - tree.addTaskProxy(taskProxyNode) - // A TaskProxy could no jobs (yet) - if (taskProxy.jobs) { - for (const job of taskProxy.jobs) { - const jobNode = createJobNode(job) - tree.addJob(jobNode) + // add if not in the lookup already + const existingFamilyProxy = workflow.lookup[familyProxy.id] + if (!existingFamilyProxy) { + Vue.set(workflow.lookup, familyProxy.id, familyProxy) + } else { + // We may get a family proxy added twice. The first time is when it is the parent of another + // family proxy. In that case, we create an orphan node in the lookup table. + // The second time will be node with more information, such as .firstParent {}. When this happens, + // we must remember to merge the objects. + mergeWith(existingFamilyProxy, familyProxy, mergeWithCustomizer) + Vue.set(workflow.lookup, existingFamilyProxy.id, existingFamilyProxy) + // NOTE: important, replace the version so that we use the existing one + // when linking with the parent node in the tree, not the new GraphQL data + familyProxy = existingFamilyProxy + } + // See comment above in the else block. When we get family proxies out of order, we create the parent + // nodes if they don't exist in the tree yet, so that we can create the correct hierarchy. Later, we + // merge the data of the node. But for a while, the family proxy that we create won't have a state (as + // the state is given in the deltas data, and is not available in the `node.firstParent { id }`. + if (!familyProxy.node.state) { + Vue.set(familyProxy.node, 'state', '') + } + + // if we got the parent, let's link parent and child + if (familyProxy.node.firstParent) { + let parent + if (familyProxy.node.firstParent.name === FAMILY_ROOT) { + // if the parent is root, we use the cyclepoint as the parent + const cyclePointId = getCyclePointId(familyProxy) + parent = workflow.lookup[cyclePointId] + } else if (workflow.lookup[familyProxy.node.firstParent.id]) { + // if its parent is another family proxy node and must already exist + parent = workflow.lookup[familyProxy.node.firstParent.id] + } else { + // otherwise we create it so task proxies can be added to it as a child + parent = createFamilyProxyNode(familyProxy.node.firstParent) + Vue.set(workflow.lookup, parent.id, parent) + } + // since this method may be called several times for the same family proxy (see comments above), it means + // the parent-child could end up repeated by accident; it means we must make sure to create this relationship + // exactly once. + if (parent.children.length === 0 || !parent.children.find(child => child.id === familyProxy.id)) { + const sortedIndex = sortedIndexBy( + parent.children, + familyProxy, + (f) => f.node.name, + sortTaskProxyOrFamilyProxy + ) + parent.children.splice(sortedIndex, 0, familyProxy) + } + } +} + +/** + * @param {FamilyProxyNode} familyProxy + * @param {Workflow} workflow + * @param {*} options + */ +function updateFamilyProxy (familyProxy, workflow, options) { + const node = workflow.lookup[familyProxy.id] + if (node) { + mergeWith(node, familyProxy, mergeWithCustomizer) + if (!node.node.state) { + Vue.set(node.node, 'state', '') + } + } +} + +// --- Task proxies + +/** + * Return a task proxy parent, which may be a family proxy, + * or a cycle point (if the parent family is ROOT). + * + * @private + * @param {TaskProxyNode} taskProxy + * @param {Workflow} workflow + * @return {?TaskProxyNode} + */ +function findTaskProxyParent (taskProxy, workflow) { + if (taskProxy.node.firstParent.name === FAMILY_ROOT) { + // if the parent is root, we must instead attach this node to the cyclepoint! + const cyclePointId = getCyclePointId(taskProxy) + return workflow.lookup[cyclePointId] + } + // otherwise its parent **MAY** already exist + return workflow.lookup[taskProxy.node.firstParent.id] +} + +/** + * @param {TaskProxyNode} taskProxy + * @param {Workflow} workflow + * @param {*} options + */ +function addTaskProxy (taskProxy, workflow, options) { + if (!workflow.lookup[taskProxy.id]) { + // progress starts at 0 + Vue.set(taskProxy.node, 'progress', 0) + // A TaskProxy could be a ghost node, which doesn't have a state/status yet. + // Note that we cannot have this if-check in `createTaskProxyNode`, as an + // update-delta might not have a state, and we don't want to merge + // { state: "" } with an object that contains { state: "running" }, for + // example. + if (!taskProxy.node.state) { + Vue.set(taskProxy.node, 'state', '') + } + Vue.set(workflow.lookup, taskProxy.id, taskProxy) + if (taskProxy.node.firstParent) { + const parent = findTaskProxyParent(taskProxy, workflow) + if (!parent) { + // eslint-disable-next-line no-console + console.error(`Missing parent ${taskProxy.node.firstParent.id}`) + } else { + const sortedIndex = sortedIndexBy( + parent.children, + taskProxy, + (t) => t.node.name, + sortTaskProxyOrFamilyProxy + ) + parent.children.splice(sortedIndex, 0, taskProxy) + } + } + } +} + +/** + * @param {TaskProxyNode} taskProxy + * @param {Workflow} workflow + * @param {*} options + */ +function updateTaskProxy (taskProxy, workflow, options) { + const node = workflow.lookup[taskProxy.id] + if (node) { + mergeWith(node, taskProxy, mergeWithCustomizer) + } +} + +/** + * @param {string} taskProxyId + * @param {Workflow} workflow + * @param {*} options + */ +function removeTaskProxy (taskProxyId, workflow, options) { + const taskProxy = workflow.lookup[taskProxyId] + if (taskProxy) { + recursivelyRemoveNode(taskProxy, workflow.lookup) + // Remember that we attach task proxies children of 'root' directly to a cycle point! + if (taskProxy.node.firstParent) { + const parent = findTaskProxyParent(taskProxy, workflow) + parent.children.splice(parent.children.indexOf(taskProxy), 1) + } + Vue.delete(workflow.lookup, taskProxyId) + } +} + +/** + * @param {String} familyProxyId + * @param {Workflow} workflow + * @param {*} options + */ +function removeFamilyProxy (familyProxyId, workflow, options) { + let node + let nodeId + let parentId + // NOTE: when deleting the root family, we can also remove the entire cycle point + if (familyProxyId.endsWith('|root')) { + // 0 has the owner, 1 has the workflow Id, 2 has the cycle point, and 3 the family name + const [owner, workflowId] = familyProxyId.split('|') + nodeId = getCyclePointId({ id: familyProxyId }) + node = workflow.lookup[nodeId] + parentId = `${owner}|${workflowId}` + } else { + nodeId = familyProxyId + node = workflow.lookup[nodeId] + if (node && node.node && node.node.firstParent) { + if (node.node.firstParent.name === FAMILY_ROOT) { + parentId = getCyclePointId(node) + } else { + parentId = node.node.firstParent.id } } } + if (node) { + recursivelyRemoveNode(node, workflow.lookup) + const parent = workflow.lookup[parentId] + // If the parent has already been removed from the lookup map, there won't be any parent here + if (parent) { + parent.children.splice(parent.children.indexOf(node), 1) + } + Vue.delete(workflow.lookup, node.id) + } +} + +// --- Jobs + +/** + * @param {JobNode} job + * @param {Workflow} workflow + * @param {*} options + */ +function addJob (job, workflow, options) { + if (!workflow.lookup[job.id]) { + Vue.set(workflow.lookup, job.id, job) + if (job.node.firstParent) { + const parent = workflow.lookup[job.node.firstParent.id] + const insertIndex = sortedIndexBy( + parent.children, + job, + (j) => `${j.node.submitNum}`) + parent.children.splice(parent.children.length - insertIndex, 0, job) + } + } } +/** + * @param {JobNode} job + * @param {Workflow} workflow + * @param {*} options + */ +function updateJob (job, workflow, options) { + const node = workflow.lookup[job.id] + if (node) { + mergeWith(node, job, mergeWithCustomizer) + } +} + +/** + * @param {string} jobId + * @param {Workflow} workflow + * @param {*} options + */ +function removeJob (jobId, workflow, options) { + const job = workflow.lookup[jobId] + if (job) { + recursivelyRemoveNode(job, workflow.lookup) + if (job.node.firstParent) { + const parent = workflow.lookup[job.node.firstParent.id] + // prevent runtime error in case the parent was already removed + if (parent) { + // re-calculate the job's task progress + parent.children.splice(parent.children.indexOf(job), 1) + } + } + Vue.delete(workflow.lookup, jobId) + } +} export { - populateTreeFromGraphQLData + clear, + isEmpty, + addWorkflow, + addCyclePoint, + updateCyclePoint, + removeCyclePoint, + tallyCyclePointStates, + addFamilyProxy, + updateFamilyProxy, + removeFamilyProxy, + addTaskProxy, + updateTaskProxy, + removeTaskProxy, + addJob, + updateJob, + removeJob } diff --git a/src/components/cylc/tree/tree-nodes.js b/src/components/cylc/tree/nodes.js similarity index 96% rename from src/components/cylc/tree/tree-nodes.js rename to src/components/cylc/tree/nodes.js index f08233c4d..1aea5e2ca 100644 --- a/src/components/cylc/tree/tree-nodes.js +++ b/src/components/cylc/tree/nodes.js @@ -343,24 +343,11 @@ function createJobNode (job) { } } -/** - * @param {?WorkflowGraphQLData} workflow - * @returns {boolean} - */ -function containsTreeData (workflow) { - return workflow !== undefined && - workflow !== null && - workflow.cyclePoints && Array.isArray(workflow.cyclePoints) && - workflow.familyProxies && Array.isArray(workflow.familyProxies) && - workflow.taskProxies && Array.isArray(workflow.taskProxies) -} - export { createWorkflowNode, createCyclePointNode, createFamilyProxyNode, createTaskProxyNode, createJobNode, - containsTreeData, getCyclePointId } diff --git a/src/components/cylc/workflow/Toolbar.vue b/src/components/cylc/workflow/Toolbar.vue index 428d21868..6dd4da34f 100644 --- a/src/components/cylc/workflow/Toolbar.vue +++ b/src/components/cylc/workflow/Toolbar.vue @@ -91,14 +91,14 @@ along with this program. If not, see . {{ view.icon }} - {{ view.title }} + {{ view.name }} @@ -123,8 +123,6 @@ along with this program. If not, see . diff --git a/src/views/GraphiQL.vue b/src/views/GraphiQL.vue index b9c4a4d38..3c8701327 100644 --- a/src/views/GraphiQL.vue +++ b/src/views/GraphiQL.vue @@ -20,14 +20,15 @@ along with this program. If not, see . diff --git a/src/views/NotFound.vue b/src/views/NotFound.vue index 6ea27fe07..026241b9a 100644 --- a/src/views/NotFound.vue +++ b/src/views/NotFound.vue @@ -46,10 +46,10 @@ along with this program. If not, see . diff --git a/src/views/UserProfile.vue b/src/views/UserProfile.vue index 3a81e446d..b33eb6ff3 100644 --- a/src/views/UserProfile.vue +++ b/src/views/UserProfile.vue @@ -193,23 +193,23 @@ along with this program. If not, see . diff --git a/src/components/cylc/workflow/Toolbar.vue b/src/components/cylc/workflow/Toolbar.vue index 6dd4da34f..e11725ea8 100644 --- a/src/components/cylc/workflow/Toolbar.vue +++ b/src/components/cylc/workflow/Toolbar.vue @@ -125,6 +125,7 @@ along with this program. If not, see . import { mapGetters, mapState } from 'vuex' import { mdiAppleKeyboardCommand, + mdiBroadcast, mdiFileTree, mdiMicrosoftXboxControllerMenu, mdiPause, @@ -137,6 +138,7 @@ import toolbar from '@/mixins/toolbar' import WorkflowState from '@/model/WorkflowState.model' import TreeView from '@/views/Tree' import MutationsView from '@/views/Mutations' +import SubscriptionsView from '@/components/cylc/Subscriptions' import { mutationStatus @@ -174,7 +176,10 @@ export default { { name: MutationsView.name, icon: mdiAppleKeyboardCommand - + }, + { + name: SubscriptionsView.name, + icon: mdiBroadcast } ] }), diff --git a/src/views/Workflow.vue b/src/views/Workflow.vue index 51c5f20f8..a6066f959 100644 --- a/src/views/Workflow.vue +++ b/src/views/Workflow.vue @@ -46,6 +46,13 @@ along with this program. If not, see . :workflow-name="workflowName" tab-title="mutations" /> + @@ -63,6 +70,7 @@ import Lumino from '@/components/cylc/workflow/Lumino' import Toolbar from '@/components/cylc/workflow/Toolbar' import CylcObjectMenu from '@/components/cylc/cylcObject/Menu' import MutationsView from '@/views/Mutations' +import SubscriptionsView from '@/components/cylc/Subscriptions' import TreeView from '@/views/Tree' import { mapState } from 'vuex' @@ -78,6 +86,7 @@ export default { Lumino, TreeView, MutationsView, + SubscriptionsView, Toolbar }, metaInfo () { @@ -106,6 +115,12 @@ export default { .entries(this.widgets) .filter(([id, type]) => type === MutationsView.name) .map(([id, type]) => id) + }, + subscriptionsWidgets () { + return Object + .entries(this.widgets) + .filter(([id, type]) => type === SubscriptionsView.name) + .map(([id, type]) => id) } }, beforeRouteEnter (to, from, next) { @@ -143,6 +158,9 @@ export default { case MutationsView.name: Vue.set(this.widgets, createWidgetId(), MutationsView.name) break + case SubscriptionsView.name: + Vue.set(this.widgets, createWidgetId(), SubscriptionsView.name) + break default: throw Error(`Unknown view "${view}"`) }