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')
.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')
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');
});
['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);
.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(() => {
.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(() => {
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}/,);
})
));
});
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["*"],
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}$`));
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}`)
});
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}`);
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}`)
});
{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]')
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(() => {
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
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) {
});
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({
}
)
+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)
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
+ )
+ ))
+ ));
+ });
}
})
)
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"));
})
SiteName: string;
IdleTimeout: string;
BannerUUID: string;
+ UserProfileFormFields: {};
+ UserProfileFormMessage: string;
};
Login: {
LoginCluster: string;
SSHHelpHostSuffix: '',
SiteName: '',
IdleTimeout: '0s',
- BannerUUID: ""
+ BannerUUID: "",
+ UserProfileFormFields: {},
+ UserProfileFormMessage: '',
},
Login: {
LoginCluster: '',
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),
};
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),
};
--- /dev/null
+// 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']);
+ });
+
+});
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;
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/),
}
}
-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);
const config: any = {
rootUrl: "https://zzzzz.example.com",
uuidPrefix: "zzzzz",
- remoteHosts: { },
+ remoteHosts: {},
apiRevision: 12345678,
clusterConfig: {
Login: { LoginCluster: "" },
+ Workbench: { UserProfileFormFields: {} }
},
};
apiRevision: 12345678,
clusterConfig: {
Login: { LoginCluster: "zzzz1" },
+ Workbench: { UserProfileFormFields: {} }
},
};
apiRevision: 12345678,
clusterConfig: {
Login: { LoginCluster: "" },
+ Workbench: { UserProfileFormFields: {} }
},
};
Login: {
LoginCluster: "",
},
+ Workbench: { UserProfileFormFields: {} }
},
remoteHosts: {
"xc59z": "xc59z.example.com",
"Login": {
"LoginCluster": "",
},
+ Workbench: { UserProfileFormFields: {} }
},
"remoteHosts": {
"xc59z": "xc59z.example.com",
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
}
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"
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';
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);
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));
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/;
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}));
// 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 }));
+ }
}
};
// 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) => {
}
};
-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 => (
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'))) {
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) => {
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>
);
});
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: {
'& svg': {
fontSize: '1rem'
}
+ },
+ userProfileFormMessage: {
+ fontSize: '1.1rem',
}
});
userUuid: string;
resources: ResourcesState;
localCluster: string;
+ userProfileFormMessage: string;
}
const RoleTypes = [
];
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}
};
componentDidMount() {
- this.setState({ value: TABS.PROFILE});
+ this.setState({ value: TABS.PROFILE });
}
render() {
</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
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"
{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 >;
}
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)),
# 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}