Merge branch '20454-cost-display' refs #20454
authorPeter Amstutz <peter.amstutz@curii.com>
Mon, 21 Aug 2023 20:13:07 +0000 (16:13 -0400)
committerPeter Amstutz <peter.amstutz@curii.com>
Mon, 21 Aug 2023 20:13:07 +0000 (16:13 -0400)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

19 files changed:
cypress/integration/collection.spec.js
cypress/integration/process.spec.js
cypress/integration/project.spec.js
cypress/integration/search.spec.js
cypress/integration/side-panel.spec.js
cypress/integration/virtual-machine-admin.spec.js
cypress/support/commands.js
src/common/config.ts
src/services/common-service/common-resource-service.ts
src/services/log-service/log-service.test.ts [new file with mode: 0644]
src/services/log-service/log-service.ts
src/store/auth/auth-action.test.ts
src/store/auth/auth-middleware.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/process-logs-panel/process-logs-panel-actions.ts
src/views-components/login-form/login-form.tsx
src/views/user-profile-panel/user-profile-panel-root.tsx
src/views/user-profile-panel/user-profile-panel.tsx
tools/run-integration-tests.sh

index d5b1b9ebcf7169dec5649e89d1f82de52b3a250c..69e484173f80aa8fdcda1f4a95c46d572b41ce1d 100644 (file)
@@ -815,7 +815,7 @@ describe('Collection panel tests', function () {
                         cy.get('[data-cy=collection-version-browser-select-1]')
                             .should('contain', '1')
                             .and('contain', '6 B')
-                            .and('contain', adminUser.user.uuid);
+                            .and('contain', adminUser.user.full_name);
                         // Version 2: 3 bytes in size (one file removed)
                         cy.get('[data-cy=collection-version-browser-select-2]')
                             .should('contain', '2')
@@ -876,6 +876,7 @@ describe('Collection panel tests', function () {
                     .should('contain', '3').and('contain', '3 B');
 
                 // Check context menus on version browser
+                cy.waitForDom();
                 cy.get('[data-cy=collection-version-browser-select-3]').rightclick()
                 cy.get('[data-cy=context-menu]')
                     .should('contain', 'Add to favorites')
@@ -932,7 +933,7 @@ describe('Collection panel tests', function () {
 
                 cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
 
-                cy.get('main').contains(`Files extracted from: ${this.collection.name}`).click();
+                cy.waitForDom().get('main').contains(`Files extracted from: ${this.collection.name}`).click();
                 cy.get('[data-cy=collection-files-panel]')
                         .and('contain', 'bar');
             });
index b41a443e95a326eb2258edef3df102dc8654c7b4..64f27c50b54cf53e602cd5afdd944b55a2c88f9b 100644 (file)
@@ -431,6 +431,11 @@ describe('Process tests', function() {
                 ['echo', 'hello world'],
                 false, 'Committed')
             .then(function(containerRequest) {
+                // Create empty log file before loading process page
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
+                    ""
+                ])
+
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 cy.get('[data-cy=process-details]').should('contain', crName);
@@ -438,6 +443,7 @@ describe('Process tests', function() {
                     .should('contain', 'No logs yet')
                     .and('not.contain', 'hello world');
 
+                // Append a log line
                 cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
                     "2023-07-18T20:14:48.128642814Z hello world"
                 ]).then(() => {
@@ -446,6 +452,7 @@ describe('Process tests', function() {
                         .and('contain', 'hello world');
                 });
 
+                // Append new log line to different file
                 cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [
                     "2023-07-18T20:14:49.128642814Z hello new line"
                 ]).then(() => {
index eff4d4e9f18651fc322b7374b0d4b03adf613fbb..fd14cc4226323b331a22c09ea35724748a07f6b7 100644 (file)
@@ -554,7 +554,7 @@ describe('Project tests', function() {
         cy.get('[data-cy=context-menu]').contains('Copy to clipboard').click();
         cy.window().then((win) => (
             win.navigator.clipboard.readText().then((text) => {
-                expect(text).to.match(/https\:\/\/localhost\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
+                expect(text).to.match(/https\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
             })
         ));
 
index c8e262f011097b43ad9b5902a7d75bacdfa26cf1..085298dcd0e05e1c485167c83f5b13a4291e6c27 100644 (file)
@@ -161,12 +161,12 @@ describe('Search tests', function() {
     });
 
     it('shows search context menu', function() {
-        const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
-        const federatedColName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        const colName = `Home Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
+        const federatedColName = `Federated Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
         const federatedColUuid = "xxxxx-4zz18-000000000000000";
 
         // Intercept config to insert remote cluster
-        cy.intercept({method: 'GET', hostname: 'localhost', url: '**/arvados/v1/config?nocache=*'}, (req) => {
+        cy.intercept({method: 'GET', hostname: '127.0.0.1', url: '**/arvados/v1/config?nocache=*'}, (req) => {
             req.reply((res) => {
                 res.body.RemoteClusters = {
                     "*": res.body.RemoteClusters["*"],
@@ -280,6 +280,7 @@ describe('Search tests', function() {
                 cy.contains('View details');
 
                 cy.contains('Copy to clipboard').click();
+                cy.waitForDom();
                 cy.window().then((win) => (
                     win.navigator.clipboard.readText().then((text) => {
                         expect(text).to.match(new RegExp(`/collections/${testCollection.uuid}$`));
@@ -291,6 +292,7 @@ describe('Search tests', function() {
             cy.get('[data-cy=search-results]').contains(colName).rightclick();
             cy.get('[data-cy=context-menu]').within(() => {
                 cy.contains('Open in new tab').click();
+                cy.waitForDom();
                 cy.get('@Open').should('have.been.calledOnceWith', `${window.location.origin}/collections/${testCollection.uuid}`)
             });
 
@@ -298,6 +300,7 @@ describe('Search tests', function() {
             cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
             cy.get('[data-cy=context-menu]').within(() => {
                 cy.contains('Copy to clipboard').click();
+                cy.waitForDom();
                 cy.window().then((win) => (
                     win.navigator.clipboard.readText().then((text) => {
                         expect(text).to.equal(`https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
@@ -308,6 +311,7 @@ describe('Search tests', function() {
             cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
             cy.get('[data-cy=context-menu]').within(() => {
                 cy.contains('Open in new tab').click();
+                cy.waitForDom();
                 cy.get('@Open').should('have.been.calledWith', `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`)
             });
 
index e187d533ead19b2b43e9ed01aae24215f84c4955..92181150edddd2f07c2e67a56e63ec03327732ce 100644 (file)
@@ -65,7 +65,7 @@ describe('Side panel tests', function() {
             {url: '/all_processes', label: 'All Processes'},
             {url: '/trash', label: 'Trash'},
         ].map(function(section) {
-            cy.goToPath(section.url);
+            cy.waitForDom().goToPath(section.url);
             cy.get('[data-cy=breadcrumb-first]')
                 .should('contain', section.label);
             cy.get('[data-cy=side-panel-button]')
index 49cb12394648138417404b52a001f2698002a541..80d649777bfb436d6f9052f17dd74ccbfd0d63ae 100644 (file)
@@ -50,7 +50,7 @@ describe('Virtual machine login manage tests', function() {
                     cy.get('input').type('VMAdmin');
                   })
             });
-        cy.get('[role=tooltip]').click();
+        cy.waitForDom().get('[role=tooltip]').click();
         cy.get('[data-cy=form-dialog]').as('add-login-dialog')
             .should('contain', 'Add login permission')
             .within(() => {
@@ -269,11 +269,11 @@ describe('Virtual machine login manage tests', function() {
         cy.get('@removeButton').click();
         cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.waitForDom().get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
             .within(() => {
-                cy.get('div[role=button]').should('not.contain', 'admin');
+                cy.get('div[role=button]').should('not.exist');
             });
 
         // Check admin's vm page for login
index e4f6fd603a12bbca7e5e3fb31cb5cdea338841a0..fadd73e0655b722202999a763763d662ba3ff5a5 100644 (file)
@@ -34,6 +34,8 @@ const controllerURL = Cypress.env('controller_url');
 const systemToken = Cypress.env('system_token');
 let createdResources = [];
 
+const containerLogFolderPrefix = 'log for container ';
+
 // Clean up on a 'before' hook to allow post-mortem analysis on individual tests.
 beforeEach(function () {
     if (createdResources.length === 0) {
@@ -63,7 +65,7 @@ Cypress.Commands.add(
 });
 
 Cypress.Commands.add(
-    "doKeepRequest", (method = 'GET', path = '', data = null, qs = null,
+    "doWebDAVRequest", (method = 'GET', path = '', data = null, qs = null,
         token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
     return cy.doRequest('GET', '/arvados/v1/config', null, null).then(({body: config}) => {
         return cy.request({
@@ -192,6 +194,17 @@ Cypress.Commands.add(
     }
 )
 
+Cypress.Commands.add(
+    "collectionReplaceFiles", (token, uuid, data) => {
+        return cy.updateResource(token, 'collections', uuid, {
+            collection: {
+                preserve_version: true,
+            },
+            replace_files: JSON.stringify(data)
+        })
+    }
+)
+
 Cypress.Commands.add(
     "getContainer", (token, uuid) => {
         return cy.getResource(token, 'containers', uuid)
@@ -237,57 +250,60 @@ Cypress.Commands.add(
         cy.getContainerRequest(token, crUuid).then((containerRequest) => {
             if (containerRequest.log_uuid) {
                 cy.listContainerRequestLogs(token, crUuid).then((logFiles) => {
+                    const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
                     if (logFiles.find((file) => (file.name === fileName))) {
                         // File exists, fetch and append
-                        return cy.doKeepRequest(
+                        return cy.doWebDAVRequest(
                                 "GET",
-                                `c=${containerRequest.log_uuid}/${fileName}`,
+                                `c=${filePath}`,
                                 null,
                                 null,
                                 token
                             )
-                            .then(({ body: contents }) => cy.doKeepRequest(
+                            .then(({ body: contents }) => cy.doWebDAVRequest(
                                 "PUT",
-                                `c=${containerRequest.log_uuid}/${fileName}`,
+                                `c=${filePath}`,
                                 contents.split("\n").concat(lines).join("\n"),
                                 null,
                                 token
                             ));
                     } else {
                         // File not exists, put new file
-                        cy.doKeepRequest(
+                        cy.doWebDAVRequest(
                             "PUT",
-                            `c=${containerRequest.log_uuid}/${fileName}`,
+                            `c=${filePath}`,
                             lines.join("\n"),
                             null,
                             token
                         )
                     }
                 });
-                // Fetch current log contents and append new line
-                // let newLines = [...lines];
-                // return cy.doKeepRequest('GET', `c=${containerRequest.log_uuid}/${fileName}`, null, null, token)
-                //     .then(({body: contents}) => {
-                //         newLines = [contents.split('\n'), ...newLines];
-                //     })
-                //     .then(() => (
-                //         cy.doKeepRequest('PUT', `c=${containerRequest.log_uuid}/${fileName}`, newLines.join('\n'), null, token)
-                //     ));
             } else {
                 // Create log collection
                 return cy.createCollection(token, {
                     name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
                     owner_uuid: containerRequest.owner_uuid,
                     manifest_text: ""
-                }).then((collection) => (
+                }).then((collection) => {
                     // Update CR log_uuid to fake log collection
                     cy.updateContainerRequest(token, containerRequest.uuid, {
                         log_uuid: collection.uuid,
                     }).then(() => (
-                        // Put new log file with contents into fake log collection
-                        cy.doKeepRequest('PUT', `c=${collection.uuid}/${fileName}`, lines.join('\n'), null, token)
-                    ))
-                ));
+                        // Create empty directory for container uuid
+                        cy.collectionReplaceFiles(token, collection.uuid, {
+                            [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0"
+                        }).then(() => (
+                            // Put new log file with contents into fake log collection
+                            cy.doWebDAVRequest(
+                                'PUT',
+                                `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
+                                lines.join('\n'),
+                                null,
+                                token
+                            )
+                        ))
+                    ));
+                });
             }
         })
     )
@@ -296,7 +312,7 @@ Cypress.Commands.add(
 Cypress.Commands.add(
     "listContainerRequestLogs", (token, crUuid) => (
         cy.getContainerRequest(token, crUuid).then((containerRequest) => (
-            cy.doKeepRequest('PROPFIND', `c=${containerRequest.log_uuid}`, null, null, token)
+            cy.doWebDAVRequest('PROPFIND', `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`, null, null, token)
                 .then(({body: data}) => {
                     return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
                 })
index fd8b75ce72bbcf34818875765645759876ea9827..eff998ae5ea45cff369d753d249acb8c6a510684 100644 (file)
@@ -76,6 +76,8 @@ export interface ClusterConfigJSON {
         SiteName: string;
         IdleTimeout: string;
         BannerUUID: string;
+        UserProfileFormFields: {};
+        UserProfileFormMessage: string;
     };
     Login: {
         LoginCluster: string;
@@ -305,7 +307,9 @@ export const mockClusterConfigJSON = (
         SSHHelpHostSuffix: '',
         SiteName: '',
         IdleTimeout: '0s',
-        BannerUUID: ""
+        BannerUUID: "",
+        UserProfileFormFields: {},
+        UserProfileFormMessage: '',
     },
     Login: {
         LoginCluster: '',
index 624525e6aecc7d70674d1e541fb072a9584c76e5..907f0081fdf6053fd983f073cefeb7eadbc77134 100644 (file)
@@ -24,14 +24,20 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
         super(serverApi, resourceType, actions, readOnlyFields.concat([
             'uuid',
             'etag',
-            'kind'
+            'kind',
+            'canWrite',
+            'canManage',
+            'createdAt',
+            'modifiedAt',
+            'modifiedByClientUuid',
+            'modifiedByUserUuid'
         ]));
     }
 
     create(data?: Partial<T>, showErrors?: boolean) {
         let payload: any;
         if (data !== undefined) {
-            this.readOnlyFields.forEach( field => delete data[field] );
+            this.readOnlyFields.forEach(field => delete data[field]);
             payload = {
                 [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
             };
@@ -42,7 +48,7 @@ export class CommonResourceService<T extends Resource> extends CommonService<T>
     update(uuid: string, data: Partial<T>, showErrors?: boolean, select?: string[]) {
         let payload: any;
         if (data !== undefined) {
-            this.readOnlyFields.forEach( field => delete data[field] );
+            this.readOnlyFields.forEach(field => delete data[field]);
             payload = {
                 [this.resourceType.slice(0, -1)]: CommonService.mapKeys(snakeCase)(data),
             };
diff --git a/src/services/log-service/log-service.test.ts b/src/services/log-service/log-service.test.ts
new file mode 100644 (file)
index 0000000..2519155
--- /dev/null
@@ -0,0 +1,168 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LogService } from "./log-service";
+import { ApiActions } from "services/api/api-actions";
+import axios from "axios";
+import { WebDAVRequestConfig } from "common/webdav";
+import { LogEventType } from "models/log";
+
+describe("LogService", () => {
+
+    let apiWebdavClient: any;
+    const axiosInstance = axios.create();
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => {},
+        errorFn: (id: string, message: string) => {}
+    };
+
+    beforeEach(() => {
+        apiWebdavClient = {
+            delete: jest.fn(),
+            upload: jest.fn(),
+            mkdir: jest.fn(),
+            get: jest.fn(),
+            propfind: jest.fn(),
+        } as any;
+    });
+
+    it("lists log files using propfind on live logs api endpoint", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const xmlData = `<?xml version="1.0" encoding="UTF-8"?>
+            <D:multistatus xmlns:D="DAV:">
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:resourcetype>
+                                                    <D:collection xmlns:D="DAV:" />
+                                            </D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                            <D:displayname></D:displayname>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/stdout.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>stdout.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                            <D:resourcetype></D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+                    <D:response>
+                            <D:href>/arvados/v1/container_requests/${containerRequest.uuid}/wrongpath.txt</D:href>
+                            <D:propstat>
+                                    <D:prop>
+                                            <D:displayname>wrongpath.txt</D:displayname>
+                                            <D:getcontentlength>15</D:getcontentlength>
+                                            <D:getcontenttype>text/plain; charset=utf-8</D:getcontenttype>
+                                            <D:getetag>"177b8fb161ff9f58f"</D:getetag>
+                                            <D:supportedlock>
+                                                    <D:lockentry xmlns:D="DAV:">
+                                                            <D:lockscope>
+                                                                    <D:exclusive />
+                                                            </D:lockscope>
+                                                            <D:locktype>
+                                                                    <D:write />
+                                                            </D:locktype>
+                                                    </D:lockentry>
+                                            </D:supportedlock>
+                                            <D:resourcetype></D:resourcetype>
+                                            <D:getlastmodified>Tue, 15 Aug 2023 12:54:37 GMT</D:getlastmodified>
+                                    </D:prop>
+                                    <D:status>HTTP/1.1 200 OK</D:status>
+                            </D:propstat>
+                    </D:response>
+            </D:multistatus>`;
+        const xmlDoc = (new DOMParser()).parseFromString(xmlData, "text/xml");
+        apiWebdavClient.propfind = jest.fn().mockReturnValue(Promise.resolve({responseXML: xmlDoc}));
+
+        // when
+        const logs = await logService.listLogFiles(containerRequest);
+
+        // then
+        expect(apiWebdavClient.propfind).toHaveBeenCalledWith(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        expect(logs.length).toEqual(1);
+        expect(logs[0]).toHaveProperty('name', 'stdout.txt');
+        expect(logs[0]).toHaveProperty('type', 'file');
+    });
+
+    it("requests log file contents with correct range request", async () => {
+        const logService = new LogService(axiosInstance, apiWebdavClient, actions);
+
+        // given
+        const containerRequest = {uuid: 'zzzzz-xvhdp-000000000000000', containerUuid: 'zzzzz-dz642-000000000000000'};
+        const fileRecord = {name: `stdout.txt`};
+        const fileContents = `Line 1\nLine 2\nLine 3`;
+        apiWebdavClient.get = jest.fn().mockImplementation((path: string, options: WebDAVRequestConfig) => {
+            const matches = /bytes=([0-9]+)-([0-9]+)/.exec(options.headers?.Range || '');
+            if (matches?.length === 3) {
+                return Promise.resolve({responseText: fileContents.substring(Number(matches[1]), Number(matches[2]) + 1)})
+            }
+            return Promise.reject();
+        });
+
+        // when
+        let result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 3);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-3`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 0, 10);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=0-10`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['Line 1', 'Line']);
+
+        // when
+        result = await logService.getLogFileContents(containerRequest, fileRecord, 6, 14);
+        // then
+        expect(apiWebdavClient.get).toHaveBeenCalledWith(
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
+            {headers: {Range: `bytes=6-14`}}
+        );
+        expect(result.logType).toEqual(LogEventType.STDOUT);
+        expect(result.contents).toEqual(['', 'Line 2', 'L']);
+    });
+
+});
index 03d3c01ec446c2a3374b3fafeca40a0a682aeefd..f36044f425227f1b55d539b1903970e7b60b9230 100644 (file)
@@ -9,6 +9,7 @@ import { ApiActions } from "services/api/api-actions";
 import { WebDAV } from "common/webdav";
 import { extractFilesData } from "services/collection-service/collection-service-files-response";
 import { CollectionFile } from "models/collection-file";
+import { ContainerRequestResource } from "models/container-request";
 
 export type LogFragment = {
     logType: LogEventType;
@@ -20,25 +21,33 @@ export class LogService extends CommonResourceService<LogResource> {
         super(serverApi, "logs", actions);
     }
 
-    async listLogFiles(containerRequestUuid: string) {
-        const request = await this.apiWebdavClient.propfind(`container_requests/${containerRequestUuid}/log`);
-        if (request.responseXML != null) {
+    async listLogFiles(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>) {
+        const request = await this.apiWebdavClient.propfind(`container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`);
+        if (request?.responseXML != null) {
             return extractFilesData(request.responseXML)
                 .filter((file) => (
-                    file.path === `/arvados/v1/container_requests/${containerRequestUuid}/log`
+                    file.path === `/arvados/v1/container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}`
                 ));
         }
         return Promise.reject();
     }
 
-    async getLogFileContents(containerRequestUuid: string, fileRecord: CollectionFile, startByte: number, endByte: number): Promise<LogFragment> {
+    /**
+     * Fetches the specified log file contents from the given container request's container live logs endpoint
+     * @param containerRequest Container request to fetch logs for
+     * @param fileRecord Log file to fetch
+     * @param startByte First byte index of the log file to fetch
+     * @param endByte Last byte index to include in the response
+     * @returns A promise that resolves to the LogEventType and a string array of the log file contents
+     */
+    async getLogFileContents(containerRequest: Pick<ContainerRequestResource, 'uuid' | 'containerUuid'>, fileRecord: Pick<CollectionFile, 'name'>, startByte: number, endByte: number): Promise<LogFragment> {
         const request = await this.apiWebdavClient.get(
-            `container_requests/${containerRequestUuid}/log/${fileRecord.name}`,
+            `container_requests/${containerRequest.uuid}/log/${containerRequest.containerUuid}/${fileRecord.name}`,
             {headers: {Range: `bytes=${startByte}-${endByte}`}}
         );
         const logFileType = logFileToLogType(fileRecord);
 
-        if (request.responseText && logFileType) {
+        if (request?.responseText && logFileType) {
             return {
                 logType: logFileType,
                 contents: request.responseText.split(/\r?\n/),
@@ -49,4 +58,4 @@ export class LogService extends CommonResourceService<LogResource> {
     }
 }
 
-export const logFileToLogType = (file: CollectionFile) => (file.name.replace(/\.(txt|json)$/, '') as LogEventType);
+export const logFileToLogType = (file: Pick<CollectionFile, 'name'>) => (file.name.replace(/\.(txt|json)$/, '') as LogEventType);
index cba93965892d037dc49f67e3c9e8126c49096db7..ede419251bc052cf3cd6f231b3461c31dd67f945 100644 (file)
@@ -83,10 +83,11 @@ describe('auth-actions', () => {
         const config: any = {
             rootUrl: "https://zzzzz.example.com",
             uuidPrefix: "zzzzz",
-            remoteHosts: { },
+            remoteHosts: {},
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -162,6 +163,7 @@ describe('auth-actions', () => {
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "zzzz1" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -226,6 +228,7 @@ describe('auth-actions', () => {
             apiRevision: 12345678,
             clusterConfig: {
                 Login: { LoginCluster: "" },
+                Workbench: { UserProfileFormFields: {} }
             },
         };
 
@@ -249,6 +252,7 @@ describe('auth-actions', () => {
                                 Login: {
                                     LoginCluster: "",
                                 },
+                                Workbench: { UserProfileFormFields: {} }
                             },
                             remoteHosts: {
                                 "xc59z": "xc59z.example.com",
@@ -269,6 +273,7 @@ describe('auth-actions', () => {
                                     "Login": {
                                         "LoginCluster": "",
                                     },
+                                    Workbench: { UserProfileFormFields: {} }
                                 },
                                 "remoteHosts": {
                                     "xc59z": "xc59z.example.com",
index 0d08405ae11e87f329553e236e4550c0720a6fd6..1658431302278d4e3ebea9bdb4b35cddfb34f8e6 100644 (file)
@@ -10,6 +10,7 @@ import { User } from "models/user";
 import { RootState } from 'store/store';
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 import { WORKBENCH_LOADING_SCREEN } from 'store/workbench/workbench-actions';
+import { navigateToMyAccount } from 'store/navigation/navigation-action';
 
 export const authMiddleware = (services: ServiceRepository): Middleware => store => next => action => {
     // Middleware to update external state (local storage, window
@@ -35,6 +36,15 @@ export const authMiddleware = (services: ServiceRepository): Middleware => store
             }
 
             store.dispatch<any>(initSessions(services.authService, state.auth.remoteHostsConfig[state.auth.localCluster], user));
+            if (Object.keys(state.auth.config.clusterConfig.Workbench.UserProfileFormFields).length > 0 &&
+                user.isActive &&
+                (Object.keys(user.prefs).length === 0 ||
+                    user.prefs.profile === undefined ||
+                    Object.keys(user.prefs.profile!).length === 0)) {
+                // If the user doesn't have a profile set, send them
+                // to the user profile page to encourage them to fill it out.
+                store.dispatch(navigateToMyAccount);
+            }
             if (!user.isActive) {
                 // As a special case, if the user is inactive, they
                 // may be able to self-activate using the "activate"
index a7e42510b1239ffe643c809b0b7ecf884c881299..9c9baf08ad76977d708d41107acf1873fee6b440 100644 (file)
@@ -6,8 +6,6 @@ import { Dispatch } from 'redux';
 import { RootState } from 'store/store';
 import { getUserUuid } from "common/getuser";
 import { getResource } from 'store/resources/resources';
-import { TreePicker } from '../tree-picker/tree-picker';
-import { getSidePanelTreeBranch, getSidePanelTreeNodeAncestorsIds } from '../side-panel-tree/side-panel-tree-actions';
 import { propertiesActions } from '../properties/properties-actions';
 import { getProcess } from 'store/processes/process';
 import { ServiceRepository } from 'services/services';
@@ -57,25 +55,40 @@ const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestRes
     icon: resourceToBreadcrumbIcon(resource),
 })
 
-const getSidePanelTreeBreadcrumbs = (uuid: string) => (treePicker: TreePicker): Breadcrumb[] => {
-    const nodes = getSidePanelTreeBranch(uuid)(treePicker);
-    return nodes.map(node =>
-        typeof node.value === 'string'
-            ? {
-                label: node.value,
-                uuid: node.id,
-                icon: getSidePanelIcon(node.value)
-            }
-            : resourceToBreadcrumb(node.value));
-};
-
 export const setSidePanelBreadcrumbs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { treePicker, collectionPanel: { item } } = getState();
-        const breadcrumbs = getSidePanelTreeBreadcrumbs(uuid)(treePicker);
+        const ancestors = await services.ancestorsService.ancestors(uuid, '');
+        dispatch(updateResources(ancestors));
+
+        let breadcrumbs: Breadcrumb[] = [];
+        const { collectionPanel: { item } } = getState();
+
         const path = getState().router.location!.pathname;
         const currentUuid = path.split('/')[2];
         const uuidKind = extractUuidKind(currentUuid);
+        const rootUuid = getUserUuid(getState());
+
+        if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
+            // Handle home project uuid root
+            breadcrumbs.push({
+                label: SidePanelTreeCategory.PROJECTS,
+                uuid: SidePanelTreeCategory.PROJECTS,
+                icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
+            });
+        } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) {
+            // Handle SidePanelTreeCategory root
+            breadcrumbs.push({
+                label: uuid,
+                uuid: uuid,
+                icon: getSidePanelIcon(uuid)
+            });
+        }
+
+        breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+            ancestor.kind === ResourceKind.GROUP
+                ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                : breadcrumbs,
+            breadcrumbs);
 
         if (uuidKind === ResourceKind.COLLECTION) {
             const collectionItem = item ? item : await services.collectionService.get(currentUuid);
@@ -189,10 +202,10 @@ const getCollectionParent = (collection: CollectionResource) =>
 
 
 export const setProjectBreadcrumbs = (uuid: string) =>
-    (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = getSidePanelTreeNodeAncestorsIds(uuid)(getState().treePicker);
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const ancestors = await services.ancestorsService.ancestors(uuid, '');
         const rootUuid = getUserUuid(getState());
-        if (uuid === rootUuid || ancestors.find(uuid => uuid === rootUuid)) {
+        if (uuid === rootUuid || ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
             dispatch(setSidePanelBreadcrumbs(uuid));
         } else {
             dispatch(setSharedWithMeBreadcrumbs(uuid));
index 781c8903738280338d5271dbd4c82eb681d42a5d..87a2fa12aaddc1d6d980908583dfda366c0f1ab4 100644 (file)
@@ -13,6 +13,7 @@ import { Process, getProcess } from 'store/processes/process';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { CollectionFile, CollectionFileType } from "models/collection-file";
+import { ContainerRequestResource } from "models/container-request";
 
 const SNIPLINE = `================ âœ€ ================ âœ€ ========= Some log(s) were skipped ========= âœ€ ================ âœ€ ================`;
 const LOG_TIMESTAMP_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9}Z/;
@@ -44,7 +45,7 @@ export const initProcessLogsPanel = (processUuid: string) =>
             const process = getProcess(processUuid)(getState().resources);
             if (process?.containerRequest?.uuid) {
                 // Get log file size info
-                const logFiles = await loadContainerLogFileList(process.containerRequest.uuid, logService);
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
 
                 // Populate lastbyte 0 for each file
                 const filesWithProgress = logFiles.map((file) => ({file, lastByte: 0}));
@@ -60,7 +61,10 @@ export const initProcessLogsPanel = (processUuid: string) =>
             // On error, populate empty state to allow polling to start
             const initialState = createInitialLogPanelState([], []);
             dispatch(processLogsPanelActions.INIT_PROCESS_LOGS_PANEL(initialState));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not load process logs', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            // Only show toast on errors other than 404 since 404 is expected when logs do not exist yet
+            if (e.status !== 404) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not load process logs', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            }
         }
     };
 
@@ -73,7 +77,7 @@ export const pollProcessLogs = (processUuid: string) =>
 
             // Check if container request is present and initial logs state loaded
             if (process?.containerRequest?.uuid && Object.keys(currentState.logs).length > 0) {
-                const logFiles = await loadContainerLogFileList(process.containerRequest.uuid, logService);
+                const logFiles = await loadContainerLogFileList(process.containerRequest, logService);
 
                 // Determine byte to fetch from while filtering unchanged files
                 const filesToUpdateWithProgress = logFiles.reduce((acc, updatedFile) => {
@@ -107,8 +111,8 @@ export const pollProcessLogs = (processUuid: string) =>
         }
     };
 
-const loadContainerLogFileList = async (containerUuid: string, logService: LogService) => {
-    const logCollectionContents = await logService.listLogFiles(containerUuid);
+const loadContainerLogFileList = async (containerRequest: ContainerRequestResource, logService: LogService) => {
+    const logCollectionContents = await logService.listLogFiles(containerRequest);
 
     // Filter only root directory files matching log event types which have bytes
     return logCollectionContents.filter((file): file is CollectionFile => (
@@ -134,11 +138,11 @@ const loadContainerLogFileContents = async (logFilesWithProgress: FileWithProgre
             const chunkSize = Math.floor(maxLogFetchSize / 2);
             const firstChunkEnd = lastByte+chunkSize-1;
             return Promise.all([
-                logService.getLogFileContents(process.containerRequest.uuid, file, lastByte, firstChunkEnd),
-                logService.getLogFileContents(process.containerRequest.uuid, file, file.size-chunkSize, file.size-1)
+                logService.getLogFileContents(process.containerRequest, file, lastByte, firstChunkEnd),
+                logService.getLogFileContents(process.containerRequest, file, file.size-chunkSize, file.size-1)
             ] as Promise<(LogFragment)>[]);
         } else {
-            return Promise.all([logService.getLogFileContents(process.containerRequest.uuid, file, lastByte, file.size-1)]);
+            return Promise.all([logService.getLogFileContents(process.containerRequest, file, lastByte, file.size-1)]);
         }
     })).then((res) => {
         if (res.length && res.every(promiseResult => (promiseResult.status === 'rejected'))) {
index 3aa9e3f25aeb8ed1c6646765051596cb16ff00dd..6c5902653bb3cdf21b1416430dfcdd4af6881ad3 100644 (file)
@@ -84,27 +84,31 @@ export const LoginForm = withStyles(styles)(
             setHelperText('');
             setSubmitting(true);
             handleSubmit(username, password)
-            .then((response) => {
-                setSubmitting(false);
-                if (response.data.uuid && response.data.api_token) {
-                    const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
-                    const rd = new URL(window.location.href);
-                    const rdUrl = rd.pathname + rd.search;
-                    dispatch<any>(saveApiToken(apiToken)).finally(
-                        () => rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl))
-                    );
-                } else {
+                .then((response) => {
+                    setSubmitting(false);
+                    if (response.data.uuid && response.data.api_token) {
+                        const apiToken = `v2/${response.data.uuid}/${response.data.api_token}`;
+                        const rd = new URL(window.location.href);
+                        const rdUrl = rd.pathname + rd.search;
+                        dispatch<any>(saveApiToken(apiToken)).finally(
+                            () => {
+                                if ((new URL(window.location.href).pathname) !== '/my-account') {
+                                    rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl))
+                                }
+                            }
+                        );
+                    } else {
+                        setError(true);
+                        setHelperText(response.data.message || 'Please try again');
+                        setFocus();
+                    }
+                })
+                .catch((err) => {
                     setError(true);
-                    setHelperText(response.data.message || 'Please try again');
+                    setSubmitting(false);
+                    setHelperText(`${(err.response && err.response.data && err.response.data.errors[0]) || 'Error logging in: ' + err}`);
                     setFocus();
-                }
-            })
-            .catch((err) => {
-                setError(true);
-                setSubmitting(false);
-                setHelperText(`${(err.response && err.response.data && err.response.data.errors[0]) || 'Error logging in: '+err}`);
-                setFocus();
-            });
+                });
         };
 
         const handleKeyPress = (e: any) => {
@@ -117,38 +121,38 @@ export const LoginForm = withStyles(styles)(
 
         return (
             <React.Fragment>
-            <form className={classes.root} noValidate autoComplete="off">
-                <Card className={classes.card}>
-                    <div className={classes.wrapper}>
-                    <CardContent>
-                        <TextField
-                            inputRef={userInput}
-                            disabled={isSubmitting}
-                            error={error} fullWidth id="username" type="email"
-                            label="Username" margin="normal"
-                            onChange={(e) => setUsername(e.target.value)}
-                            onKeyPress={(e) => handleKeyPress(e)}
-                        />
-                        <TextField
-                            disabled={isSubmitting}
-                            error={error} fullWidth id="password" type="password"
-                            label="Password" margin="normal"
-                            helperText={helperText}
-                            onChange={(e) => setPassword(e.target.value)}
-                            onKeyPress={(e) => handleKeyPress(e)}
-                        />
-                    </CardContent>
-                    <CardActions>
-                        <Button variant="contained" size="large" color="primary"
-                            className={classes.loginBtn} onClick={() => handleLogin()}
-                            disabled={isSubmitting || isButtonDisabled}>
-                            {loginLabel || 'Log in'}
-                        </Button>
-                    </CardActions>
-                    isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
-                    </div>
-                </Card>
-            </form>
+                <form className={classes.root} noValidate autoComplete="off">
+                    <Card className={classes.card}>
+                        <div className={classes.wrapper}>
+                            <CardContent>
+                                <TextField
+                                    inputRef={userInput}
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="username" type="email"
+                                    label="Username" margin="normal"
+                                    onChange={(e) => setUsername(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                                <TextField
+                                    disabled={isSubmitting}
+                                    error={error} fullWidth id="password" type="password"
+                                    label="Password" margin="normal"
+                                    helperText={helperText}
+                                    onChange={(e) => setPassword(e.target.value)}
+                                    onKeyPress={(e) => handleKeyPress(e)}
+                                />
+                            </CardContent>
+                            <CardActions>
+                                <Button variant="contained" size="large" color="primary"
+                                    className={classes.loginBtn} onClick={() => handleLogin()}
+                                    disabled={isSubmitting || isButtonDisabled}>
+                                    {loginLabel || 'Log in'}
+                                </Button>
+                            </CardActions>
+                            {isSubmitting && <CircularProgress color='secondary' className={classes.progress} />}
+                        </div>
+                    </Card>
+                </form>
             </React.Fragment>
         );
     });
index 85eaaf0c985fb6c604e101fb37be4e56f6786fb1..4a2083711efbee0f3d996216609e45441ebc99a1 100644 (file)
@@ -36,7 +36,7 @@ import { DefaultView } from 'components/default-view/default-view';
 import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
 import { PermissionResource } from 'models/permission';
 
-type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon';
+type CssRules = 'root' | 'emptyRoot' | 'gridItem' | 'label' | 'readOnlyValue' | 'title' | 'description' | 'actions' | 'content' | 'copyIcon' | 'userProfileFormMessage';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
@@ -81,6 +81,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         '& svg': {
             fontSize: '1rem'
         }
+    },
+    userProfileFormMessage: {
+        fontSize: '1.1rem',
     }
 });
 
@@ -97,6 +100,7 @@ export interface UserProfilePanelRootDataProps {
     userUuid: string;
     resources: ResourcesState;
     localCluster: string;
+    userProfileFormMessage: string;
 }
 
 const RoleTypes = [
@@ -165,7 +169,7 @@ export const userProfileGroupsColumns: DataColumns<string, PermissionResource> =
 ];
 
 const ReadOnlyField = withStyles(styles)(
-    (props: ({ label: string, input: {value: string} }) & WithStyles<CssRules> ) => (
+    (props: ({ label: string, input: { value: string } }) & WithStyles<CssRules>) => (
         <Grid item xs={12} data-cy="field">
             <Typography className={props.classes.label}>
                 {props.label}
@@ -184,7 +188,7 @@ export const UserProfilePanelRoot = withStyles(styles)(
         };
 
         componentDidMount() {
-            this.setState({ value: TABS.PROFILE});
+            this.setState({ value: TABS.PROFILE });
         }
 
         render() {
@@ -213,7 +217,7 @@ export const UserProfilePanelRoot = withStyles(styles)(
                                 </Grid>
                                 <Grid item>
                                     <Grid container alignItems="center">
-                                        <Grid item style={{marginRight: '10px'}}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
+                                        <Grid item style={{ marginRight: '10px' }}><UserResourceAccountStatus uuid={this.props.userUuid} /></Grid>
                                         <Grid item>
                                             <Tooltip title="Actions" disableFocusListener>
                                                 <IconButton
@@ -261,6 +265,9 @@ export const UserProfilePanelRoot = withStyles(styles)(
                                             disabled
                                         />
                                     </Grid>
+                                    <Grid item className={this.props.classes.gridItem} xs={12}>
+                                        <span className={this.props.classes.userProfileFormMessage}>{this.props.userProfileFormMessage}</span>
+                                    </Grid>
                                     <Grid item className={this.props.classes.gridItem} sm={6} xs={12}>
                                         <Field
                                             label="Organization"
@@ -316,19 +323,19 @@ export const UserProfilePanelRoot = withStyles(styles)(
                     {this.state.value === TABS.GROUPS &&
                         <div className={this.props.classes.content}>
                             <DataExplorer
-                                    id={USER_PROFILE_PANEL_ID}
-                                    data-cy="user-profile-groups-data-explorer"
-                                    onRowClick={noop}
-                                    onRowDoubleClick={noop}
-                                    onContextMenu={noop}
-                                    contextMenuColumn={false}
-                                    hideColumnSelector
-                                    hideSearchInput
-                                    paperProps={{
-                                        elevation: 0,
-                                    }}
-                                    defaultViewIcon={GroupsIcon}
-                                    defaultViewMessages={['Group list is empty.']} />
+                                id={USER_PROFILE_PANEL_ID}
+                                data-cy="user-profile-groups-data-explorer"
+                                onRowClick={noop}
+                                onRowDoubleClick={noop}
+                                onContextMenu={noop}
+                                contextMenuColumn={false}
+                                hideColumnSelector
+                                hideSearchInput
+                                paperProps={{
+                                    elevation: 0,
+                                }}
+                                defaultViewIcon={GroupsIcon}
+                                defaultViewMessages={['Group list is empty.']} />
                         </div>}
                 </Paper >;
             }
index a90d44a9a7a7d207e5ca3d4ea6b425acca21d356..040cbc6f4aa1304e92f121cc8a36f2509873779b 100644 (file)
@@ -14,20 +14,22 @@ import { matchUserProfileRoute } from 'routes/routes';
 import { openUserContextMenu } from 'store/context-menu/context-menu-actions';
 
 const mapStateToProps = (state: RootState): UserProfilePanelRootDataProps => {
-  const pathname = state.router.location ? state.router.location.pathname : '';
-  const match = matchUserProfileRoute(pathname);
-  const uuid = match ? match.params.id : state.auth.user?.uuid || '';
+    const pathname = state.router.location ? state.router.location.pathname : '';
+    const match = matchUserProfileRoute(pathname);
+    const uuid = match ? match.params.id : state.auth.user?.uuid || '';
 
-  return {
-    isAdmin: state.auth.user!.isAdmin,
-    isSelf: state.auth.user!.uuid === uuid,
-    isPristine: isPristine(USER_PROFILE_FORM)(state),
-    isValid: isValid(USER_PROFILE_FORM)(state),
-    isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
-    localCluster: state.auth.localCluster,
-    userUuid: uuid,
-    resources: state.resources,
-}};
+    return {
+        isAdmin: state.auth.user!.isAdmin,
+        isSelf: state.auth.user!.uuid === uuid,
+        isPristine: isPristine(USER_PROFILE_FORM)(state),
+        isValid: isValid(USER_PROFILE_FORM)(state),
+        isInaccessible: getUserProfileIsInaccessible(state.properties) || false,
+        localCluster: state.auth.localCluster,
+        userUuid: uuid,
+        resources: state.resources,
+        userProfileFormMessage: state.auth.config.clusterConfig.Workbench.UserProfileFormMessage,
+    }
+};
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     handleContextMenu: (event, resource: UserResource) => dispatch<any>(openUserContextMenu(event, resource)),
index 367ccecd3512d90f261adc30b85405aac3f6176a..1f2b7c5ee585a0b7ec4d9250005a8f5d155472dd 100755 (executable)
@@ -139,10 +139,10 @@ exec 8<&"${wb2[0]}"; coproc consume_wb2_stdout (cat <&8 >&2)
 
 # Wait for workbench2 to be up.
 # Using https-get to avoid false positive 'ready' detection.
-yarn run wait-on --timeout 300000 https-get://localhost:${WB2_PORT} || exit 1
+yarn run wait-on --timeout 300000 https-get://127.0.0.1:${WB2_PORT} || exit 1
 
 echo "Running tests..."
 CYPRESS_system_token=systemusertesttoken1234567890aoeuidhtnsqjkxbmwvzpy \
     CYPRESS_controller_url=${controllerURL} \
-    CYPRESS_BASE_URL=https://localhost:${WB2_PORT} \
+    CYPRESS_BASE_URL=https://127.0.0.1:${WB2_PORT} \
     yarn run cypress ${CYPRESS_MODE}