Merge branch '18881-process-runtime-status'. Closes #18881
authorLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 12 Apr 2022 19:29:17 +0000 (16:29 -0300)
committerLucas Di Pentima <lucas.dipentima@curii.com>
Tue, 12 Apr 2022 19:29:17 +0000 (16:29 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas.dipentima@curii.com>

15 files changed:
cypress/integration/collection.spec.js
cypress/integration/process.spec.js
cypress/support/commands.js
src/common/custom-theme.ts
src/models/container.ts
src/models/runtime-status.ts [new file with mode: 0644]
src/store/process-panel/process-panel-actions.ts
src/store/processes/process.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resource-type-filters/resource-type-filters.ts
src/views-components/data-explorer/renderers.test.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/process-runtime-status/process-runtime-status.tsx [new file with mode: 0644]
src/views/process-panel/process-information-card.tsx

index 74acd056ffdd21e45f8f8cafb543e7447542982e..39a2af4209db4d3b4b5b15f5d64630ee88c40334 100644 (file)
@@ -252,8 +252,8 @@ describe('Collection panel tests', function () {
                             .and('not.contain', 'anotherKey: anotherValue');
                         // Check that the file listing show both read & write operations
                         cy.get('[data-cy=collection-files-panel]').within(() => {
-                            cy.wait(1000);
-                            cy.root().should('contain', fileName);
+                            cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
+                                .should('contain', fileName);
                             if (isWritable) {
                                 cy.get('[data-cy=upload-button]')
                                     .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
@@ -335,7 +335,7 @@ describe('Collection panel tests', function () {
                 ];
                 eachPair(names, (from, to) => {
                     cy.get('[data-cy=collection-files-panel]')
-                        .contains(`${from}`).rightclick();
+                        .contains(`${from}`).rightclick({force: true});
                     cy.get('[data-cy=context-menu]')
                         .contains('Rename')
                         .click();
@@ -842,7 +842,7 @@ describe('Collection panel tests', function () {
 
                 cy.get('[data-cy=form-submit-btn]').click();
 
-                cy.get('.layout-pane-primary', { wait: 12000 }).contains('Projects').click();
+                cy.get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
 
                 cy.get('main').contains(`Files extracted from: ${this.collection.name}`).should('exist');
             });
@@ -976,7 +976,6 @@ describe('Collection panel tests', function () {
                             cy.get('[data-cy=form-submit-btn]').should('not.exist');
                             cy.get('[data-cy=collection-files-right-panel]')
                                  .contains('5mb_b.bin').should('exist');
-                            
                         });
                     });
                 });
index 75c318dbfa3d395c4fd2c6faab1431f8d950cd43..3234f7c4bd5bcd04a89df3274642b467a2e3a2f6 100644 (file)
@@ -191,4 +191,58 @@ describe('Process tests', function() {
             });
         });
     });
+
+    it('should show runtime status indicators', function() {
+        // Setup running container with runtime_status error & warning messages
+        createContainerRequest(
+            activeUser,
+            'test_container_request',
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .as('containerRequest')
+        .then(function(containerRequest) {
+            expect(containerRequest.state).to.equal('Committed');
+            expect(containerRequest.container_uuid).not.to.be.equal('');
+
+            cy.getContainer(activeUser.token, containerRequest.container_uuid)
+            .then(function(queuedContainer) {
+                expect(queuedContainer.state).to.be.equal('Queued');
+            });
+            cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
+                state: 'Locked'
+            }).then(function(lockedContainer) {
+                expect(lockedContainer.state).to.be.equal('Locked');
+
+                cy.updateContainer(adminUser.token, lockedContainer.uuid, {
+                    state: 'Running',
+                    runtime_status: {
+                        error: 'Something went wrong',
+                        errorDetail: 'Process exited with status 1',
+                        warning: 'Free disk space is low',
+                    }
+                })
+                .as('runningContainer')
+                .then(function(runningContainer) {
+                    expect(runningContainer.state).to.be.equal('Running');
+                    expect(runningContainer.runtime_status).to.be.deep.equal({
+                        'error': 'Something went wrong',
+                        'errorDetail': 'Process exited with status 1',
+                        'warning': 'Free disk space is low',
+                    });
+                });
+            })
+        });
+        // Test that the UI shows the error and warning messages
+        cy.getAll('@containerRequest', '@runningContainer').then(function([containerRequest]) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-runtime-status-error]')
+                .should('contain', 'Something went wrong')
+                .and('contain', 'Process exited with status 1');
+            cy.get('[data-cy=process-runtime-status-warning]')
+                .should('contain', 'Free disk space is low')
+                .and('contain', 'No additional warning details available');
+        });
+    });
 });
