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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Metadata
+
+
+
+ - Title
+ - {{ taskMetadata.title }}
+
+ - Description
+ - {{ taskMetadata.description }}
+
+ - URL
+ -
+
+
+ {{ taskMetadata.URL }}
+
+
+
+
+ - {{ key }}
+ - {{ value }}
+
+
+
+
+
+
+
+
+
+ Prerequisites
+
+
+
+ -
+ {{ prereq.expression.replace(/c/g, '') }}
+
+ -
+
+ {{ condition.exprAlias.replace(/c/, '') }}
+
+
+ {{ condition.taskId }}:{{ condition.reqState }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Outputs
+
+
+
+ -
+ {{ output.label }}
+
+
+
+
+
+
+
+
+ Completion
+
+
+
+
+ -
+
+
+
+ {{ line }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 .
.