diff --git a/changes.d/1886.feat.md b/changes.d/1886.feat.md new file mode 100644 index 000000000..9f20cc650 --- /dev/null +++ b/changes.d/1886.feat.md @@ -0,0 +1 @@ +Added an info view to display task information including metadata, prerequisites and outputs. diff --git a/cypress/component/info.cy.js b/cypress/component/info.cy.js new file mode 100644 index 000000000..3bd79dc37 --- /dev/null +++ b/cypress/component/info.cy.js @@ -0,0 +1,282 @@ +/** + * 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 InfoComponent from '@/components/cylc/Info.vue' +import { Tokens } from '@/utils/uid' + +const DESCRIPTION = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi. +` +const TOKENS = new Tokens('~user/workflow//1234/foo') +const TASK = { + id: TOKENS.id, + name: TOKENS.task, + tokens: TOKENS, + node: { + state: 'running', + task: { + meta: { + title: 'My Foo', + description: DESCRIPTION, + URL: 'https://cylc.org', + customMeta: { + answer: '42', + question: 'mutually exclusive', + } + } + }, + prerequisites: [ + { + satisfied: false, + expression: '(c0 & c1) | c2', + conditions: [ + { + taskId: 'a', + message: 'succeeded', + reqState: 'succeeded', + exprAlias: 'c0', + satisfied: true, + }, + { + taskId: 'b', + message: 'custom message', + reqState: 'custom_output', + exprAlias: 'c1', + satisfied: false, + }, + { + taskId: 'a', + message: 'expired', + reqState: 'expired', + exprAlias: 'c2', + satisfied: false, + }, + ], + }, + { + satisfied: true, + expression: 'c0', + conditions: [ + { + taskId: 'x', + message: 'succeeded', + reqState: 'succeeded', + exprAlias: 'c0', + satisfied: true, + }, + ], + }, + ], + outputs: [ + { + label: 'started', + message: 'started', + satisfied: true, + }, + { + label: 'succeeded', + message: 'succeeded', + satisfied: false, + }, + { + label: 'failed', + message: 'failed', + satisfied: false, + }, + { + label: 'x', + message: 'xxx', + satisfied: true, + } + ], + runtime: { + completion: '(succeeded and x) or failed' + } + }, + children: [ + { + id: TOKENS.clone({ job: '01' }).id, + tokens: TOKENS.clone({ job: '01' }), + name: '01', + node: { + state: 'failed' + } + }, + { + id: TOKENS.clone({ job: '02' }).id, + tokens: TOKENS.clone({ job: '02' }), + name: '02', + node: { + state: 'succeeded' + } + }, + ], +} + +describe('Info component', () => { + it('displays task information', () => { + cy.vmount(InfoComponent, { + props: { + task: TASK, + class: 'job_theme--default', + // NOTE: expand all sections by default + panelExpansion: [0, 1, 2, 3], + } + }) + + // there should be a task icon (running) + cy.get('.c-graph-node .c8-task.running').should('be.visible') + + // and two job icons (succeeded & failed) + cy.get('.c-graph-node .c-job').should('have.length', 2) + .get('.c-graph-node .c-job .failed').should('be.visible') + .get('.c-graph-node .c-job .succeeded').should('be.visible') + + // the metadata panel + cy.get('.metadata-panel.v-expansion-panel--active').should('be.visible') + .contains('My Foo') + .get('.metadata-panel') // the description should be displayed + .contains(/Lorem ipsum dolor sit amet.*/) + .get('.metadata-panel a:first') // the URL should be an anchor + .should('have.attr', 'href', 'https://cylc.org') + .contains(/^https:\/\/cylc.org$/) + + // the prerequisites panel + cy.get('.prerequisites-panel.v-expansion-panel--active').should('be.visible') + .find('.prerequisite-alias.condition') + .should('have.length', 6) + .then((selector) => { + expect(selector[0].innerText).to.equal('(0 & 1) | 2') + expect(selector[0]).to.not.have.class('satisfied') + + expect(selector[1].innerText).to.equal('0 a:succeeded') + expect(selector[1]).to.have.class('satisfied') + + expect(selector[2].innerText).to.equal('1 b:custom_output') + expect(selector[2]).to.not.have.class('satisfied') + + expect(selector[3].innerText).to.equal('2 a:expired') + expect(selector[3]).to.not.have.class('satisfied') + + expect(selector[4].innerText).to.equal('0') + expect(selector[4]).to.have.class('satisfied') + + expect(selector[5].innerText).to.equal('0 x:succeeded') + expect(selector[5]).to.have.class('satisfied') + }) + + // the outputs panel + cy.get('.outputs-panel.v-expansion-panel--active').should('be.visible') + .find('.condition') + .should('have.length', 4) + .then((selector) => { + expect(selector[0]).to.contain('started') + expect(selector[0].classList.toString()).to.equal('condition satisfied') + + expect(selector[1]).to.contain('succeeded') + expect(selector[1].classList.toString()).to.equal('condition') + + expect(selector[2]).to.contain('failed') + expect(selector[2].classList.toString()).to.equal('condition') + + expect(selector[3]).to.contain('x') + expect(selector[3].classList.toString()).to.equal('condition satisfied') + }) + + // the completion panel + cy.get('.completion-panel.v-expansion-panel--active').should('be.visible') + .find('.condition') + .should('have.length', 5) + .then((selector) => { + expect(selector[0]).to.contain('(') + expect(selector[0].classList.toString()).to.equal('condition blank') + + expect(selector[1]).to.contain('succeeded') + expect(selector[1].classList.toString()).to.equal('condition') + + expect(selector[2]).to.contain('and x') + expect(selector[2].classList.toString()).to.equal('condition satisfied') + + expect(selector[3]).to.contain(')') + expect(selector[3].classList.toString()).to.equal('condition blank') + + expect(selector[4]).to.contain('or failed') + expect(selector[4].classList.toString()).to.equal('condition') + }) + }) + + it('should expand sections as intended', () => { + const spy = cy.spy() + cy.vmount(InfoComponent, { + props: { + task: TASK, + class: 'job_theme--default' + }, + listeners: { + 'update:panelExpansion': spy, + } + }).as('wrapper') + + // ONLY the metadata panel should be expanded by default + cy.get('.v-expansion-panel--active') + .should('have.length', 1) + .should('have.class', 'metadata-panel') + + // the update:panelExpansion event should be emitted when a panel is + // expanded/collapsed + cy.get('.prerequisites-panel') + .find('button') + .should('be.visible') + .click({ force: true }) + .get('@wrapper').then(({ wrapper }) => { + expect( + wrapper.emitted('update:panelExpansion')[0][0] + ).to.deep.equal([0, 1]) + }) + }) + + it('should work for a task with no data', () => { + // ensure the component can be mounted without errors for empty states + // i.e. no metadata, prerequisites, outputs or jobs + const tokens = new Tokens('~user/workflow//1234/foo') + const task = { + id: tokens.id, + name: tokens.task, + tokens, + node: { + task: { + meta: { + customMeta: {} + } + }, + prerequisites: [], + outputs: [], + }, + children: [], + } + + cy.vmount(InfoComponent, { + props: { + task, + class: 'job_theme--default', + // NOTE: expand all sections by default + panelExpansion: [0, 1, 2], + } + }) + }) +}) diff --git a/src/components/cylc/Info.vue b/src/components/cylc/Info.vue new file mode 100644 index 000000000..04581ec43 --- /dev/null +++ b/src/components/cylc/Info.vue @@ -0,0 +1,315 @@ + + + + + + + diff --git a/src/components/cylc/commandMenu/Menu.vue b/src/components/cylc/commandMenu/Menu.vue index a75a8e065..a3e532ce9 100644 --- a/src/components/cylc/commandMenu/Menu.vue +++ b/src/components/cylc/commandMenu/Menu.vue @@ -224,7 +224,7 @@ export default { methods: { isEditable (mutation, authorised) { - return mutation.name !== 'log' && !this.isDisabled(mutation, authorised) + return mutation.name !== 'log' && mutation.name !== 'info' && !this.isDisabled(mutation, authorised) }, isDisabled (mutation, authorised) { if (!authorised) { @@ -271,6 +271,23 @@ export default { } ) }) + } else if (mutation.name === 'info') { + this.$router.push({ + name: 'Workspace', + params: { + workflowName: this.node.tokens.workflow + } + }).then(() => { + eventBus.emit( + 'add-view', + { + name: 'Info', + initialOptions: { + requestedTokens: this.node.tokens || undefined + } + } + ) + }) } else { mutate( mutation, diff --git a/src/services/mock/json/index.cjs b/src/services/mock/json/index.cjs index 522885114..8f6a3f766 100644 --- a/src/services/mock/json/index.cjs +++ b/src/services/mock/json/index.cjs @@ -24,6 +24,7 @@ const { LogData } = require('./logData.cjs') const { LogFiles } = require('./logFiles.cjs') const analysisQuery = require('./analysisQuery.json') const ganttQuery = require('./ganttQuery.json') +const InfoViewSubscription = require('./infoView.json') const workflows = [workflowOne, ...workflowsMulti] const analysisTaskQuery = analysisQuery.taskQuery @@ -43,5 +44,6 @@ module.exports = { analysisTaskQuery, analysisJobQuery, analysisQuery, - ganttQuery + ganttQuery, + InfoViewSubscription } diff --git a/src/services/mock/json/infoView.json b/src/services/mock/json/infoView.json new file mode 100644 index 000000000..05b7d1bfa --- /dev/null +++ b/src/services/mock/json/infoView.json @@ -0,0 +1,85 @@ +{ + "deltas": { + "id": "~user/one", + "added": { + "taskProxies": [ + { + "id": "~user/one//20000102T0000Z/failed", + "state": "failed", + "isHeld": false, + "isQueued": false, + "isRunahead": false, + + "task": { + "meanElapsedTime": "10", + "meta": { + "title": "Failed Task", + "description": "A task that always fails!", + "URL": "https://cylc.org", + "userDefined": { + "my custom field": "My custom value!" + } + } + }, + + "jobs": [ + { + "id": "~user/one//20000102T0000Z/failed/01", + "jobId": "1234", + "startedTime": "0", + "state": "failed" + } + ], + + "prerequisites": [ + { + "satisfied": true, + "expression": "c0 & c1", + "conditions": [ + { + "taskId": "20000102T0000Z/succeeded", + "reqState": "succeeded", + "exprAlias": "c0", + "satisfied": true + }, + { + "taskId": "20000102T0000Z/eventually_succeeded", + "reqState": "succeeded", + "exprAlias": "c1", + "satisfied": true + } + ] + } + ], + + "outputs": [ + { + "label": "submitted", + "satisfied": true + }, + { + "label": "started", + "satisfied": true + }, + { + "label": "succeeded", + "satisfied": false + }, + { + "label": "failed", + "satisfied": true + }, + { + "label": "expired", + "satisfied": false + } + ], + + "runtime": { + "completion": "succeeded" + } + } + ] + } + } +} diff --git a/src/utils/aotf.js b/src/utils/aotf.js index e864b2b95..ccf42a6ab 100644 --- a/src/utils/aotf.js +++ b/src/utils/aotf.js @@ -35,7 +35,7 @@ import { mdiDelete, mdiEmail, mdiFileDocumentOutline, - mdiVectorPolylineEdit, + mdiInformationOutline, mdiMinusCircleOutline, mdiPause, mdiPauseCircleOutline, @@ -44,7 +44,8 @@ import { mdiPlaylistEdit, mdiRefreshCircle, mdiReload, - mdiStop + mdiStop, + mdiVectorPolylineEdit, } from '@mdi/js' import { Alert } from '@/model/Alert.model' @@ -128,6 +129,7 @@ export function getMutationIcon (name) { case 'clean': return mdiDelete case 'editRuntime': return mdiPlaylistEdit case 'hold': return mdiPauseCircleOutline // to distinguish from pause + case 'info': return mdiInformationOutline case 'kill': return mdiCloseCircle case 'log': return mdiFileDocumentOutline case 'message': return mdiEmail @@ -188,6 +190,7 @@ export const primaryMutations = { 'trigger', 'kill', 'log', + 'info', 'set' ] } @@ -314,6 +317,13 @@ export const dummyMutations = [ _requiresInfo: false, _validStates: WorkflowStateNames, }, + { + name: 'info', + description: 'View task information.', + args: [], + _appliesTo: [cylcObjects.Namespace], + _requiresInfo: false + }, ] /** @@ -323,7 +333,7 @@ export const dummyMutations = [ */ const dummyMutationsPermissionsMap = Object.freeze({ broadcast: Object.freeze(['editRuntime']), - read: Object.freeze(['log']) + read: Object.freeze(['log', 'info']) }) /** diff --git a/src/utils/outputs.js b/src/utils/outputs.js new file mode 100644 index 000000000..deb0b1173 --- /dev/null +++ b/src/utils/outputs.js @@ -0,0 +1,71 @@ +/** + * 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 . + */ + +/** Functionality relating to task output formatting. **/ + +/** Format a completion expression for display. + * + * @param {str} completion - The task's completion expression. + * @param {Array} outputs - The task's outputs as obtained from GraphQL as an + * array of objects with "label" and "satisfied" attributes. + * + * @returns {Array} - [isSatisfied, indentLevel, text] + **/ + +export function formatCompletion (completion, outputs) { + // the array to return + const lines = [] + // indent level of the expression + let indent = 0 + // text yet to be added to the return result + let buffer = '' + + // break the completion expression down into parts and iterate over them + for (let part of completion.split(/(and|or|\(|\))/)) { + part = part.trim() + + if (!part) { + continue + } + + if (part === '(') { + // open bracket + lines.push([null, indent, `${buffer}(`]) + buffer = '' + indent = indent + 1 + } else if (part === ')') { + // close bracket + indent = indent - 1 + lines.push([null, indent, `${buffer})`]) + buffer = '' + } else if (part === 'and' || part === 'or') { + // local operator + buffer = `${part} ` + } else { + // Cylc output -> look it up in the outputs Array + for (const output of outputs) { + if (output.label === part) { + lines.push([output.satisfied, indent, `${buffer}${part}`]) + break + } + } + buffer = '' + } + } + + return lines +} diff --git a/src/views/Info.vue b/src/views/Info.vue new file mode 100644 index 000000000..3b60bfa5e --- /dev/null +++ b/src/views/Info.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/views/UserProfile.vue b/src/views/UserProfile.vue index 3d34de22d..c21f896ea 100644 --- a/src/views/UserProfile.vue +++ b/src/views/UserProfile.vue @@ -195,7 +195,7 @@ along with this program. If not, see . .