\ No newline at end of file
index 5a2428b2c5e7764f1f8c1c30cb9a221a7bb61ffc..a28308e3cd6f2db7e9af30ba1bd5068e15cceb00 100644 (file)
@@ -135,11 +135,7 @@ Cypress.Commands.add(
 
 Cypress.Commands.add(
     "getCollection", (token, uuid) => {
-        return cy.doRequest('GET', `/arvados/v1/collections/${uuid}`, null, {}, token)
-            .its('body')
-            .then(function (theCollection) {
-                return theCollection;
-            })
+        return cy.getResource(token, 'collections', uuid)
     }
 )
 
@@ -160,6 +156,20 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    "getContainer", (token, uuid) => {
+        return cy.getResource(token, 'containers', uuid)
+    }
+)
+
+Cypress.Commands.add(
+    "updateContainer", (token, uuid, data) => {
+        return cy.updateResource(token, 'containers', uuid, {
+            container: JSON.stringify(data)
+        })
+    }
+)
+
 Cypress.Commands.add(
     'createContainerRequest', (token, data) => {
         return cy.createResource(token, 'container_requests', {
@@ -212,13 +222,23 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    "getResource", (token, suffix, uuid) => {
+        return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
+            .its('body')
+            .then(function (resource) {
+                return resource;
+            })
+    }
+)
+
 Cypress.Commands.add(
     "createResource", (token, suffix, data) => {
         return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true)
-            .its('body').as('resource')
-            .then(function () {
-                createdResources.push({suffix, uuid: this.resource.uuid});
-                return this.resource;
+            .its('body')
+            .then(function (resource) {
+                createdResources.push({suffix, uuid: resource.uuid});
+                return resource;
             })
     }
 )
@@ -226,19 +246,19 @@ Cypress.Commands.add(
 Cypress.Commands.add(
     "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
         return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode)
-            .its('body').as('resource')
-            .then(function () {
-                return this.resource;
+            .its('body')
+            .then(function (resource) {
+                return resource;
             })
     }
 )
 
 Cypress.Commands.add(
     "updateResource", (token, suffix, uuid, data) => {
-        return cy.doRequest('PUT', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
-            .its('body').as('resource')
-            .then(function () {
-                return this.resource;
+        return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
+            .its('body')
+            .then(function (resource) {
+                return resource;
             })
     }
 )
index 74dee7f6c9cec71047ad0373162b5d10dc6786d1..b0703237af97f4c603dffdf42548302cb67a7eac 100644 (file)
@@ -23,13 +23,18 @@ export interface ArvadosTheme extends Theme {
 
 interface Colors {
     green700: string;
+    yellow100: string;
     yellow700: string;
+    yellow900: string;
+    red100: string;
     red900: string;
     blue500: string;
+    grey500: string;
     purple: string;
 }
 
 const arvadosPurple = '#361336';
+const grey500 = grey["500"];
 const grey600 = grey["600"];
 const grey700 = grey["700"];
 const grey900 = grey["900"];
@@ -41,9 +46,13 @@ export const themeOptions: ArvadosThemeOptions = {
     customs: {
         colors: {
             green700: green["700"],
+            yellow100: yellow["100"],
             yellow700: yellow["700"],
+            yellow900: yellow["900"],
+            red100: red["100"],
             red900: red['900'],
             blue500: blue['500'],
+            grey500: grey500,
             purple: arvadosPurple
         }
     },
index e931c4bfd84bfec9c67d53b227f7e58b46f8aaf4..127c250886f1b1c5086080bb006ece4f0a7e7308 100644 (file)
@@ -6,6 +6,7 @@ import { Resource, ResourceKind } from "./resource";
 import { MountType } from 'models/mount-types';
 import { RuntimeConstraints } from "models/runtime-constraints";
 import { SchedulingParameters } from './scheduling-parameters';
+import { RuntimeStatus } from "./runtime-status";
 
 export enum ContainerState {
     QUEUED = 'Queued',
@@ -27,6 +28,7 @@ export interface ContainerResource extends Resource {
     outputPath: string;
     mounts: MountType[];
     runtimeConstraints: RuntimeConstraints;
+    runtimeStatus: RuntimeStatus;
     schedulingParameters: SchedulingParameters;
     output: string | null;
     containerImage: string;
diff --git a/src/models/runtime-status.ts b/src/models/runtime-status.ts
new file mode 100644 (file)
index 0000000..c659930
--- /dev/null
@@ -0,0 +1,11 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface RuntimeStatus {
+    error?: string;
+    warning?: string;
+    activity?: string;
+    errorDetail?: string;
+    warningDetail?: string;
+}
index 962f5dfcf3b6a123aaaa3a7993ef7844bf77a39c..e77c300d8c07cc50a73da44734a59b69b1737211 100644 (file)
@@ -56,6 +56,8 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL
     ProcessStatus.COMPLETED,
     ProcessStatus.FAILED,
     ProcessStatus.RUNNING,
-    ProcessStatus.LOCKED,
+    ProcessStatus.ONHOLD,
+    ProcessStatus.FAILING,
+    ProcessStatus.WARNING,
     ProcessStatus.CANCELLED
 ]);
index 60505be0c4c0f6a20ca160501206b8c28994b9fd..b72a0c2b10b9a280ad4a59c6167cd45d152ff520 100644 (file)
@@ -19,10 +19,12 @@ export enum ProcessStatus {
     CANCELLED = 'Cancelled',
     COMPLETED = 'Completed',
     DRAFT = 'Draft',
+    FAILING = 'Failing',
     FAILED = 'Failed',
-    LOCKED = 'Locked',
+    ONHOLD = 'On hold',
     QUEUED = 'Queued',
     RUNNING = 'Running',
+    WARNING = 'Warning',
     UNKNOWN = 'Unknown',
 }
 
@@ -71,44 +73,59 @@ export const getProcessRuntime = ({ container }: Process) => {
     }
 };
 
-export const getProcessStatusColor = (status: string, { customs, palette }: ArvadosTheme) => {
+export const getProcessStatusColor = (status: string, { customs }: ArvadosTheme) => {
     switch (status) {
         case ProcessStatus.RUNNING:
             return customs.colors.blue500;
         case ProcessStatus.COMPLETED:
             return customs.colors.green700;
+        case ProcessStatus.WARNING:
+            return customs.colors.yellow700;
+        case ProcessStatus.FAILING:
         case ProcessStatus.CANCELLED:
         case ProcessStatus.FAILED:
             return customs.colors.red900;
         default:
-            return palette.grey["500"];
+            return customs.colors.grey500;
     }
 };
 
 export const getProcessStatus = ({ containerRequest, container }: Process): ProcessStatus => {
     switch (true) {
+        case containerRequest.state === ContainerRequestState.FINAL &&
+            container?.state !== ContainerState.COMPLETE:
+            // Request was finalized before its container started (or the
+            // container was cancelled)
+            return ProcessStatus.CANCELLED;
+
         case containerRequest.state === ContainerRequestState.UNCOMMITTED:
             return ProcessStatus.DRAFT;
 
-        case container && container.state === ContainerState.COMPLETE && container.exitCode === 0:
-            return ProcessStatus.COMPLETED;
+        case container?.state === ContainerState.COMPLETE:
+            if (container?.exitCode === 0) {
+                return ProcessStatus.COMPLETED;
+            }
+            return ProcessStatus.FAILED;
 
-        case containerRequest.priority === 0:
-        case container && container.state === ContainerState.CANCELLED:
+        case container?.state === ContainerState.CANCELLED:
             return ProcessStatus.CANCELLED;
 
-        case container && container.state === ContainerState.QUEUED:
+        case container?.state === ContainerState.QUEUED ||
+            container?.state === ContainerState.LOCKED:
+            if (containerRequest.priority === 0) {
+                return ProcessStatus.ONHOLD;
+            }
             return ProcessStatus.QUEUED;
 
-        case container && container.state === ContainerState.LOCKED:
-            return ProcessStatus.LOCKED;
-
-        case container && container.state === ContainerState.RUNNING:
+        case container?.state === ContainerState.RUNNING:
+            if (!!container?.runtimeStatus.error) {
+                return ProcessStatus.FAILING;
+            }
+            if (!!container?.runtimeStatus.warning) {
+                return ProcessStatus.WARNING;
+            }
             return ProcessStatus.RUNNING;
 
-        case container && container.state === ContainerState.COMPLETE && container.exitCode !== 0:
-            return ProcessStatus.FAILED;
-
         default:
             return ProcessStatus.UNKNOWN;
     }
index be569b49ff0276aa031f1467aec896d08c32989b..ccfa4fff9fcc4d0a34cef6a2af20f7388344a425 100644 (file)
@@ -17,7 +17,11 @@ import { OrderBuilder, OrderDirection } from "services/api/order-builder";
 import { FilterBuilder, joinFilters } from "services/api/filter-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
 import { updateFavorites } from "store/favorites/favorites-actions";
-import { IS_PROJECT_PANEL_TRASHED, projectPanelActions, getProjectPanelCurrentUuid } from 'store/project-panel/project-panel-action';
+import {
+    IS_PROJECT_PANEL_TRASHED,
+    projectPanelActions,
+    getProjectPanelCurrentUuid
+} from 'store/project-panel/project-panel-action';
 import { Dispatch, MiddlewareAPI } from "redux";
 import { ProjectResource } from "models/project";
 import { updateResources } from "store/resources/resources-actions";
@@ -29,7 +33,10 @@ import { ListResults } from 'services/common-service/common-service';
 import { loadContainers } from 'store/processes/processes-actions';
 import { ResourceKind } from 'models/resource';
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
-import { serializeResourceTypeFilters, ProcessStatusFilter } from 'store/resource-type-filters/resource-type-filters';
+import {
+    serializeResourceTypeFilters,
+    buildProcessStatusFilters
+} from 'store/resource-type-filters/resource-type-filters';
 import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
@@ -116,27 +123,10 @@ export const getFilters = (dataExplorer: DataExplorer) => {
         .getFilters();
 
     // Filter by container status
-    const fb = new FilterBuilder();
-    switch (activeStatusFilter) {
-        case ProcessStatusFilter.COMPLETED: {
-            fb.addEqual('container.state', 'Complete', GroupContentsResourcePrefix.PROCESS);
-            fb.addEqual('container.exit_code', '0', GroupContentsResourcePrefix.PROCESS);
-            break;
-        }
-        case ProcessStatusFilter.FAILED: {
-            fb.addEqual('container.state', 'Complete', GroupContentsResourcePrefix.PROCESS);
-            fb.addDistinct('container.exit_code', '0', GroupContentsResourcePrefix.PROCESS);
-            break;
-        }
-        case ProcessStatusFilter.CANCELLED:
-        case ProcessStatusFilter.LOCKED:
-        case ProcessStatusFilter.QUEUED:
-        case ProcessStatusFilter.RUNNING: {
-            fb.addEqual('container.state', activeStatusFilter, GroupContentsResourcePrefix.PROCESS);
-            break;
-        }
-    }
-    const statusFilters = fb.getFilters();
+    const statusFilters = buildProcessStatusFilters(
+        new FilterBuilder(),
+        activeStatusFilter || '',
+        GroupContentsResourcePrefix.PROCESS).getFilters();
 
     return joinFilters(
         statusFilters,
index 71b00b2ed328ace73e8a0553adf9cb661bb2aeef..698515bde55f8fc945baa5ce3d9e40bddd22f623 100644 (file)
@@ -2,10 +2,29 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter, GroupTypeFilter } from './resource-type-filters';
+import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter, GroupTypeFilter, buildProcessStatusFilters, ProcessStatusFilter } from './resource-type-filters';
 import { ResourceKind } from 'models/resource';
 import { deselectNode } from 'models/tree';
 import { pipe } from 'lodash/fp';
+import { FilterBuilder } from 'services/api/filter-builder';
+
+describe("buildProcessStatusFilters", () => {
+    [
+        [ProcessStatusFilter.ALL, ""],
+        [ProcessStatusFilter.ONHOLD, `["state","!=","Final"],["priority","=","0"],["container.state","in",["Queued","Locked"]]`],
+        [ProcessStatusFilter.COMPLETED, `["container.state","=","Complete"],["container.exit_code","=","0"]`],
+        [ProcessStatusFilter.FAILED, `["container.state","=","Complete"],["container.exit_code","!=","0"]`],
+        [ProcessStatusFilter.QUEUED, `["container.state","=","Queued"],["priority","!=","0"]`],
+        [ProcessStatusFilter.CANCELLED, `["container.state","=","Cancelled"]`],
+        [ProcessStatusFilter.RUNNING, `["container.state","=","Running"]`],
+    ].forEach(([status, expected]) => {
+        it(`can filter "${status}" processes`, () => {
+            const filters = buildProcessStatusFilters(new FilterBuilder(), status);
+            expect(filters.getFilters())
+                .toEqual(expected);
+        })
+    });
+});
 
 describe("serializeResourceTypeFilters", () => {
     it("should serialize all filters", () => {
index e42a16d89863e494c22ecef9fc87c8f0329eabae..a39807d58238196a44a9c93e347927778ad4bf36 100644 (file)
@@ -11,6 +11,7 @@ import { getSelectedNodes } from 'models/tree';
 import { CollectionType } from 'models/collection';
 import { GroupContentsResourcePrefix } from 'services/groups-service/groups-service';
 import { ContainerState } from 'models/container';
+import { ContainerRequestState } from 'models/container-request';
 
 export enum ProcessStatusFilter {
     ALL = 'All',
@@ -18,7 +19,7 @@ export enum ProcessStatusFilter {
     FAILED = 'Failed',
     COMPLETED = 'Completed',
     CANCELLED = 'Cancelled',
-    LOCKED = 'Locked',
+    ONHOLD = 'On hold',
     QUEUED = 'Queued'
 }
 
@@ -95,12 +96,12 @@ export const getInitialProcessStatusFilters = pipe(
     (): DataTableFilters => createTree<DataTableFilterItem>(),
     pipe(
         initFilter(ProcessStatusFilter.ALL, '', true),
+        initFilter(ProcessStatusFilter.ONHOLD, '', false),
+        initFilter(ProcessStatusFilter.QUEUED, '', false),
         initFilter(ProcessStatusFilter.RUNNING, '', false),
-        initFilter(ProcessStatusFilter.FAILED, '', false),
         initFilter(ProcessStatusFilter.COMPLETED, '', false),
         initFilter(ProcessStatusFilter.CANCELLED, '', false),
-        initFilter(ProcessStatusFilter.QUEUED, '', false),
-        initFilter(ProcessStatusFilter.LOCKED, '', false),
+        initFilter(ProcessStatusFilter.FAILED, '', false),
     ),
 );
 
@@ -272,27 +273,32 @@ export const serializeSimpleObjectTypeFilters = (filters: Tree<DataTableFilterIt
         .map(objectTypeToResourceKind);
 };
 
-export const buildProcessStatusFilters = ( fb:FilterBuilder, activeStatusFilter:string ): FilterBuilder => {
+export const buildProcessStatusFilters = ( fb: FilterBuilder, activeStatusFilter: string, resourcePrefix?: string ): FilterBuilder => {
     switch (activeStatusFilter) {
+        case ProcessStatusFilter.ONHOLD: {
+            fb.addDistinct('state', ContainerRequestState.FINAL, resourcePrefix);
+            fb.addEqual('priority', '0', resourcePrefix);
+            fb.addIn('container.state', [ContainerState.QUEUED, ContainerState.LOCKED], resourcePrefix);
+            break;
+        }
         case ProcessStatusFilter.COMPLETED: {
-            fb.addEqual('container.state', ContainerState.COMPLETE);
-            fb.addEqual('container.exit_code', '0');
+            fb.addEqual('container.state', ContainerState.COMPLETE, resourcePrefix);
+            fb.addEqual('container.exit_code', '0', resourcePrefix);
             break;
         }
         case ProcessStatusFilter.FAILED: {
-            fb.addEqual('container.state', ContainerState.COMPLETE);
-            fb.addDistinct('container.exit_code', '0');
+            fb.addEqual('container.state', ContainerState.COMPLETE, resourcePrefix);
+            fb.addDistinct('container.exit_code', '0', resourcePrefix);
             break;
         }
         case ProcessStatusFilter.QUEUED: {
-            fb.addEqual('container.state', ContainerState.QUEUED);
-            fb.addDistinct('container.priority', '0');
+            fb.addEqual('container.state', ContainerState.QUEUED, resourcePrefix);
+            fb.addDistinct('priority', '0', resourcePrefix);
             break;
         }
         case ProcessStatusFilter.CANCELLED:
-        case ProcessStatusFilter.LOCKED:
         case ProcessStatusFilter.RUNNING: {
-            fb.addEqual('container.state', activeStatusFilter);
+            fb.addEqual('container.state', activeStatusFilter, resourcePrefix);
             break;
         }
     }
index f0efdf74322345f9ac661aa2e72d3f244164baed..229d99094ed36ed7fd5500fc0607cbfc4092a102 100644 (file)
@@ -4,11 +4,14 @@
 
 import React from 'react';
 import { mount, configure } from 'enzyme';
-import { ResourceFileSize } from './renderers';
+import { ProcessStatus, ResourceFileSize } from './renderers';
 import Adapter from "enzyme-adapter-react-16";
 import { Provider } from 'react-redux';
 import configureMockStore from 'redux-mock-store'
 import { ResourceKind } from '../../models/resource';
+import { ContainerRequestState as CR } from '../../models/container-request';
+import { ContainerState as C } from '../../models/container';
+import { ProcessStatus as PS } from '../../store/processes/process';
 
 const middlewares = [];
 const mockStore = configureMockStore(middlewares);
@@ -18,6 +21,75 @@ configure({ adapter: new Adapter() });
 describe('renderers', () => {
     let props = null;
 
+    describe('ProcessStatus', () => {
+        props = {
+            uuid: 'zzzzz-xvhdp-zzzzzzzzzzzzzzz',
+            theme: {
+                customs: {
+                    colors: {
+                        // Color values are arbitrary, but they should be
+                        // representative of the colors used in the UI.
+                        blue500: 'rgb(0, 0, 255)',
+                        green700: 'rgb(0, 255, 0)',
+                        yellow700: 'rgb(255, 255, 0)',
+                        red900: 'rgb(255, 0, 0)',
+                        grey500: 'rgb(128, 128, 128)',
+                    }
+                },
+                spacing: {
+                    unit: 8,
+                },
+                palette: {
+                    common: {
+                        white: 'rgb(255, 255, 255)',
+                    },
+                },
+            },
+        };
+
+        [
+            // CR Status ; Priority ; C Status ; Exit Code ; C RuntimeStatus ; Expected label ; Expected Color
+            [CR.COMMITTED, 1, C.RUNNING, null, {}, PS.RUNNING, props.theme.customs.colors.blue500],
+            [CR.COMMITTED, 1, C.RUNNING, null, {error: 'whoops'}, PS.FAILING, props.theme.customs.colors.red900],
+            [CR.COMMITTED, 1, C.RUNNING, null, {warning: 'watch out!'}, PS.WARNING, props.theme.customs.colors.yellow700],
+            [CR.FINAL, 1, C.CANCELLED, null, {}, PS.CANCELLED, props.theme.customs.colors.red900],
+            [CR.FINAL, 1, C.COMPLETE, 137, {}, PS.FAILED, props.theme.customs.colors.red900],
+            [CR.FINAL, 1, C.COMPLETE, 0, {}, PS.COMPLETED, props.theme.customs.colors.green700],
+            [CR.COMMITTED, 0, C.LOCKED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500],
+            [CR.COMMITTED, 0, C.QUEUED, null, {}, PS.ONHOLD, props.theme.customs.colors.grey500],
+            [CR.COMMITTED, 1, C.LOCKED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500],
+            [CR.COMMITTED, 1, C.QUEUED, null, {}, PS.QUEUED, props.theme.customs.colors.grey500],
+        ].forEach(([crState, crPrio, cState, exitCode, rs, eLabel, eColor]) => {
+            it(`should render the state label '${eLabel}' and color '${eColor}' for CR state=${crState}, priority=${crPrio}, C state=${cState}, exitCode=${exitCode} and RuntimeStatus=${JSON.stringify(rs)}`, () => {
+                const containerUuid = 'zzzzz-dz642-zzzzzzzzzzzzzzz';
+                const store = mockStore({ resources: {
+                    [props.uuid]: {
+                        kind: ResourceKind.CONTAINER_REQUEST,
+                        state: crState,
+                        containerUuid: containerUuid,
+                        priority: crPrio,
+                    },
+                    [containerUuid]: {
+                        kind: ResourceKind.CONTAINER,
+                        state: cState,
+                        runtimeStatus: rs,
+                        exitCode: exitCode,
+                    },
+                }});
+
+                const wrapper = mount(<Provider store={store}>
+                        <ProcessStatus {...props} />
+                    </Provider>);
+
+                expect(wrapper.text()).toEqual(eLabel);
+                expect(getComputedStyle(wrapper.getDOMNode())
+                    .getPropertyValue('color')).toEqual(props.theme.palette.common.white);
+                expect(getComputedStyle(wrapper.getDOMNode())
+                    .getPropertyValue('background-color')).toEqual(eColor);
+            });
+        })
+    });
+
     describe('ResourceFileSize', () => {
         beforeEach(() => {
             props = {
index e854da0ee6caeebfdfd41af58dc8a081b58ecf0e..cd9f972e249a32e6111bc17de17fab8b8c7e0b45 100644 (file)
@@ -3,7 +3,15 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
+import {
+    Grid,
+    Typography,
+    withStyles,
+    Tooltip,
+    IconButton,
+    Checkbox,
+    Chip
+} from '@material-ui/core';
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { Resource, ResourceKind, TrashableResource } from 'models/resource';
 import {
@@ -825,14 +833,21 @@ export const ProcessStatus = compose(
         return { process: getProcess(props.uuid)(state.resources) };
     }),
     withStyles({}, { withTheme: true }))
-    ((props: { process?: Process, theme: ArvadosTheme }) => {
-        const status = props.process ? getProcessStatus(props.process) : "-";
-        return <Typography
-            noWrap
-            style={{ color: getProcessStatusColor(status, props.theme) }} >
-            {status}
-        </Typography>;
-    });
+    ((props: { process?: Process, theme: ArvadosTheme }) =>
+        props.process
+        ? <Chip label={getProcessStatus(props.process)}
+            style={{
+                height: props.theme.spacing.unit * 3,
+                width: props.theme.spacing.unit * 12,
+                backgroundColor: getProcessStatusColor(
+                    getProcessStatus(props.process), props.theme),
+                color: props.theme.palette.common.white,
+                fontSize: '0.875rem',
+                borderRadius: props.theme.spacing.unit * 0.625,
+            }}
+        />
+        : <Typography>-</Typography>
+    );
 
 export const ProcessStartDate = connect(
     (state: RootState, props: { uuid: string }) => {
diff --git a/src/views-components/process-runtime-status/process-runtime-status.tsx b/src/views-components/process-runtime-status/process-runtime-status.tsx
new file mode 100644 (file)
index 0000000..26e0459
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from 'react';
+import {
+    ExpansionPanel,
+    ExpansionPanelDetails,
+    ExpansionPanelSummary,
+    StyleRulesCallback,
+    Typography,
+    withStyles,
+    WithStyles
+} from "@material-ui/core";
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import { RuntimeStatus } from "models/runtime-status";
+import { ArvadosTheme } from 'common/custom-theme';
+import classNames from 'classnames';
+
+type CssRules = 'heading' | 'summary' | 'details' | 'error' | 'errorColor' | 'warning' | 'warningColor';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    heading: {
+        fontSize: '1rem',
+    },
+    summary: {
+        paddingLeft: theme.spacing.unit * 1,
+        paddingRight: theme.spacing.unit * 1,
+    },
+    details: {
+        paddingLeft: theme.spacing.unit * 1,
+        paddingRight: theme.spacing.unit * 1,
+    },
+    errorColor: {
+        color: theme.customs.colors.red900,
+    },
+    error: {
+        backgroundColor: theme.customs.colors.red100,
+
+    },
+    warning: {
+        backgroundColor: theme.customs.colors.yellow100,
+    },
+    warningColor: {
+        color: theme.customs.colors.yellow900,
+    },
+});
+export interface ProcessRuntimeStatusDataProps {
+    runtimeStatus: RuntimeStatus | undefined;
+}
+
+type ProcessRuntimeStatusProps = ProcessRuntimeStatusDataProps & WithStyles<CssRules>;
+
+export const ProcessRuntimeStatus = withStyles(styles)(
+    ({ runtimeStatus, classes }: ProcessRuntimeStatusProps) => {
+    return <>
+        { runtimeStatus?.error &&
+        <div data-cy='process-runtime-status-error'><ExpansionPanel className={classes.error} elevation={0}>
+            <ExpansionPanelSummary className={classes.summary} expandIcon={<ExpandMoreIcon />}>
+                <Typography className={classNames(classes.heading, classes.errorColor)}>
+                    {`Error: ${runtimeStatus.error }`}
+                </Typography>
+            </ExpansionPanelSummary>
+            <ExpansionPanelDetails className={classes.details}>
+                <Typography className={classes.errorColor}>
+                    {runtimeStatus?.errorDetail || 'No additional error details available'}
+                </Typography>
+            </ExpansionPanelDetails>
+        </ExpansionPanel></div>
+        }
+        { runtimeStatus?.warning &&
+        <div data-cy='process-runtime-status-warning' ><ExpansionPanel className={classes.warning} elevation={0}>
+            <ExpansionPanelSummary className={classes.summary} expandIcon={<ExpandMoreIcon />}>
+                <Typography className={classNames(classes.heading, classes.warningColor)}>
+                    {`Warning: ${runtimeStatus.warning }`}
+                </Typography>
+            </ExpansionPanelSummary>
+            <ExpansionPanelDetails className={classes.details}>
+                <Typography className={classes.warningColor}>
+                    {runtimeStatus?.warningDetail || 'No additional warning details available'}
+                </Typography>
+            </ExpansionPanelDetails>
+        </ExpansionPanel></div>
+        }
+    </>
+});
\ No newline at end of file
index fc34a31c2f2b0c7a761b88b85ca1b7e485091890..4fcbf257ef7717a77ffe75f8292aa6d08faaa01e 100644 (file)
@@ -4,20 +4,29 @@
 
 import React from 'react';
 import {
-    StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Chip, Typography, Tooltip
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Grid,
+    Typography,
+    Tooltip
 } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import { CloseIcon, MoreOptionsIcon, ProcessIcon } from 'components/icon/icon';
 import { DetailsAttribute } from 'components/details-attribute/details-attribute';
 import { Process } from 'store/processes/process';
-import { getProcessStatus, getProcessStatusColor } from 'store/processes/process';
 import { formatDate } from 'common/formatters';
 import classNames from 'classnames';
 import { ContainerState } from 'models/container';
 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { ProcessRuntimeStatus } from 'views-components/process-runtime-status/process-runtime-status';
+import { ProcessStatus } from 'views-components/data-explorer/renderers';
 
-type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'chip' | 'link' | 'content' | 'title' | 'avatar' | 'cancelButton' | 'header';
+type CssRules = 'card' | 'iconHeader' | 'label' | 'value' | 'link' | 'content' | 'title' | 'avatar' | 'cancelButton' | 'header';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -37,7 +46,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     label: {
         display: 'flex',
-        justifyContent: 'flex-end',
+        justifyContent: 'flex-start',
         fontSize: '0.875rem',
         marginRight: theme.spacing.unit * 3,
         paddingRight: theme.spacing.unit
@@ -53,16 +62,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             cursor: 'pointer'
         }
     },
-    chip: {
-        height: theme.spacing.unit * 3,
-        width: theme.spacing.unit * 12,
-        color: theme.palette.common.white,
-        fontSize: '0.875rem',
-        borderRadius: theme.spacing.unit * 0.625,
-    },
     content: {
+        paddingTop: '0px',
+        paddingLeft: theme.spacing.unit * 1,
+        paddingRight: theme.spacing.unit * 1,
         '&:last-child': {
-            paddingBottom: theme.spacing.unit * 2,
+            paddingBottom: theme.spacing.unit * 1,
         }
     },
     title: {
@@ -107,9 +112,7 @@ export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
                     <div>
                         {process.container && process.container.state === ContainerState.RUNNING &&
                             <span className={classes.cancelButton} onClick={() => cancelProcess(process.containerRequest.uuid)}>Cancel</span>}
-                        <Chip label={getProcessStatus(process)}
-                            className={classes.chip}
-                            style={{ backgroundColor: getProcessStatusColor(getProcessStatus(process), theme as ArvadosTheme) }} />
+                        <ProcessStatus uuid={process.containerRequest.uuid} />
                         <Tooltip title="More options" disableFocusListener>
                             <IconButton
                                 aria-label="More options"
@@ -123,27 +126,28 @@ export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
                         </Tooltip> }
                     </div>
                 }
-                title={
-                    <Tooltip title={process.containerRequest.name} placement="bottom-start">
-                        <Typography noWrap variant='h6' color='inherit'>
-                            {process.containerRequest.name}
-                        </Typography>
-                    </Tooltip>
+                title={ !!process.containerRequest.name &&
+                    <Typography noWrap variant='h6' color='inherit'>
+                        {process.containerRequest.name}
+                    </Typography>
                 }
                 subheader={
-                    <Tooltip title={getDescription(process)} placement="bottom-start">
-                        <Typography noWrap variant='body1' color='inherit'>
-                            {getDescription(process)}
-                        </Typography>
-                    </Tooltip>} />
+                    <Typography noWrap variant='body1' color='inherit'>
+                        {process.containerRequest.description}
+                    </Typography>
+                }
+            />
             <CardContent className={classes.content}>
                 <Grid container>
+                    <Grid item xs={12}>
+                        <ProcessRuntimeStatus runtimeStatus={process.container?.runtimeStatus} />
+                    </Grid>
                     <Grid item xs={6}>
                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                            label='From'
+                            label='Started at'
                             value={startedAt} />
                         <DetailsAttribute classLabel={classes.label} classValue={classes.value}
-                            label='To'
+                            label='Finished at'
                             value={finishedAt} />
                         {process.containerRequest.properties.workflowUuid &&
                             <span onClick={() => openWorkflow(process.containerRequest.properties.workflowUuid)}>
@@ -164,6 +168,3 @@ export const ProcessInformationCard = withStyles(styles, { withTheme: true })(
         </Card>;
     }
 );
-
-const getDescription = (process: Process) =>
-    process.containerRequest.description || '(no-description)';