From: Daniel Kutyła Date: Mon, 8 Aug 2022 10:55:15 +0000 (+0200) Subject: Merge remote-tracking branch 'origin/main' into 18692-frozen-projects-workbench-support X-Git-Url: https://git.arvados.org/arvados.git/commitdiff_plain/a23cfd6defb8dab9ac9afe13034f7b667f07acca?hp=fe6dbb1425a3283f09b5533554bd27bef452b849 Merge remote-tracking branch 'origin/main' into 18692-frozen-projects-workbench-support Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła --- diff --git a/cypress/integration/collection.spec.js b/cypress/integration/collection.spec.js index fd8e65b26f..28454a9093 100644 --- a/cypress/integration/collection.spec.js +++ b/cypress/integration/collection.spec.js @@ -374,7 +374,7 @@ describe('Collection panel tests', function () { cy.goToPath(`/collections/${this.testCollection.uuid}`); ['subdir', 'G%C3%BCnter\'s%20file', 'table%&?*2'].forEach((subdir) => { - cy.get('[data-cy=collection-files-panel]') + cy.waitForDom().get('[data-cy=collection-files-panel]') .contains('bar').rightclick(); cy.get('[data-cy=context-menu]') .contains('Rename') @@ -642,7 +642,7 @@ describe('Collection panel tests', function () { .contains(projName).and('contain', testProject.uuid); // Double check that the collection is in the project cy.goToPath(`/projects/${testProject.uuid}`); - cy.get('[data-cy=project-panel]').should('contain', collName); + cy.waitForDom().get('[data-cy=project-panel]').should('contain', collName); }); }); diff --git a/cypress/integration/process.spec.js b/cypress/integration/process.spec.js index 48b936cf69..55290fa36b 100644 --- a/cypress/integration/process.spec.js +++ b/cypress/integration/process.spec.js @@ -168,8 +168,15 @@ describe('Process tests', function() { cy.getAll('@stdoutLogs', '@nodeInfoLogs', '@crunchRunLogs').then(function() { cy.loginAs(activeUser); cy.goToPath(`/processes/${containerRequest.uuid}`); - // Should should all logs - cy.get('[data-cy=process-logs-filter]').should('contain', 'All logs'); + // Should show main logs by default + cy.get('[data-cy=process-logs-filter]').should('contain', 'Main logs'); + cy.get('[data-cy=process-logs]') + .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) + .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) + .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]); + // Select 'All logs' + cy.get('[data-cy=process-logs-filter]').click(); + cy.get('body').contains('li', 'All logs').click(); cy.get('[data-cy=process-logs]') .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)]) .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)]) @@ -244,5 +251,27 @@ describe('Process tests', function() { .should('contain', 'Free disk space is low') .and('contain', 'No additional warning details available'); }); + + + // Force container_count for testing + let containerCount = 2; + cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => { + req.reply((res) => { + res.body.container_count = containerCount; + }); + }); + + cy.getAll('@containerRequest').then(function([containerRequest]) { + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get('[data-cy=process-runtime-status-retry-warning]') + .should('contain', 'Process retried 1 time'); + }); + + cy.getAll('@containerRequest').then(function([containerRequest]) { + containerCount = 3; + cy.goToPath(`/processes/${containerRequest.uuid}`); + cy.get('[data-cy=process-runtime-status-retry-warning]') + .should('contain', 'Process retried 2 times'); + }); }); }); diff --git a/cypress/integration/project.spec.js b/cypress/integration/project.spec.js index 26b4a15087..d4d32117c9 100644 --- a/cypress/integration/project.spec.js +++ b/cypress/integration/project.spec.js @@ -85,6 +85,39 @@ describe('Project tests', function() { // Pink is not in the test vocab {IDTAGCOLORS: ['IDVALCOLORS3', 'Pink', 'IDVALCOLORS1']}); }); + + // Open project edit via breadcrumbs + cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick(); + cy.get('[data-cy=context-menu]').contains('Edit').click(); + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=resource-properties-list]').within(() => { + cy.get('div[role=button]').contains('Color: Magenta'); + cy.get('div[role=button]').contains('Color: Pink'); + cy.get('div[role=button]').contains('Color: Yellow'); + }); + }); + // Add another property + cy.get('[data-cy=resource-properties-form]').within(() => { + cy.get('[data-cy=property-field-key]').within(() => { + cy.get('input').type('Animal'); + }); + cy.get('[data-cy=property-field-value]').within(() => { + cy.get('input').type('Dog'); + }); + cy.root().submit(); + }); + cy.get('[data-cy=form-submit-btn]').click(); + // Reopen edit via breadcrumbs and verify properties + cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick(); + cy.get('[data-cy=context-menu]').contains('Edit').click(); + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=resource-properties-list]').within(() => { + cy.get('div[role=button]').contains('Color: Magenta'); + cy.get('div[role=button]').contains('Color: Pink'); + cy.get('div[role=button]').contains('Color: Yellow'); + cy.get('div[role=button]').contains('Animal: Dog'); + }); + }); }); it('creates new project on home project and then a subproject inside it', function() { diff --git a/cypress/integration/search.spec.js b/cypress/integration/search.spec.js index 5434ca248a..2216c067f8 100644 --- a/cypress/integration/search.spec.js +++ b/cypress/integration/search.spec.js @@ -126,4 +126,158 @@ describe('Search tests', function() { }); }); }); -}); \ No newline at end of file + + 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 federatedColUuid = "xxxxx-4zz18-000000000000000"; + + // Intercept config to insert remote cluster + cy.intercept({method: 'GET', hostname: 'localhost', url: '**/arvados/v1/config?nocache=*'}, (req) => { + req.reply((res) => { + res.body.RemoteClusters = { + "*": res.body.RemoteClusters["*"], + "xxxxx": { + "ActivateUsers": true, + "Host": "xxxxx.fakecluster.tld", + "Insecure": false, + "Proxy": true, + "Scheme": "" + } + }; + }); + }); + + // Fake remote cluster config + cy.intercept( + { + method: "GET", + hostname: "xxxxx.fakecluster.tld", + url: "**/arvados/v1/config", + }, + { + statusCode: 200, + body: { + API: {}, + ClusterID: "xxxxx", + Collections: {}, + Containers: {}, + InstanceTypes: {}, + Login: {}, + Mail: { SupportEmailAddress: "arvados@example.com" }, + RemoteClusters: { + "*": { + ActivateUsers: false, + Host: "", + Insecure: false, + Proxy: false, + Scheme: "https", + }, + }, + Services: { + Composer: { ExternalURL: "" }, + Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" }, + DispatchCloud: { ExternalURL: "" }, + DispatchLSF: { ExternalURL: "" }, + DispatchSLURM: { ExternalURL: "" }, + GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" }, + GitSSH: { ExternalURL: "" }, + Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" }, + Keepbalance: { ExternalURL: "" }, + Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" }, + Keepstore: { ExternalURL: "" }, + RailsAPI: { ExternalURL: "" }, + WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" }, + WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" }, + WebShell: { ExternalURL: "" }, + Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" }, + Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" }, + Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" }, + }, + StorageClasses: { + default: { Default: true, Priority: 0 }, + }, + Users: {}, + Volumes: {}, + Workbench: {}, + }, + } + ); + + cy.createCollection(adminUser.token, { + name: colName, + owner_uuid: activeUser.user.uuid, + preserve_version: true, + manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n" + }).then(function(testCollection) { + cy.loginAs(activeUser); + + // Intercept search results to add federated result + cy.intercept({method: 'GET', url: '**/arvados/v1/groups/contents?*'}, (req) => { + req.reply((res) => { + res.body.items = [ + res.body.items[0], + { + ...res.body.items[0], + uuid: federatedColUuid, + portable_data_hash: "00000000000000000000000000000000+0", + name: federatedColName, + href: res.body.items[0].href.replace(testCollection.uuid, federatedColUuid), + } + ]; + res.body.items_available += 1; + }); + }); + + cy.doSearch(colName); + + // Stub new window + cy.window().then(win => { + cy.stub(win, 'open').as('Open') + }); + + // Check copy to clipboard + cy.get('[data-cy=search-results]').contains(colName).rightclick(); + cy.get('[data-cy=context-menu]').within((ctx) => { + // Check that there are 4 items in the menu + cy.get(ctx).children().should('have.length', 4); + cy.contains('Advanced'); + cy.contains('Copy to clipboard'); + cy.contains('Open in new tab'); + cy.contains('View details'); + + cy.contains('Copy to clipboard').click(); + cy.window().then((win) => ( + win.navigator.clipboard.readText().then((text) => { + expect(text).to.match(new RegExp(`/collections/${testCollection.uuid}$`)); + }) + )); + }); + + // Check open in new tab + cy.get('[data-cy=search-results]').contains(colName).rightclick(); + cy.get('[data-cy=context-menu]').within(() => { + cy.contains('Open in new tab').click(); + cy.get('@Open').should('have.been.calledOnceWith', `${window.location.origin}/collections/${testCollection.uuid}`) + }); + + // Check federated result copy to clipboard + cy.get('[data-cy=search-results]').contains(federatedColName).rightclick(); + cy.get('[data-cy=context-menu]').within(() => { + cy.contains('Copy to clipboard').click(); + cy.window().then((win) => ( + win.navigator.clipboard.readText().then((text) => { + expect(text).to.equal(`https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`); + }) + )); + }); + // Check open in new tab + cy.get('[data-cy=search-results]').contains(federatedColName).rightclick(); + cy.get('[data-cy=context-menu]').within(() => { + cy.contains('Open in new tab').click(); + cy.get('@Open').should('have.been.calledWith', `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`) + }); + + }); + }); +}); diff --git a/package.json b/package.json index a8b3ee819a..9e663ca6ac 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@types/react-virtualized-auto-sizer": "1.0.0", "@types/react-window": "1.8.2", "@types/redux-form": "7.4.12", - "@types/shell-quote": "1.6.0", + "@types/shell-escape": "^0.2.0", "axios": "^0.21.1", "babel-core": "6.26.3", "babel-runtime": "6.26.0", @@ -71,7 +71,7 @@ "redux-thunk": "2.3.0", "reselect": "4.0.0", "set-value": "2.0.1", - "shell-quote": "1.6.1", + "shell-escape": "^0.2.0", "sinon": "7.3", "tslint": "5.20.0", "tslint-etc": "1.6.0", diff --git a/src/components/code-snippet/code-snippet.tsx b/src/components/code-snippet/code-snippet.tsx index f0a2b2131f..83c378b899 100644 --- a/src/components/code-snippet/code-snippet.tsx +++ b/src/components/code-snippet/code-snippet.tsx @@ -3,9 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; -import { StyleRulesCallback, WithStyles, Typography, withStyles } from '@material-ui/core'; +import { StyleRulesCallback, WithStyles, Typography, withStyles, Link } from '@material-ui/core'; import { ArvadosTheme } from 'common/custom-theme'; import classNames from 'classnames'; +import { connect, DispatchProp } from 'react-redux'; +import { RootState } from 'store/store'; +import { FederationConfig, getNavUrl } from 'routes/routes'; +import { Dispatch } from 'redux'; +import { navigationNotAvailable } from 'store/navigation/navigation-action'; type CssRules = 'root' | 'space'; @@ -24,19 +29,57 @@ export interface CodeSnippetDataProps { lines: string[]; className?: string; apiResponse?: boolean; + linked?: boolean; +} + +interface CodeSnippetAuthProps { + auth: FederationConfig; } type CodeSnippetProps = CodeSnippetDataProps & WithStyles; -export const CodeSnippet = withStyles(styles)( - ({ classes, lines, className, apiResponse }: CodeSnippetProps) => +const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({ + auth: state.auth, +}); + +export const CodeSnippet = withStyles(styles)(connect(mapStateToProps)( + ({ classes, lines, linked, className, apiResponse, dispatch, auth }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) => - { - lines.map((line: string, index: number) => { - return {line}; - }) - } + + {linked ? + lines.map((line, index) => {renderLinks(auth, dispatch)(line)}{`\n`}) : + lines.join('\n') + } + - ); \ No newline at end of file + )); + +const renderLinks = (auth: FederationConfig, dispatch: Dispatch) => (text: string): JSX.Element => { + // Matches UUIDs & PDHs + const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g; + const links = text.match(REGEX); + if (!links) { + return <>{text}; + } + return <> + {text.split(REGEX).map((part, index) => + + {part} + {links[index] && + { + const url = getNavUrl(links[index], auth) + if (url) { + window.open(`${window.location.origin}${url}`, '_blank'); + } else { + dispatch(navigationNotAvailable(links[index])); + } + }} + style={ {cursor: 'pointer'} }> + {links[index]} + } + + )} + ; + }; diff --git a/src/components/default-code-snippet/default-code-snippet.tsx b/src/components/default-code-snippet/default-code-snippet.tsx index 7ba97db49f..bdcfc10f64 100644 --- a/src/components/default-code-snippet/default-code-snippet.tsx +++ b/src/components/default-code-snippet/default-code-snippet.tsx @@ -6,8 +6,9 @@ import React from 'react'; import { MuiThemeProvider, createMuiTheme } from '@material-ui/core/styles'; import { CodeSnippet, CodeSnippetDataProps } from 'components/code-snippet/code-snippet'; import grey from '@material-ui/core/colors/grey'; +import { themeOptions } from 'common/custom-theme'; -const theme = createMuiTheme({ +const theme = createMuiTheme(Object.assign({}, themeOptions, { overrides: { MuiTypography: { body1: { @@ -22,9 +23,9 @@ const theme = createMuiTheme({ fontFamily: 'monospace', useNextVariants: true, } -}); +})); -export const DefaultCodeSnippet = (props: CodeSnippetDataProps) => +export const DefaultCodeSnippet = (props: CodeSnippetDataProps) => - ; \ No newline at end of file + ; diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index 74c2f54cca..960cc38b1c 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -77,6 +77,7 @@ import NotInterested from '@material-ui/icons/NotInterested'; // Import FontAwesome icons import { library } from '@fortawesome/fontawesome-svg-core'; import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import { FormatAlignLeft } from '@material-ui/icons'; library.add( faPencilAlt, faSlash, @@ -194,7 +195,8 @@ export const CanReadIcon: IconType = (props) => ; export const CanWriteIcon: IconType = (props) => ; export const CanManageIcon: IconType = (props) => ; export const AddUserIcon: IconType = (props) => ; -export const WordWrapIcon: IconType = (props) => ; +export const WordWrapOnIcon: IconType = (props) => ; +export const WordWrapOffIcon: IconType = (props) => ; export const TextIncreaseIcon: IconType = (props) => ; export const TextDecreaseIcon: IconType = (props) => ; export const DeactivateUserIcon: IconType = (props) => ; diff --git a/src/index.tsx b/src/index.tsx index d00095411f..c214228258 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -66,6 +66,7 @@ import { workflowActionSet } from 'views-components/context-menu/action-sets/wor import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action'; import { storeRedirects } from './common/redirect-to'; +import { searchResultsActionSet } from 'views-components/context-menu/action-sets/search-results-action-set'; console.log(`Starting arvados [${getBuildInfo()}]`); @@ -106,6 +107,7 @@ addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet); addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet); addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet); addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet); +addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet); storeRedirects(); diff --git a/src/models/log.ts b/src/models/log.ts index 20060f88b6..f5d351acb1 100644 --- a/src/models/log.ts +++ b/src/models/log.ts @@ -17,12 +17,13 @@ export enum LogEventType { STDOUT = 'stdout', STDERR = 'stderr', CONTAINER = 'container', + KEEPSTORE = 'keepstore', } export interface LogResource extends Resource, ResourceWithProperties { kind: ResourceKind.LOG; objectUuid: string; eventAt: string; - eventType: string; + eventType: LogEventType; summary: string; } diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 50689ec37c..22c8f4c8e5 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -66,7 +66,10 @@ export const getResourceUrl = (uuid: string) => { } }; -export const getNavUrl = (uuid: string, config: FederationConfig) => { +/** + * @returns A relative or federated url for the given uuid, with a token for federated WB1 urls + */ +export const getNavUrl = (uuid: string, config: FederationConfig, includeToken: boolean = true): string => { const path = getResourceUrl(uuid) || ""; const cls = uuid.substring(0, 5); if (cls === config.localCluster || extractUuidKind(uuid) === ResourceKind.USER || COLLECTION_PDH_REGEX.exec(uuid)) { @@ -83,7 +86,9 @@ export const getNavUrl = (uuid: string, config: FederationConfig) => { u = new URL(config.remoteHostsConfig[cls].workbench2Url); } else { u = new URL(config.remoteHostsConfig[cls].workbenchUrl); - u.search = "api_token=" + config.sessions.filter((s) => s.clusterId === cls)[0].token; + if (includeToken) { + u.search = "api_token=" + config.sessions.filter((s) => s.clusterId === cls)[0].token; + } } u.pathname = path; return u.toString(); diff --git a/src/services/auth-service/auth-service.ts b/src/services/auth-service/auth-service.ts index 548dbcaa13..52bfa29eca 100644 --- a/src/services/auth-service/auth-service.ts +++ b/src/services/auth-service/auth-service.ts @@ -68,13 +68,16 @@ export class AuthService { } } + public setTargetUrl(url: string) { + localStorage.setItem(TARGET_URL, url); + } + public removeTargetURL() { localStorage.removeItem(TARGET_URL); - sessionStorage.removeItem(TARGET_URL); } public getTargetURL() { - return this.getStorage().getItem(TARGET_URL); + return localStorage.getItem(TARGET_URL); } public removeApiToken() { @@ -113,7 +116,7 @@ export class AuthService { const currentUrl = `${window.location.protocol}//${window.location.host}/token`; const homeClusterHost = remoteHosts[homeCluster]; const rd = new URL(window.location.href); - this.getStorage().setItem(TARGET_URL, rd.pathname + rd.search); + this.setTargetUrl(rd.pathname + rd.search); window.location.assign(`https://${homeClusterHost}/login?${(uuidPrefix !== homeCluster && homeCluster !== loginCluster) ? "remote=" + uuidPrefix + "&" : ""}return_to=${currentUrl}`); } diff --git a/src/store/all-processes-panel/all-processes-panel-middleware-service.ts b/src/store/all-processes-panel/all-processes-panel-middleware-service.ts index 88b64e6243..5d5e77d6c6 100644 --- a/src/store/all-processes-panel/all-processes-panel-middleware-service.ts +++ b/src/store/all-processes-panel/all-processes-panel-middleware-service.ts @@ -37,7 +37,11 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe try { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const processItems = await this.services.containerRequestService.list( - { ...getParams(dataExplorer) }); + { + ...getParams(dataExplorer), + // Omit mounts when viewing all process panel + select: containerRequestFieldsNoMounts, + }); api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); api.dispatch(resourcesActions.SET_RESOURCES(processItems.items)); @@ -62,6 +66,44 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe } } +// Until the api supports unselecting fields, we need a list of all other fields to omit mounts +export const containerRequestFieldsNoMounts = [ + "command", + "container_count_max", + "container_count", + "container_image", + "container_uuid", + "created_at", + "cwd", + "description", + "environment", + "etag", + "expires_at", + "filters", + "href", + "kind", + "log_uuid", + "modified_at", + "modified_by_client_uuid", + "modified_by_user_uuid", + "name", + "output_name", + "output_path", + "output_properties", + "output_storage_classes", + "output_ttl", + "output_uuid", + "owner_uuid", + "priority", + "properties", + "requesting_container_uuid", + "runtime_constraints", + "scheduling_parameters", + "state", + "use_existing", + "uuid", +]; + const getParams = ( dataExplorer: DataExplorer ) => ({ ...dataExplorerToListParams(dataExplorer), order: getOrder(dataExplorer), diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index f01c186658..3bc91ae0c7 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -10,7 +10,7 @@ import { RootState } from 'store/store'; import { getResource, getResourceWithEditableStatus } from '../resources/resources'; import { UserResource } from 'models/user'; import { isSidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions'; -import { extractUuidKind, ResourceKind, EditableResource } from 'models/resource'; +import { extractUuidKind, ResourceKind, EditableResource, Resource } from 'models/resource'; import { Process } from 'store/processes/process'; import { RepositoryResource } from 'models/repositories'; import { SshKeyResource } from 'models/ssh-key'; @@ -277,3 +277,17 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) => return; } }; + +export const openSearchResultsContextMenu = (event: React.MouseEvent, uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + const res = getResource(uuid)(getState().resources); + if (res) { + dispatch(openContextMenu(event, { + name: '', + uuid: res.uuid, + ownerUuid: '', + kind: res.kind, + menuKind: ContextMenuKind.SEARCH_RESULTS, + })); + } + }; diff --git a/src/store/data-explorer/data-explorer-reducer.ts b/src/store/data-explorer/data-explorer-reducer.ts index f67cccdcd4..1e5cd88fa1 100644 --- a/src/store/data-explorer/data-explorer-reducer.ts +++ b/src/store/data-explorer/data-explorer-reducer.ts @@ -32,7 +32,7 @@ export const initialDataExplorer: DataExplorer = { itemsAvailable: 0, page: 0, rowsPerPage: 50, - rowsPerPageOptions: [50, 100, 200, 500], + rowsPerPageOptions: [10, 20, 50, 100, 200, 500], searchValue: "", requestState: DataTableRequestState.IDLE }; diff --git a/src/store/navigation/navigation-action.ts b/src/store/navigation/navigation-action.ts index c8811bf439..146530cae8 100644 --- a/src/store/navigation/navigation-action.ts +++ b/src/store/navigation/navigation-action.ts @@ -14,7 +14,7 @@ import { pluginConfig } from 'plugins'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions'; -const navigationNotAvailable = (id: string) => +export const navigationNotAvailable = (id: string) => snackbarActions.OPEN_SNACKBAR({ message: `${id} not available`, hideDuration: 3000, diff --git a/src/store/open-in-new-tab/open-in-new-tab.actions.ts b/src/store/open-in-new-tab/open-in-new-tab.actions.ts index 94aec140d6..c465aae869 100644 --- a/src/store/open-in-new-tab/open-in-new-tab.actions.ts +++ b/src/store/open-in-new-tab/open-in-new-tab.actions.ts @@ -3,35 +3,25 @@ // SPDX-License-Identifier: AGPL-3.0 import copy from 'copy-to-clipboard'; -import { ResourceKind } from 'models/resource'; -import { getClipboardUrl } from 'views-components/context-menu/actions/helpers'; +import { Dispatch } from 'redux'; +import { getNavUrl } from 'routes/routes'; +import { RootState } from 'store/store'; -const getUrl = (resource: any) => { - let url: string | null = null; - const { uuid, kind } = resource; +export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { + const url = getNavUrl(resource.uuid, getState().auth); - if (kind === ResourceKind.COLLECTION) { - url = `/collections/${uuid}`; - } - if (kind === ResourceKind.PROJECT) { - url = `/projects/${uuid}`; - } - - return url; -}; - -export const openInNewTabAction = (resource: any) => () => { - const url = getUrl(resource); - - if (url) { + if (url[0] === '/') { window.open(`${window.location.origin}${url}`, '_blank'); + } else if (url.length) { + window.open(url, '_blank'); } }; -export const copyToClipboardAction = (resource: any) => () => { - const url = getUrl(resource); +export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => { + // Copy to clipboard omits token to avoid accidental sharing + const url = getNavUrl(resource.uuid, getState().auth, false); if (url) { - copy(getClipboardUrl(url, false)); + copy(url); } -}; \ No newline at end of file +}; diff --git a/src/store/process-logs-panel/process-logs-panel-actions.ts b/src/store/process-logs-panel/process-logs-panel-actions.ts index b0ddd7ee11..d4f5ab5924 100644 --- a/src/store/process-logs-panel/process-logs-panel-actions.ts +++ b/src/store/process-logs-panel/process-logs-panel-actions.ts @@ -45,21 +45,25 @@ export const addProcessLogsPanelItem = (message: ResourceEventMessage<{ text: st async (dispatch: Dispatch, getState: () => RootState, { logService }: ServiceRepository) => { if (PROCESS_PANEL_LOG_EVENT_TYPES.indexOf(message.eventType) > -1) { const uuid = getProcessLogsPanelCurrentUuid(getState().router); - if (uuid) { - const process = getProcess(uuid)(getState().resources); - if (process) { - const { containerRequest, container } = process; - if (message.objectUuid === containerRequest.uuid - || (container && message.objectUuid === container.uuid)) { - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: COMBINED_FILTER_TYPE, - log: message.properties.text - })); - dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ - logType: message.eventType, - log: message.properties.text - })); - } + if (!uuid) { return } + const process = getProcess(uuid)(getState().resources); + if (!process) { return } + const { containerRequest, container } = process; + if (message.objectUuid === containerRequest.uuid + || (container && message.objectUuid === container.uuid)) { + dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ + logType: ALL_FILTER_TYPE, + log: message.properties.text + })); + dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ + logType: message.eventType, + log: message.properties.text + })); + if (MAIN_EVENT_TYPES.indexOf(message.eventType) > -1) { + dispatch(processLogsPanelActions.ADD_PROCESS_LOGS_PANEL_ITEM({ + logType: MAIN_FILTER_TYPE, + log: message.properties.text + })); } } } @@ -84,6 +88,9 @@ const loadContainerLogs = async (containerUuid: string, logService: LogService) const createInitialLogPanelState = (logResources: LogResource[]) => { const allLogs = logsToLines(logResources); + const mainLogs = logsToLines(logResources.filter( + e => MAIN_EVENT_TYPES.indexOf(e.eventType) > -1 + )); const groupedLogResources = groupBy(logResources, log => log.eventType); const groupedLogs = Object .keys(groupedLogResources) @@ -91,8 +98,12 @@ const createInitialLogPanelState = (logResources: LogResource[]) => { ...grouped, [key]: logsToLines(groupedLogResources[key]) }), {}); - const filters = [COMBINED_FILTER_TYPE, ...Object.keys(groupedLogs)]; - const logs = { [COMBINED_FILTER_TYPE]: allLogs, ...groupedLogs }; + const filters = [MAIN_FILTER_TYPE, ALL_FILTER_TYPE, ...Object.keys(groupedLogs)]; + const logs = { + [MAIN_FILTER_TYPE]: mainLogs, + [ALL_FILTER_TYPE]: allLogs, + ...groupedLogs + }; return { filters, logs }; }; @@ -111,7 +122,14 @@ export const navigateToLogCollection = (uuid: string) => const MAX_AMOUNT_OF_LOGS = 10000; -const COMBINED_FILTER_TYPE = 'All logs'; +const ALL_FILTER_TYPE = 'All logs'; + +const MAIN_FILTER_TYPE = 'Main logs'; +const MAIN_EVENT_TYPES = [ + LogEventType.CRUNCH_RUN, + LogEventType.STDERR, + LogEventType.STDOUT, +]; const PROCESS_PANEL_LOG_EVENT_TYPES = [ LogEventType.ARV_MOUNT, @@ -123,4 +141,5 @@ const PROCESS_PANEL_LOG_EVENT_TYPES = [ LogEventType.STDERR, LogEventType.STDOUT, LogEventType.CONTAINER, + LogEventType.KEEPSTORE, ]; diff --git a/src/store/process-logs-panel/process-logs-panel-reducer.ts b/src/store/process-logs-panel/process-logs-panel-reducer.ts index c7d694c0ee..c502f1b1ff 100644 --- a/src/store/process-logs-panel/process-logs-panel-reducer.ts +++ b/src/store/process-logs-panel/process-logs-panel-reducer.ts @@ -24,10 +24,13 @@ export const processLogsPanelReducer = (state = initialState, action: ProcessLog selectedFilter }), ADD_PROCESS_LOGS_PANEL_ITEM: ({ logType, log }) => { + const filters = state.filters.indexOf(logType) > -1 + ? state.filters + : [...state.filters, logType]; const currentLogs = state.logs[logType] || []; const logsOfType = [...currentLogs, log]; const logs = { ...state.logs, [logType]: logsOfType }; - return { ...state, logs }; + return { ...state, logs, filters }; }, default: () => state, }); diff --git a/src/store/processes/process-command-actions.ts b/src/store/processes/process-command-actions.ts deleted file mode 100644 index 9dec9b30ce..0000000000 --- a/src/store/processes/process-command-actions.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import { dialogActions } from 'store/dialog/dialog-actions'; -import { RootState } from 'store/store'; -import { Dispatch } from 'redux'; -import { getProcess } from 'store/processes/process'; -import { quote } from 'shell-quote'; - -export const PROCESS_COMMAND_DIALOG_NAME = 'processCommandDialog'; - -export interface ProcessCommandDialogData { - command: string; - processName: string; -} - -export const openProcessCommandDialog = (processUuid: string) => - (dispatch: Dispatch, getState: () => RootState) => { - const process = getProcess(processUuid)(getState().resources); - if (process) { - const data: ProcessCommandDialogData = { - command: quote(process.containerRequest.command), - processName: process.containerRequest.name, - }; - dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_COMMAND_DIALOG_NAME, data })); - } - }; diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts index 213e292bfe..c4d421ac09 100644 --- a/src/store/processes/processes-actions.ts +++ b/src/store/processes/processes-actions.ts @@ -34,13 +34,55 @@ export const loadProcess = (containerRequestUuid: string) => return { containerRequest }; }; -export const loadContainers = (filters: string) => +export const loadContainers = (filters: string, loadMounts: boolean = true) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { - const { items } = await services.containerService.list({ filters }); + let args: any = { filters }; + if (!loadMounts) { + args.select = containerFieldsNoMounts; + } + const { items } = await services.containerService.list(args); dispatch(updateResources(items)); return items; }; +// Until the api supports unselecting fields, we need a list of all other fields to omit mounts +const containerFieldsNoMounts = [ + "auth_uuid", + "command", + "container_image", + "created_at", + "cwd", + "environment", + "etag", + "exit_code", + "finished_at", + "gateway_address", + "href", + "interactive_session_started", + "kind", + "lock_count", + "locked_by_uuid", + "log", + "modified_at", + "modified_by_client_uuid", + "modified_by_user_uuid", + "output_path", + "output_properties", + "output_storage_classes", + "output", + "owner_uuid", + "priority", + "progress", + "runtime_auth_scopes", + "runtime_constraints", + "runtime_status", + "runtime_user_uuid", + "scheduling_parameters", + "started_at", + "state", + "uuid", +] + export const cancelRunningWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { try { diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts index ccfa4fff9f..d8a5d82dc2 100644 --- a/src/store/project-panel/project-panel-middleware-service.ts +++ b/src/store/project-panel/project-panel-middleware-service.ts @@ -89,7 +89,8 @@ export const loadMissingProcessesInformation = (resources: GroupContentsResource }, []); if (containerUuids.length > 0) { await dispatch(loadContainers( - new FilterBuilder().addIn('uuid', containerUuids).getFilters() + new FilterBuilder().addIn('uuid', containerUuids).getFilters(), + false )); } }; diff --git a/src/store/projects/project-update-actions.ts b/src/store/projects/project-update-actions.ts index 52abfd3fd2..a6e6748535 100644 --- a/src/store/projects/project-update-actions.ts +++ b/src/store/projects/project-update-actions.ts @@ -22,6 +22,8 @@ import { projectPanelActions } from 'store/project-panel/project-panel-action'; import { GroupClass } from "models/group"; import { Participant } from "views-components/sharing-dialog/participant-select"; import { ProjectProperties } from "./project-create-actions"; +import { getResource } from "store/resources/resources"; +import { ProjectResource } from "models/project"; export interface ProjectUpdateFormDialogData { uuid: string; @@ -37,7 +39,9 @@ export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FOR export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState) => { - dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource)); + // Get complete project resource from store to handle consumers passing in partial resources + const project = getResource(resource.uuid)(getState().resources); + dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project)); dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: { diff --git a/src/store/subprocess-panel/subprocess-panel-middleware-service.ts b/src/store/subprocess-panel/subprocess-panel-middleware-service.ts index fb9b0e8b01..dd30323311 100644 --- a/src/store/subprocess-panel/subprocess-panel-middleware-service.ts +++ b/src/store/subprocess-panel/subprocess-panel-middleware-service.ts @@ -24,6 +24,7 @@ import { ProcessStatusFilter, buildProcessStatusFilters } from '../resource-type import { ContainerRequestResource } from 'models/container-request'; import { progressIndicatorActions } from '../progress-indicator/progress-indicator-actions'; import { loadMissingProcessesInformation } from '../project-panel/project-panel-middleware-service'; +import { containerRequestFieldsNoMounts } from 'store/all-processes-panel/all-processes-panel-middleware-service'; export class SubprocessMiddlewareService extends DataExplorerMiddlewareService { constructor(private services: ServiceRepository, id: string) { @@ -40,7 +41,10 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); const parentContainerRequest = await this.services.containerRequestService.get(parentContainerRequestUuid); const containerRequests = await this.services.containerRequestService.list( - { ...getParams(dataExplorer, parentContainerRequest) }); + { + ...getParams(dataExplorer, parentContainerRequest) , + select: containerRequestFieldsNoMounts + }); api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); api.dispatch(updateResources(containerRequests.items)); diff --git a/src/views-components/context-menu/action-sets/process-resource-action-set.ts b/src/views-components/context-menu/action-sets/process-resource-action-set.ts index 55b2d31fab..f17d74c92b 100644 --- a/src/views-components/context-menu/action-sets/process-resource-action-set.ts +++ b/src/views-components/context-menu/action-sets/process-resource-action-set.ts @@ -7,7 +7,7 @@ import { ToggleFavoriteAction } from "../actions/favorite-action"; import { toggleFavorite } from "store/favorites/favorites-actions"; import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, - RemoveIcon, ReRunProcessIcon, InputIcon, OutputIcon, CommandIcon, + RemoveIcon, ReRunProcessIcon, OutputIcon, AdvancedIcon } from "components/icon/icon"; import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action"; @@ -18,9 +18,7 @@ import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions"; import { openRemoveProcessDialog, reRunProcess } from "store/processes/processes-actions"; import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions"; -import { openProcessInputDialog } from "store/processes/process-input-actions"; import { navigateToOutput } from "store/process-panel/process-panel-actions"; -import { openProcessCommandDialog } from "store/processes/process-command-actions"; import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; import { TogglePublicFavoriteAction } from "../actions/public-favorite-action"; import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions"; @@ -53,13 +51,6 @@ export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[ } } }, - { - icon: InputIcon, - name: "Inputs", - execute: (dispatch, resource) => { - dispatch(openProcessInputDialog(resource.uuid)); - } - }, { icon: OutputIcon, name: "Outputs", @@ -69,13 +60,6 @@ export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[ } } }, - { - icon: CommandIcon, - name: "Command", - execute: (dispatch, resource) => { - dispatch(openProcessCommandDialog(resource.uuid)); - } - }, { icon: DetailsIcon, name: "View details", @@ -135,4 +119,4 @@ export const processResourceAdminActionSet: ContextMenuActionSet = [[ }); } }, -]]; \ No newline at end of file +]]; diff --git a/src/views-components/context-menu/action-sets/search-results-action-set.ts b/src/views-components/context-menu/action-sets/search-results-action-set.ts new file mode 100644 index 0000000000..e916a1057a --- /dev/null +++ b/src/views-components/context-menu/action-sets/search-results-action-set.ts @@ -0,0 +1,42 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { ContextMenuActionSet } from "../context-menu-action-set"; +import { DetailsIcon, AdvancedIcon, OpenIcon, Link } from 'components/icon/icon'; +import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab"; +import { toggleDetailsPanel } from 'store/details-panel/details-panel-action'; +import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions"; + +export const searchResultsActionSet: ContextMenuActionSet = [ + [ + { + icon: OpenIcon, + name: "Open in new tab", + execute: (dispatch, resource) => { + dispatch(openInNewTabAction(resource)); + } + }, + { + icon: Link, + name: "Copy to clipboard", + execute: (dispatch, resource) => { + dispatch(copyToClipboardAction(resource)); + } + }, + { + icon: DetailsIcon, + name: "View details", + execute: dispatch => { + dispatch(toggleDetailsPanel()); + } + }, + { + icon: AdvancedIcon, + name: "Advanced", + execute: (dispatch, resource) => { + dispatch(openAdvancedTabDialog(resource.uuid)); + } + }, + ] +]; diff --git a/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx b/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx index c34087400c..50ed20fd7f 100644 --- a/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx +++ b/src/views-components/context-menu/actions/copy-to-clipboard-action.tsx @@ -6,12 +6,12 @@ import React from "react"; import copy from 'copy-to-clipboard'; import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core"; import { Link } from "components/icon/icon"; -import { getClipboardUrl } from "./helpers"; +import { getCollectionItemClipboardUrl } from "./helpers"; export const CopyToClipboardAction = (props: { href?: any, download?: any, onClick?: () => void, kind?: string, currentCollectionUuid?: string; }) => { const copyToClipboard = () => { if (props.href) { - const clipboardUrl = getClipboardUrl(props.href, true, true); + const clipboardUrl = getCollectionItemClipboardUrl(props.href, true, true); copy(clipboardUrl); } diff --git a/src/views-components/context-menu/actions/helpers.test.ts b/src/views-components/context-menu/actions/helpers.test.ts index b3b7f7f8da..7776d0e5aa 100644 --- a/src/views-components/context-menu/actions/helpers.test.ts +++ b/src/views-components/context-menu/actions/helpers.test.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: AGPL-3.0 -import { sanitizeToken, getClipboardUrl, getInlineFileUrl } from "./helpers"; +import { sanitizeToken, getCollectionItemClipboardUrl, getInlineFileUrl } from "./helpers"; describe('helpers', () => { // given @@ -22,7 +22,7 @@ describe('helpers', () => { describe('getClipboardUrl', () => { it('should add redirectTo query param', () => { // when - const result = getClipboardUrl(url); + const result = getCollectionItemClipboardUrl(url); // then expect(result).toBe('http://localhost?redirectToDownload=https://example.com/c=zzzzz-4zz18-0123456789abcde/LIMS/1.html'); diff --git a/src/views-components/context-menu/actions/helpers.ts b/src/views-components/context-menu/actions/helpers.ts index f196074d7e..9140e457af 100644 --- a/src/views-components/context-menu/actions/helpers.ts +++ b/src/views-components/context-menu/actions/helpers.ts @@ -14,7 +14,10 @@ export const sanitizeToken = (href: string, tokenAsQueryParam = true): string => return `${[prefix, ...rest].join('/')}${tokenAsQueryParam ? `${sep}api_token=${token}` : ''}`; }; -export const getClipboardUrl = (href: string, shouldSanitizeToken = true, inline = false): string => { +/** + * @returns A shareable token-free WB2 url that redirects to keep-web after login + */ +export const getCollectionItemClipboardUrl = (href: string, shouldSanitizeToken = true, inline = false): string => { const { origin } = window.location; const url = shouldSanitizeToken ? sanitizeToken(href, false) : href; const redirectKey = inline ? REDIRECT_TO_PREVIEW_KEY : REDIRECT_TO_DOWNLOAD_KEY; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index f89a17809a..c659b7c508 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -113,4 +113,5 @@ export enum ContextMenuKind { PERMISSION_EDIT = "PermissionEdit", LINK = "Link", WORKFLOW = "Workflow", + SEARCH_RESULTS = "SearchResults" } diff --git a/src/views-components/login-form/login-form.tsx b/src/views-components/login-form/login-form.tsx index aac1364207..3aa9e3f25a 100644 --- a/src/views-components/login-form/login-form.tsx +++ b/src/views-components/login-form/login-form.tsx @@ -12,6 +12,7 @@ import { AxiosPromise } from 'axios'; import { DispatchProp } from 'react-redux'; import { saveApiToken } from 'store/auth/auth-action'; import { navigateToRootProject } from 'store/navigation/navigation-action'; +import { replace } from 'react-router-redux'; type CssRules = 'root' | 'loginBtn' | 'card' | 'wrapper' | 'progress'; @@ -87,8 +88,11 @@ export const LoginForm = withStyles(styles)( 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(saveApiToken(apiToken)).finally( - () => dispatch(navigateToRootProject)); + () => rdUrl === '/' ? dispatch(navigateToRootProject) : dispatch(replace(rdUrl)) + ); } else { setError(true); setHelperText(response.data.message || 'Please try again'); diff --git a/src/views-components/process-command-dialog/process-command-dialog.tsx b/src/views-components/process-command-dialog/process-command-dialog.tsx deleted file mode 100644 index 7695837e6f..0000000000 --- a/src/views-components/process-command-dialog/process-command-dialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (C) The Arvados Authors. All rights reserved. -// -// SPDX-License-Identifier: AGPL-3.0 - -import React from "react"; -import { Dialog, DialogActions, Button, StyleRulesCallback, WithStyles, withStyles, Tooltip, IconButton, CardHeader } from '@material-ui/core'; -import { withDialog } from "store/dialog/with-dialog"; -import { PROCESS_COMMAND_DIALOG_NAME } from 'store/processes/process-command-actions'; -import { WithDialogProps } from 'store/dialog/with-dialog'; -import { ProcessCommandDialogData } from 'store/processes/process-command-actions'; -import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet"; -import { compose } from 'redux'; -import CopyToClipboard from "react-copy-to-clipboard"; -import { CopyIcon } from 'components/icon/icon'; - -type CssRules = 'codeSnippet' | 'copyToClipboard'; - -const styles: StyleRulesCallback = theme => ({ - codeSnippet: { - marginLeft: theme.spacing.unit * 3, - marginRight: theme.spacing.unit * 3, - }, - copyToClipboard: { - marginRight: theme.spacing.unit, - } -}); - -export const ProcessCommandDialog = compose( - withDialog(PROCESS_COMMAND_DIALOG_NAME), - withStyles(styles), -)( - (props: WithDialogProps & WithStyles) => - - - - - - - - - } /> - - - - - -); \ No newline at end of file diff --git a/src/views-components/process-runtime-status/process-runtime-status.tsx b/src/views-components/process-runtime-status/process-runtime-status.tsx index 3858e49e23..3b5fae3639 100644 --- a/src/views-components/process-runtime-status/process-runtime-status.tsx +++ b/src/views-components/process-runtime-status/process-runtime-status.tsx @@ -7,6 +7,7 @@ import { ExpansionPanel, ExpansionPanelDetails, ExpansionPanelSummary, + Paper, StyleRulesCallback, Typography, withStyles, @@ -26,7 +27,8 @@ type CssRules = 'root' | 'error' | 'errorColor' | 'warning' - | 'warningColor'; + | 'warningColor' + | 'paperRoot'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ root: { @@ -65,15 +67,21 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ warningColor: { color: theme.customs.colors.yellow900, }, + paperRoot: { + minHeight: theme.spacing.unit * 6, + display: 'flex', + alignItems: 'center', + }, }); export interface ProcessRuntimeStatusDataProps { runtimeStatus: RuntimeStatus | undefined; + containerCount: number; } type ProcessRuntimeStatusProps = ProcessRuntimeStatusDataProps & WithStyles; export const ProcessRuntimeStatus = withStyles(styles)( - ({ runtimeStatus, classes }: ProcessRuntimeStatusProps) => { + ({ runtimeStatus, containerCount, classes }: ProcessRuntimeStatusProps) => { return
{ runtimeStatus?.error &&
@@ -103,5 +111,14 @@ export const ProcessRuntimeStatus = withStyles(styles)(
} + { containerCount > 1 && +
+ + + {`Warning: Process retried ${containerCount - 1} time${containerCount > 2 ? 's' : ''} due to failure.`} + + +
+ }
-}); \ No newline at end of file +}); diff --git a/src/views-components/resource-properties/resource-properties-list.tsx b/src/views-components/resource-properties/resource-properties-list.tsx index a7b5825244..47d7729b4d 100644 --- a/src/views-components/resource-properties/resource-properties-list.tsx +++ b/src/views-components/resource-properties/resource-properties-list.tsx @@ -38,7 +38,7 @@ ResourcePropertiesListActionProps & WithStyles; const List = withStyles(styles)( ({ classes, handleDelete, properties }: ResourcePropertiesListProps) => -
+
{properties && Object.keys(properties).map(k => Array.isArray(properties[k]) @@ -63,4 +63,4 @@ export const resourcePropertiesList = (formName: string) => (dispatch: Dispatch): ResourcePropertiesListActionProps => ({ handleDelete: (key: string, value: string) => dispatch(removePropertyFromResourceForm(key, value, formName)) }) - )(List); \ No newline at end of file + )(List); diff --git a/src/views-components/token-dialog/token-dialog.test.tsx b/src/views-components/token-dialog/token-dialog.test.tsx index d2ff77e3d4..400bb1e687 100644 --- a/src/views-components/token-dialog/token-dialog.test.tsx +++ b/src/views-components/token-dialog/token-dialog.test.tsx @@ -16,6 +16,8 @@ import { mount, configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import CopyToClipboard from 'react-copy-to-clipboard'; import { TokenDialogComponent } from './token-dialog'; +import { combineReducers, createStore } from 'redux'; +import { Provider } from 'react-redux'; configure({ adapter: new Adapter() }); @@ -24,6 +26,7 @@ jest.mock('toggle-selection', () => () => () => null); describe('', () => { let props; let wrapper; + let store; beforeEach(() => { props = { @@ -33,11 +36,25 @@ describe('', () => { open: true, dispatch: jest.fn(), }; + + const initialAuthState = { + localCluster: "zzzzz", + remoteHostsConfig: {}, + sessions: {}, + }; + + store = createStore(combineReducers({ + auth: (state: any = initialAuthState, action: any) => state, + })); }); describe('Get API Token dialog', () => { beforeEach(() => { - wrapper = mount(); + wrapper = mount( + + + + ); }); it('should include API host and token', () => { @@ -51,7 +68,10 @@ describe('', () => { const someDate = '2140-01-01T00:00:00.000Z' props.tokenExpiration = new Date(someDate); - wrapper = mount(); + wrapper = mount( + + + ); expect(wrapper.html()).toContain(props.tokenExpiration.toLocaleString()); }); @@ -60,14 +80,20 @@ describe('', () => { expect(wrapper.html()).not.toContain('GET NEW TOKEN'); props.canCreateNewTokens = true; - wrapper = mount(); + wrapper = mount( + + + ); expect(wrapper.html()).toContain('GET NEW TOKEN'); }); }); describe('copy to clipboard button', () => { beforeEach(() => { - wrapper = mount(); + wrapper = mount( + + + ); }); it('should copy API TOKEN to the clipboard', () => { diff --git a/src/views/process-panel/process-cmd-card.tsx b/src/views/process-panel/process-cmd-card.tsx new file mode 100644 index 0000000000..4143501e23 --- /dev/null +++ b/src/views/process-panel/process-cmd-card.tsx @@ -0,0 +1,133 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { + StyleRulesCallback, + WithStyles, + withStyles, + Card, + CardHeader, + IconButton, + CardContent, + Tooltip, + Typography, + Grid, +} from '@material-ui/core'; +import { ArvadosTheme } from 'common/custom-theme'; +import { CloseIcon, CommandIcon, CopyIcon } from 'components/icon/icon'; +import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; +import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet'; +import { Process } from 'store/processes/process'; +import shellescape from 'shell-escape'; +import CopyToClipboard from 'react-copy-to-clipboard'; + +type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader'; + +const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ + card: { + height: '100%' + }, + header: { + paddingTop: theme.spacing.unit, + paddingBottom: theme.spacing.unit, + }, + iconHeader: { + fontSize: '1.875rem', + color: theme.customs.colors.green700, + }, + avatar: { + alignSelf: 'flex-start', + paddingTop: theme.spacing.unit * 0.5 + }, + content: { + padding: theme.spacing.unit * 1.0, + paddingTop: theme.spacing.unit * 0.5, + '&:last-child': { + paddingBottom: theme.spacing.unit * 1, + } + }, + title: { + overflow: 'hidden', + paddingTop: theme.spacing.unit * 0.5 + }, +}); + +interface ProcessCmdCardDataProps { + process: Process; + onCopy: (text: string) => void; +} + +type ProcessCmdCardProps = ProcessCmdCardDataProps & WithStyles & MPVPanelProps; + +export const ProcessCmdCard = withStyles(styles)( + ({ + process, + onCopy, + classes, + doHidePanel, + }: ProcessCmdCardProps) => { + const command = process.containerRequest.command.map((v) => + shellescape([v]) // Escape each arg separately + ); + + let formattedCommand = [...command]; + formattedCommand.forEach((item, i, arr) => { + // Indent lines after the first + const indent = i > 0 ? ' ' : ''; + // Escape newlines on every non-last arg when there are multiple lines + const lineBreak = arr.length > 1 && i < arr.length - 1 ? ' \\' : ''; + arr[i] = `${indent}${item}${lineBreak}`; + }); + + return ( + + } + title={ + + Command + + } + action={ + + + + + onCopy("Command copied to clipboard")} + > + + + + + + + {doHidePanel && ( + + + + + + )} + + + } + /> + + + + + ); + } +); diff --git a/src/views/process-panel/process-details-attributes.tsx b/src/views/process-panel/process-details-attributes.tsx index 1e3e5591e7..4af2c9cda7 100644 --- a/src/views/process-panel/process-details-attributes.tsx +++ b/src/views/process-panel/process-details-attributes.tsx @@ -64,7 +64,7 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })( const mdSize = props.twoCol ? 6 : 12; return - + {!props.hideProcessPanelRedundantFields && diff --git a/src/views/process-panel/process-log-card.tsx b/src/views/process-panel/process-log-card.tsx index ac409ec187..936b31a549 100644 --- a/src/views/process-panel/process-log-card.tsx +++ b/src/views/process-panel/process-log-card.tsx @@ -24,7 +24,8 @@ import { MaximizeIcon, TextDecreaseIcon, TextIncreaseIcon, - WordWrapIcon + WordWrapOffIcon, + WordWrapOnIcon, } from 'components/icon/icon'; import { Process } from 'store/processes/process'; import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view'; @@ -92,7 +93,7 @@ export const ProcessLogsCard = withStyles(styles)( ({ classes, process, filters, selectedFilter, lines, onLogFilterChange, navigateToLog, onCopy, doHidePanel, doMaximizePanel, panelMaximized, panelName }: ProcessLogsCardProps) => { - const [wordWrapToggle, setWordWrapToggle] = useState(true); + const [wordWrap, setWordWrap] = useState(true); const [fontSize, setFontSize] = useState(3); const fontBaseSize = 10; const fontStepSize = 1; @@ -130,9 +131,9 @@ export const ProcessLogsCard = withStyles(styles)( - - setWordWrapToggle(!wordWrapToggle)}> - + + setWordWrap(!wordWrap)}> + {wordWrap ? : } @@ -166,7 +167,7 @@ export const ProcessLogsCard = withStyles(styles)( spacing={24} direction='column'> - + : = (theme: ArvadosTheme) => ({ overflow: 'auto', backgroundColor: '#000', height: `calc(100% - ${theme.spacing.unit * 4}px)`, // so that horizontal scollbar is visible + "& a": { + color: theme.palette.primary.main, + }, }, logText: { padding: theme.spacing.unit * 0.5, @@ -55,7 +60,11 @@ interface ProcessLogCodeSnippetProps { wordWrap?: boolean; } -const renderLinks = (fontSize: number, dispatch: Dispatch) => (text: string) => { +interface ProcessLogCodeSnippetAuthProps { + auth: FederationConfig; +} + +const renderLinks = (fontSize: number, auth: FederationConfig, dispatch: Dispatch) => (text: string) => { // Matches UUIDs & PDHs const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g; const links = text.match(REGEX); @@ -67,7 +76,14 @@ const renderLinks = (fontSize: number, dispatch: Dispatch) => (text: string) => {part} {links[index] && - dispatch(navigateTo(links[index]))} + { + const url = getNavUrl(links[index], auth) + if (url) { + window.open(`${window.location.origin}${url}`, '_blank'); + } else { + dispatch(navigationNotAvailable(links[index])); + } + }} style={ {cursor: 'pointer'} }> {links[index]} } @@ -76,9 +92,13 @@ const renderLinks = (fontSize: number, dispatch: Dispatch) => (text: string) => ; }; -export const ProcessLogCodeSnippet = withStyles(styles)(connect()( - ({classes, lines, fontSize, dispatch, wordWrap}: ProcessLogCodeSnippetProps & WithStyles & DispatchProp) => { - const [followMode, setFollowMode] = useState(false); +const mapStateToProps = (state: RootState): ProcessLogCodeSnippetAuthProps => ({ + auth: state.auth, +}); + +export const ProcessLogCodeSnippet = withStyles(styles)(connect(mapStateToProps)( + ({classes, lines, fontSize, auth, dispatch, wordWrap}: ProcessLogCodeSnippetProps & WithStyles & ProcessLogCodeSnippetAuthProps & DispatchProp) => { + const [followMode, setFollowMode] = useState(true); const scrollRef = useRef(null); useEffect(() => { @@ -92,7 +112,7 @@ export const ProcessLogCodeSnippet = withStyles(styles)(connect()(
{ const elem = e.target as HTMLDivElement; - if (elem.scrollTop + elem.clientHeight >= elem.scrollHeight) { + if (elem.scrollTop + (elem.clientHeight*1.1) >= elem.scrollHeight) { setFollowMode(true); } else { setFollowMode(false); @@ -101,7 +121,7 @@ export const ProcessLogCodeSnippet = withStyles(styles)(connect()( { lines.map((line: string, index: number) => - {renderLinks(fontSize, dispatch)(line)} + {renderLinks(fontSize, auth, dispatch)(line)} ) }
diff --git a/src/views/process-panel/process-panel-root.tsx b/src/views/process-panel/process-panel-root.tsx index 4f95d0d887..f8ff84304d 100644 --- a/src/views/process-panel/process-panel-root.tsx +++ b/src/views/process-panel/process-panel-root.tsx @@ -15,6 +15,7 @@ import { ProcessDetailsCard } from './process-details-card'; import { getProcessPanelLogs, ProcessLogsPanel } from 'store/process-logs-panel/process-logs-panel'; import { ProcessLogsCard } from './process-log-card'; import { FilterOption } from 'views/process-panel/process-log-form'; +import { ProcessCmdCard } from './process-cmd-card'; type CssRules = 'root'; @@ -37,13 +38,14 @@ export interface ProcessPanelRootActionProps { cancelProcess: (uuid: string) => void; onLogFilterChange: (filter: FilterOption) => void; navigateToLog: (uuid: string) => void; - onLogCopyToClipboard: (uuid: string) => void; + onCopyToClipboard: (uuid: string) => void; } export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles; const panelsData: MPVPanelState[] = [ {name: "Details"}, + {name: "Command"}, {name: "Logs", visible: true}, {name: "Subprocesses"}, ]; @@ -59,9 +61,14 @@ export const ProcessPanelRoot = withStyles(styles)( cancelProcess={props.cancelProcess} /> + + + ({ - onLogCopyToClipboard: (message: string) => { + onCopyToClipboard: (message: string) => { dispatch(snackbarActions.OPEN_SNACKBAR({ message, hideDuration: 2000, diff --git a/src/views/search-results-panel/search-results-panel.tsx b/src/views/search-results-panel/search-results-panel.tsx index d25682f6e1..0902f15bdc 100644 --- a/src/views/search-results-panel/search-results-panel.tsx +++ b/src/views/search-results-panel/search-results-panel.tsx @@ -5,8 +5,7 @@ import { Dispatch } from "redux"; import { connect } from "react-redux"; import { navigateTo } from 'store/navigation/navigation-action'; -// import { openContextMenu, resourceKindToContextMenuKind } from 'store/context-menu/context-menu-actions'; -// import { ResourceKind } from 'models/resource'; +import { openSearchResultsContextMenu } from 'store/context-menu/context-menu-actions'; import { loadDetailsPanel } from 'store/details-panel/details-panel-action'; import { SearchResultsPanelView } from 'views/search-results-panel/search-results-panel-view'; import { RootState } from 'store/store'; @@ -42,7 +41,9 @@ const mapStateToProps = (rootState: RootState) => { }; const mapDispatchToProps = (dispatch: Dispatch): SearchResultsPanelActionProps => ({ - onContextMenu: (event, resourceUuid) => { return; }, + onContextMenu: (event, resourceUuid) => { + dispatch(openSearchResultsContextMenu(event, resourceUuid)); + }, onDialogOpen: (ownerUuid: string) => { return; }, onItemClick: (resourceUuid: string) => { dispatch(loadDetailsPanel(resourceUuid)); diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index 28fae4cd6b..a6c49e3484 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -33,7 +33,6 @@ import { MoveProjectDialog } from 'views-components/dialog-forms/move-project-di import { MoveCollectionDialog } from 'views-components/dialog-forms/move-collection-dialog'; import { FilesUploadCollectionDialog } from 'views-components/dialog-forms/files-upload-collection-dialog'; import { PartialCopyCollectionDialog } from 'views-components/dialog-forms/partial-copy-collection-dialog'; -import { ProcessCommandDialog } from 'views-components/process-command-dialog/process-command-dialog'; import { RemoveProcessDialog } from 'views-components/process-remove-dialog/process-remove-dialog'; import { MainContentBar } from 'views-components/main-content-bar/main-content-bar'; import { Grid } from '@material-ui/core'; @@ -241,7 +240,6 @@ export const WorkbenchPanel = - diff --git a/tools/run-integration-tests.sh b/tools/run-integration-tests.sh index 0a9a0fc42a..367ccecd35 100755 --- a/tools/run-integration-tests.sh +++ b/tools/run-integration-tests.sh @@ -114,6 +114,7 @@ coproc arvboot (~/go/bin/arvados-server boot \ -type test \ -config ${TMPDIR}/arvados.yml \ -no-workbench1 \ + -no-workbench2 \ -own-temporary-database \ -timeout 20m 2> ${ARVADOS_LOG}) trap cleanup ERR EXIT diff --git a/yarn.lock b/yarn.lock index 13ea553a01..6dfb5b18b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2817,10 +2817,10 @@ __metadata: languageName: node linkType: hard -"@types/shell-quote@npm:1.6.0": - version: 1.6.0 - resolution: "@types/shell-quote@npm:1.6.0" - checksum: 5d9f4e35c8df32d9994f8ae2f1a1fe8a6b7ee96794f803e0904ceae7ad7255a214954e85cd75bd847fe77458d3746430522e87237438f223b7d72a23c4928c0e +"@types/shell-escape@npm:^0.2.0": + version: 0.2.0 + resolution: "@types/shell-escape@npm:0.2.0" + checksum: 020696ed313eeb02deb2abcc581e8b570be6f9ee662892339965b524bb4fbdc9a97b6520d914117740ec11147b0b1aa52358b8e03fa214c2da99743adb196853 languageName: node linkType: hard @@ -3600,13 +3600,6 @@ __metadata: languageName: node linkType: hard -"array-filter@npm:~0.0.0": - version: 0.0.1 - resolution: "array-filter@npm:0.0.1" - checksum: 0e9afdf5e248c45821c6fe1232071a13a3811e1902c2c2a39d12e4495e8b0b25739fd95bffbbf9884b9693629621f6077b4ae16207b8f23d17710fc2465cebbb - languageName: node - linkType: hard - "array-find-index@npm:^1.0.1": version: 1.0.2 resolution: "array-find-index@npm:1.0.2" @@ -3648,20 +3641,6 @@ __metadata: languageName: node linkType: hard -"array-map@npm:~0.0.0": - version: 0.0.0 - resolution: "array-map@npm:0.0.0" - checksum: 30d73fdc99956c8bd70daea40db5a7d78c5c2c75a03c64fc77904885e79adf7d5a0595076534f4e58962d89435f0687182ac929e65634e3d19931698cbac8149 - languageName: node - linkType: hard - -"array-reduce@npm:~0.0.0": - version: 0.0.0 - resolution: "array-reduce@npm:0.0.0" - checksum: d6226325271f477e3dd65b4d40db8597735b8d08bebcca4972e52d3c173d6c697533664fa8865789ea2d076bdaf1989bab5bdfbb61598be92074a67f13057c3a - languageName: node - linkType: hard - "array-union@npm:^1.0.1": version: 1.0.2 resolution: "array-union@npm:1.0.2" @@ -3769,7 +3748,7 @@ __metadata: "@types/redux-devtools": 3.0.44 "@types/redux-form": 7.4.12 "@types/redux-mock-store": 1.0.2 - "@types/shell-quote": 1.6.0 + "@types/shell-escape": ^0.2.0 "@types/sinon": 7.5 "@types/uuid": 3.4.4 axios: ^0.21.1 @@ -3829,7 +3808,7 @@ __metadata: redux-thunk: 2.3.0 reselect: 4.0.0 set-value: 2.0.1 - shell-quote: 1.6.1 + shell-escape: ^0.2.0 sinon: 7.3 ts-mock-imports: 1.3.7 tslint: 5.20.0 @@ -16502,15 +16481,10 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:1.6.1": - version: 1.6.1 - resolution: "shell-quote@npm:1.6.1" - dependencies: - array-filter: ~0.0.0 - array-map: ~0.0.0 - array-reduce: ~0.0.0 - jsonify: ~0.0.0 - checksum: 982a4fdf2d474f0dc40885de4222f100ba457d7c75d46b532bf23b01774b8617bc62522c6825cb1fa7dd4c54c18e9dcbae7df2ca8983101841b6f2e6a7cacd2f +"shell-escape@npm:^0.2.0": + version: 0.2.0 + resolution: "shell-escape@npm:0.2.0" + checksum: 0d87f1ae22ad22a74e148348ceaf64721e3024f83c90afcfb527318ce10ece654dd62e103dd89a242f2f4e4ce3cecdef63e3d148c40e5fabca8ba6c508f97d9f languageName: node linkType: hard