From: Stephen Smith Date: Mon, 14 Feb 2022 15:31:50 +0000 (-0500) Subject: Merge branch '18284-vm-listing' into main. Closes #18284 X-Git-Tag: 2.4.0~12 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/5e805cf2209d3afe42699e4658d8a12e50bcd5a4?hp=d861bd54089e9279cd03b2e4561869ee877b9559 Merge branch '18284-vm-listing' into main. Closes #18284 Arvados-DCO-1.1-Signed-off-by: Stephen Smith --- diff --git a/cypress/integration/virtual-machine-admin.spec.js b/cypress/integration/virtual-machine-admin.spec.js new file mode 100644 index 00000000..73804b20 --- /dev/null +++ b/cypress/integration/virtual-machine-admin.spec.js @@ -0,0 +1,274 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +describe('Virtual machine login manage tests', function() { + let activeUser; + let adminUser; + + const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`; + + before(function() { + // Only set up common users once. These aren't set up as aliases because + // aliases are cleaned up after every test. Also it doesn't make sense + // to set the same users on beforeEach() over and over again, so we + // separate a little from Cypress' 'Best Practices' here. + cy.getUser('admin', 'VMAdmin', 'User', true, true) + .as('adminUser').then(function() { + adminUser = this.adminUser; + } + ); + cy.getUser('user', 'VMActive', 'User', false, true) + .as('activeUser').then(function() { + activeUser = this.activeUser; + } + ); + }); + + it('adds and removes vm logins', function() { + cy.loginAs(adminUser); + cy.createVirtualMachine(adminUser.token, {hostname: vmHost}); + + // Navigate to VM admin + cy.get('header button[title="Admin Panel"]').click(); + cy.get('#admin-menu').contains('Virtual Machines').click(); + + // Add login permission to admin + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('button[title="Add Login Permission"]').click(); + }); + cy.get('[data-cy=form-dialog]') + .should('contain', 'Add login permission') + .within(() => { + cy.get('label') + .contains('Search for user') + .parent() + .within(() => { + cy.get('input').type('VMAdmin'); + }) + }); + cy.get('[role=tooltip]').click(); + cy.get('[data-cy=form-dialog]') + .should('contain', 'Add login permission') + .within(() => { + cy.get('label') + .contains('Add groups') + .parent() + .within(() => { + cy.get('input').type('docker sudo{enter}'); + }) + }); + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=form-submit-btn]').click(); + }); + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('admin'); + }); + + // Add login permission to activeUser + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('button[title="Add Login Permission"]').click(); + }); + cy.get('[data-cy=form-dialog]') + .should('contain', 'Add login permission') + .within(() => { + cy.get('label') + .contains('Search for user') + .parent() + .within(() => { + cy.get('input').type('VMActive user'); + }) + }); + cy.get('[role=tooltip]').click(); + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=form-submit-btn]').click(); + }); + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('user'); + }); + + // Check admin's vm page for login + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('admin'); + cy.get('td').contains('docker'); + cy.get('td').contains('sudo'); + cy.get('td').contains('ssh admin@' + vmHost); + }); + + // Check activeUser's vm page for login + cy.loginAs(activeUser); + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('user'); + cy.get('td').should('not.contain', 'docker'); + cy.get('td').should('not.contain', 'sudo'); + cy.get('td').contains('ssh user@' + vmHost); + }); + + // Edit login permissions + cy.loginAs(adminUser); + cy.get('header button[title="Admin Panel"]').click(); + cy.get('#admin-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-admin-table]') + .contains('admin'); // Wait for page to finish + + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .contains('admin') + .click(); + + cy.get('[data-cy=form-dialog]') + .should('contain', 'Update login permission') + .within(() => { + cy.get('label') + .contains('Add groups') + .parent() + .as('groupInput'); + }); + + cy.get('@groupInput').within(() => { + cy.get('div[role=button]').contains('sudo').parent().find('svg').click(); + cy.get('div[role=button]').contains('docker').parent().find('svg').click(); + }); + + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=form-submit-btn]').click(); + }); + + cy.get('[data-cy=vm-admin-table]') + .contains('user'); // Wait for page to finish + + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .contains('user') + .click(); + + cy.get('[data-cy=form-dialog]') + .should('contain', 'Update login permission') + .within(() => { + cy.get('label') + .contains('Add groups') + .parent() + .within(() => { + cy.get('input').type('docker{enter}'); + }) + }); + + cy.get('[data-cy=form-dialog]').within(() => { + cy.get('[data-cy=form-submit-btn]').click(); + }); + + // Verify new login permissions + // Check admin's vm page for login + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('admin'); + cy.get('td').should('not.contain', 'docker'); + cy.get('td').should('not.contain', 'sudo'); + cy.get('td').contains('ssh admin@' + vmHost); + }); + + // Verify new login permissions + // Check activeUser's vm page for login + cy.loginAs(activeUser); + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('td').contains('user'); + cy.get('td').contains('docker'); + cy.get('td').should('not.contain', 'sudo'); + cy.get('td').contains('ssh user@' + vmHost); + }); + + // Remove login permissions + cy.loginAs(adminUser); + cy.get('header button[title="Admin Panel"]').click(); + cy.get('#admin-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-admin-table]') + .contains('user'); // Wait for page to finish + + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .as('vmRow') + .contains('user') + .parents('[role=button]') + .find('svg') + .as('removeButton'); + cy.get('@removeButton').click(); + cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + + cy.get('@vmRow') + .within(() => { + cy.get('div[role=button]').should('not.contain', 'user'); + cy.get('div[role=button]').should('have.length', 1) + }); + + cy.get('@vmRow') + .find('div[role=button]') + .contains('admin') + .parents('[role=button]') + .find('svg') + .as('removeButton'); + cy.get('@removeButton').click(); + cy.get('[data-cy=confirmation-dialog-ok-btn]').click(); + + cy.get('[data-cy=vm-admin-table]') + .contains(vmHost) + .parents('tr') + .within(() => { + cy.get('div[role=button]').should('not.contain', 'admin'); + }); + + // Check admin's vm page for login + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-panel]') + .should('not.contain', vmHost); + + // Check activeUser's vm page for login + cy.loginAs(activeUser); + cy.get('header button[title="Account Management"]').click(); + cy.get('#account-menu').contains('Virtual Machines').click(); + + cy.get('[data-cy=vm-user-panel]') + .should('not.contain', vmHost); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 07290e55..cfdfa9ec 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -150,6 +150,15 @@ Cypress.Commands.add( } ) +Cypress.Commands.add( + "createVirtualMachine", (token, data) => { + return cy.createResource(token, 'virtual_machines', { + virtual_machine: JSON.stringify(data), + ensure_unique_name: true + }) + } +) + Cypress.Commands.add( "createResource", (token, suffix, data) => { return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true) @@ -318,4 +327,4 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) { const blob = new Blob(byteArrays, { type: contentType }); return blob -} \ No newline at end of file +} diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 21211460..3d668856 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -7,12 +7,15 @@ import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } fro import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import { withStyles } from '@material-ui/core'; import { IllegalNamingWarning } from '../warning/warning'; +import { IconType } from 'components/icon/icon'; +import grey from '@material-ui/core/colors/grey'; export interface Breadcrumb { label: string; + icon?: IconType; } -type CssRules = "item" | "currentItem" | "label"; +type CssRules = "item" | "currentItem" | "label" | "icon"; const styles: StyleRulesCallback = theme => ({ item: { @@ -23,7 +26,11 @@ const styles: StyleRulesCallback = theme => ({ }, label: { textTransform: "none" - } + }, + icon: { + fontSize: 20, + color: grey["600"] + }, }); export interface BreadcrumbsProps { @@ -39,6 +46,7 @@ export const Breadcrumbs = withStyles(styles)( items.map((item, index) => { const isLastItem = index === items.length - 1; const isFirstItem = index === 0; + const Icon = item.icon || (() => (null)); return ( {isFirstItem ? null : } @@ -54,6 +62,7 @@ export const Breadcrumbs = withStyles(styles)( className={isLastItem ? classes.currentItem : classes.item} onClick={() => onClick(item)} onContextMenu={event => onContextMenu(event, item)}> + { values: Value[]; getLabel?: (value: Value) => string; onChange: (value: Value[]) => void; + handleFocus?: (e: any) => void; + handleBlur?: (e: any) => void; + chipsClassName?: string; createNewValue: (value: string) => Value; inputComponent?: React.ComponentType; inputProps?: InputProps; deletable?: boolean; orderable?: boolean; disabled?: boolean; + pattern?: RegExp; } type CssRules = 'chips' | 'input' | 'inputContainer'; @@ -49,22 +53,45 @@ export const ChipsInput = withStyles(styles)( timeout = -1; setText = (event: React.ChangeEvent) => { - this.setState({ text: event.target.value }); + this.setState({ text: event.target.value }, () => { + // If pattern is provided, check for delimiter + if (this.props.pattern) { + const matches = this.state.text.match(this.props.pattern); + // Only create values if 1 match and the last character is a delimiter + // (user pressed an invalid character at the end of a token) + // or if multiple matches (user pasted text) + if (matches && + ( + matches.length > 1 || + (matches.length === 1 && !this.state.text.endsWith(matches[0])) + )) { + this.createNewValue(matches.map((i) => i)); + } + } + }); } - handleKeyPress = ({ key }: React.KeyboardEvent) => { - if (key === 'Enter') { + handleKeyPress = (e: React.KeyboardEvent) => { + // Handle special keypresses + if (e.key === 'Enter') { this.createNewValue(); - } else if (key === 'Backspace') { + e.preventDefault(); + } else if (e.key === 'Backspace') { this.deleteLastValue(); } } - createNewValue = () => { + createNewValue = (matches?: string[]) => { if (this.state.text) { - const newValue = this.props.createNewValue(this.state.text); - this.setState({ text: '' }); - this.props.onChange([...this.props.values, newValue]); + if (matches && matches.length > 0) { + const newValues = matches.map((v) => this.props.createNewValue(v)); + this.setState({ text: '' }); + this.props.onChange([...this.props.values, ...newValues]); + } else { + const newValue = this.props.createNewValue(this.state.text); + this.setState({ text: '' }); + this.props.onChange([...this.props.values, newValue]); + } } } @@ -104,7 +131,7 @@ export const ChipsInput = withStyles(styles)( renderChips() { const { classes, ...props } = this.props; - return
+ return
export const CanReadIcon: IconType = (props) => ; export const CanWriteIcon: IconType = (props) => ; export const CanManageIcon: IconType = (props) => ; +export const AddUserIcon: IconType = (props) => ; diff --git a/src/models/permission.ts b/src/models/permission.ts index f340c502..1d603801 100644 --- a/src/models/permission.ts +++ b/src/models/permission.ts @@ -13,4 +13,5 @@ export enum PermissionLevel { CAN_READ = 'can_read', CAN_WRITE = 'can_write', CAN_MANAGE = 'can_manage', + CAN_LOGIN = 'can_login', } diff --git a/src/routes/route-change-handlers.ts b/src/routes/route-change-handlers.ts index 70f65cb4..044a38bf 100644 --- a/src/routes/route-change-handlers.ts +++ b/src/routes/route-change-handlers.ts @@ -86,7 +86,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => { } else if (virtualMachineUserMatch) { store.dispatch(WorkbenchActions.loadVirtualMachines); } else if (virtualMachineAdminMatch) { - store.dispatch(WorkbenchActions.loadVirtualMachines); + store.dispatch(WorkbenchActions.loadVirtualMachinesAdmin); } else if (repositoryMatch) { store.dispatch(WorkbenchActions.loadRepositories); } else if (sshKeysUserMatch) { diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts index e3b69d8d..e2cf6fd4 100644 --- a/src/store/virtual-machines/virtual-machines-actions.ts +++ b/src/store/virtual-machines/virtual-machines-actions.ts @@ -14,6 +14,11 @@ import { FilterBuilder } from "services/api/filter-builder"; import { ListResults } from "services/common-service/common-service"; import { dialogActions } from 'store/dialog/dialog-actions'; import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; +import { PermissionLevel } from "models/permission"; +import { deleteResources, updateResources } from 'store/resources/resources-actions'; +import { Participant } from "views-components/sharing-dialog/participant-select"; +import { initialize, reset } from "redux-form"; +import { getUserDisplayName } from "models/user"; export const virtualMachinesActions = unionize({ SET_REQUESTED_DATE: ofType(), @@ -27,6 +32,14 @@ export type VirtualMachineActions = UnionOf; export const VIRTUAL_MACHINES_PANEL = 'virtualMachinesPanel'; export const VIRTUAL_MACHINE_ATTRIBUTES_DIALOG = 'virtualMachineAttributesDialog'; export const VIRTUAL_MACHINE_REMOVE_DIALOG = 'virtualMachineRemoveDialog'; +export const VIRTUAL_MACHINE_ADD_LOGIN_DIALOG = 'virtualMachineAddLoginDialog'; +export const VIRTUAL_MACHINE_ADD_LOGIN_FORM = 'virtualMachineAddLoginForm'; +export const VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG = 'virtualMachineRemoveLoginDialog'; + +export const VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD = 'uuid'; +export const VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD = 'vmUuid'; +export const VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD = 'user'; +export const VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD = 'groups'; export const openUserVirtualMachines = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { @@ -59,8 +72,29 @@ const loadRequestedDate = () => export const loadVirtualMachinesAdminData = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { dispatch(loadRequestedDate()); + const virtualMachines = await services.virtualMachineService.list(); + dispatch(updateResources(virtualMachines.items)); dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines)); + + + const logins = await services.permissionService.list({ + filters: new FilterBuilder() + .addIn('head_uuid', virtualMachines.items.map(item => item.uuid)) + .addEqual('name', PermissionLevel.CAN_LOGIN) + .getFilters() + }); + dispatch(updateResources(logins.items)); + dispatch(virtualMachinesActions.SET_LINKS(logins)); + + const users = await services.userService.list({ + filters: new FilterBuilder() + .addIn('uuid', logins.items.map(item => item.tailUuid)) + .getFilters(), + count: "none" + }); + dispatch(updateResources(users.items)); + const getAllLogins = await services.virtualMachineService.getAllLogins(); dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins)); }; @@ -79,6 +113,104 @@ export const loadVirtualMachinesUserData = () => dispatch(virtualMachinesActions.SET_LINKS(links)); }; +export const openAddVirtualMachineLoginDialog = (vmUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid, [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: []})); + dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {}} )); + } + +export const openEditVirtualMachineLoginDialog = (permissionUuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + const login = await services.permissionService.get(permissionUuid); + const user = await services.userService.get(login.tailUuid); + dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, { + [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid, + [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true), uuid: login.tailUuid}, + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups, + })); + dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} )); + } + +export interface AddLoginFormData { + [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: string; + [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string; + [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: Participant; + [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[]; +} + + +export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + // Get user + const userResource = await services.userService.get(user.uuid); + + if (uuid) { + const permission = await services.permissionService.update(uuid, { + tailUuid: userResource.uuid, + name: PermissionLevel.CAN_LOGIN, + properties: { + username: userResource.username, + groups, + } + }); + dispatch(updateResources([permission])); + } else { + const permission = await services.permissionService.create({ + headUuid: vmUuid, + tailUuid: userResource.uuid, + name: PermissionLevel.CAN_LOGIN, + properties: { + username: userResource.username, + groups, + } + }); + dispatch(updateResources([permission])); + } + + dispatch(reset(VIRTUAL_MACHINE_ADD_LOGIN_FORM)); + dispatch(dialogActions.CLOSE_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG })); + dispatch(loadVirtualMachinesAdminData()); + + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: `Permission updated`, + kind: SnackbarKind.SUCCESS + })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } + }; + +export const openRemoveVirtualMachineLoginDialog = (uuid: string) => + (dispatch: Dispatch, getState: () => RootState) => { + dispatch(dialogActions.OPEN_DIALOG({ + id: VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG, + data: { + title: 'Remove login permission', + text: 'Are you sure you want to remove this permission?', + confirmButtonLabel: 'Remove', + uuid + } + })); + }; + +export const removeVirtualMachineLogin = (uuid: string) => + async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { + try { + await services.permissionService.delete(uuid); + dispatch(deleteResources([uuid])); + + dispatch(loadVirtualMachinesAdminData()); + + dispatch(snackbarActions.OPEN_SNACKBAR({ + message: `Login permission removed`, + kind: SnackbarKind.SUCCESS + })); + } catch (e) { + dispatch(snackbarActions.OPEN_SNACKBAR({ message: e.message, hideDuration: 2000, kind: SnackbarKind.ERROR })); + } + }; + export const saveRequestedDate = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => { const date = formatDate((new Date()).toISOString()); diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index 03d65f62..98508f75 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -100,6 +100,7 @@ import { loadAllProcessesPanel, allProcessesPanelActions } from '../all-processe import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel'; import { collectionPanelFilesAction } from '../collection-panel/collection-panel-files/collection-panel-files-actions'; import { createTree } from 'models/tree'; +import { AdminMenuIcon } from 'components/icon/icon'; export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen'; @@ -491,6 +492,12 @@ export const loadVirtualMachines = handleFirstTimeLoad( dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }])); }); +export const loadVirtualMachinesAdmin = handleFirstTimeLoad( + async (dispatch: Dispatch) => { + await dispatch(loadVirtualMachinesPanel()); + dispatch(setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }])); + }); + export const loadRepositories = handleFirstTimeLoad( async (dispatch: Dispatch) => { await dispatch(loadRepositoriesPanel()); diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 47c1eaa9..a2acaca4 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -35,6 +35,7 @@ import { formatPermissionLevel } from 'views-components/sharing-dialog/permissio import { PermissionLevel } from 'models/permission'; import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions'; import { getUserUuid } from 'common/getuser'; +import { VirtualMachinesResource } from 'models/virtual-machines'; const renderName = (dispatch: Dispatch, item: GroupContentsResource) => { @@ -271,15 +272,37 @@ export const ResourceIsAdmin = connect( }, { toggleIsAdmin } )(renderIsAdmin); -const renderUsername = (item: { username: string }) => - {item.username}; +const renderUsername = (item: { username: string, uuid: string }) => + {item.username || item.uuid}; export const ResourceUsername = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(props.uuid)(state.resources); - return resource || { username: '' }; + return resource || { username: '', uuid: props.uuid }; })(renderUsername); +// Virtual machine resource + +const renderHostname = (item: { hostname: string }) => + {item.hostname}; + +export const VirtualMachineHostname = connect( + (state: RootState, props: { uuid: string }) => { + const resource = getResource(props.uuid)(state.resources); + return resource || { hostname: '' }; + })(renderHostname); + +const renderVirtualMachineLogin = (login: {user: string}) => + {login.user} + +export const VirtualMachineLogin = connect( + (state: RootState, props: { linkUuid: string }) => { + const permission = getResource(props.linkUuid)(state.resources); + const user = getResource(permission?.tailUuid || '')(state.resources); + + return {user: user?.username || permission?.tailUuid || ''}; + })(renderVirtualMachineLogin); + // Common methods const renderCommonData = (data: string) => {data}; diff --git a/src/views-components/virtual-machines-dialog/add-login-dialog.tsx b/src/views-components/virtual-machines-dialog/add-login-dialog.tsx new file mode 100644 index 00000000..bfc04716 --- /dev/null +++ b/src/views-components/virtual-machines-dialog/add-login-dialog.tsx @@ -0,0 +1,56 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { compose } from "redux"; +import { reduxForm, InjectedFormProps, WrappedFieldProps, Field } from 'redux-form'; +import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; +import { FormDialog } from 'components/form-dialog/form-dialog'; +import { VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, VIRTUAL_MACHINE_ADD_LOGIN_FORM, addUpdateVirtualMachineLogin, AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD } from 'store/virtual-machines/virtual-machines-actions'; +import { ParticipantSelect } from 'views-components/sharing-dialog/participant-select'; +import { GroupArrayInput } from 'views-components/virtual-machines-dialog/group-array-input'; + +export const VirtualMachineAddLoginDialog = compose( + withDialog(VIRTUAL_MACHINE_ADD_LOGIN_DIALOG), + reduxForm({ + form: VIRTUAL_MACHINE_ADD_LOGIN_FORM, + onSubmit: (data, dispatch) => { + dispatch(addUpdateVirtualMachineLogin(data)); + } + }) +)( + (props: CreateGroupDialogComponentProps) => + +); + +type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & InjectedFormProps; + +const AddLoginFormFields = () => + <> + + + ; + +const UserField = () => + ; + +const UserSelect = ({ input, meta }: WrappedFieldProps) => + (input.onChange(''))} />; diff --git a/src/views-components/virtual-machines-dialog/group-array-input.tsx b/src/views-components/virtual-machines-dialog/group-array-input.tsx new file mode 100644 index 00000000..3ea5e77c --- /dev/null +++ b/src/views-components/virtual-machines-dialog/group-array-input.tsx @@ -0,0 +1,73 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import React from 'react'; +import { StringArrayCommandInputParameter } from 'models/workflow'; +import { Field } from 'redux-form'; +import { GenericInputProps } from 'views/run-process-panel/inputs/generic-input'; +import { ChipsInput } from 'components/chips-input/chips-input'; +import { identity } from 'lodash'; +import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl } from '@material-ui/core'; + +export interface StringArrayInputProps { + name: string; + input: StringArrayCommandInputParameter; + required: boolean; +} + +type CssRules = 'chips'; + +const styles = { + chips: { + marginTop: "16px", + }, +}; + +export const GroupArrayInput = ({name, input}: StringArrayInputProps) => + ; + +const StringArrayInputComponent = (props: GenericInputProps) => { + return + + 0}>{props.commandInput.id} + + + ; + }; + +const StyledInputComponent = withStyles(styles)( + class InputComponent extends React.PureComponent>{ + render() { + const { classes } = this.props; + const { commandInput, input, meta } = this.props; + return ; + } + + handleChange = (values: {}[]) => { + const { input, meta } = this.props; + if (!meta.touched) { + input.onBlur(values); + } + input.onChange(values); + } + + } +); diff --git a/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx b/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx new file mode 100644 index 00000000..60a485f1 --- /dev/null +++ b/src/views-components/virtual-machines-dialog/remove-login-dialog.tsx @@ -0,0 +1,22 @@ +// Copyright (C) The Arvados Authors. All rights reserved. +// +// SPDX-License-Identifier: AGPL-3.0 + +import { Dispatch, compose } from 'redux'; +import { connect } from "react-redux"; +import { ConfirmationDialog } from "components/confirmation-dialog/confirmation-dialog"; +import { withDialog, WithDialogProps } from "store/dialog/with-dialog"; +import { VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG, removeVirtualMachineLogin, loadVirtualMachinesAdminData } from 'store/virtual-machines/virtual-machines-actions'; + +const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps) => ({ + onConfirm: () => { + props.closeDialog(); + dispatch(removeVirtualMachineLogin(props.data.uuid)); + dispatch(loadVirtualMachinesAdminData()); + } +}); + +export const RemoveVirtualMachineLoginDialog = compose( + withDialog(VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG), + connect(null, mapDispatchToProps) +)(ConfirmationDialog); diff --git a/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx index a6ad24a7..468ef35a 100644 --- a/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx +++ b/src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx @@ -4,18 +4,19 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Grid, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core'; +import { Grid, Card, Chip, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, IconButton } from '@material-ui/core'; import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; import { ArvadosTheme } from 'common/custom-theme'; import { compose, Dispatch } from 'redux'; -import { loadVirtualMachinesAdminData } from 'store/virtual-machines/virtual-machines-actions'; +import { loadVirtualMachinesAdminData, openAddVirtualMachineLoginDialog, openRemoveVirtualMachineLoginDialog, openEditVirtualMachineLoginDialog } from 'store/virtual-machines/virtual-machines-actions'; import { RootState } from 'store/store'; import { ListResults } from 'services/common-service/common-service'; -import { MoreOptionsIcon } from 'components/icon/icon'; +import { MoreOptionsIcon, AddUserIcon } from 'components/icon/icon'; import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-machines'; import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions'; +import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers'; -type CssRules = 'moreOptionsButton' | 'moreOptions'; +type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ moreOptionsButton: { @@ -27,6 +28,9 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ paddingRight: 0 } }, + chipsRoot: { + margin: `0px -${theme.spacing.unit / 2}px`, + }, }); const mapStateToProps = (state: RootState) => { @@ -36,22 +40,35 @@ const mapStateToProps = (state: RootState) => { }; }; -const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ +const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ loadVirtualMachinesData: () => dispatch(loadVirtualMachinesAdminData()), onOptionsMenuOpen: (event, virtualMachine) => { dispatch(openVirtualMachinesContextMenu(event, virtualMachine)); }, + onAddLogin: (uuid: string) => { + dispatch(openAddVirtualMachineLoginDialog(uuid)); + }, + onDeleteLogin: (uuid: string) => { + dispatch(openRemoveVirtualMachineLoginDialog(uuid)); + }, + onLoginEdit: (uuid: string) => { + dispatch(openEditVirtualMachineLoginDialog(uuid)); + }, }); interface VirtualMachinesPanelDataProps { virtualMachines: ListResults; logins: VirtualMachineLogins; + links: ListResults; userUuid: string; } interface VirtualMachinesPanelActionProps { loadVirtualMachinesData: () => string; onOptionsMenuOpen: (event: React.MouseEvent, virtualMachine: VirtualMachinesResource) => void; + onAddLogin: (uuid: string) => void; + onDeleteLogin: (uuid: string) => void; + onLoginEdit: (uuid: string) => void; } type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles; @@ -85,24 +102,40 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) => ; const virtualMachinesTable = (props: VirtualMachineProps) => - +
Uuid Host name Logins + - {props.logins.items.length > 0 && props.virtualMachines.items.map((it, index) => + {props.virtualMachines.items.map((machine, index) => - {it.uuid} - {it.hostname} - ["{props.logins.items.map(it => it.userUuid === props.userUuid ? it.username : '')}"] + + + + + {props.links.items.filter((link) => (link.headUuid === machine.uuid)).map((permission, i) => ( + + } onDelete={event => props.onDeleteLogin(permission.uuid)} onClick={event => props.onLoginEdit(permission.uuid)} /> + + ))} + + + + + props.onAddLogin(machine.uuid)} className={props.classes.moreOptionsButton}> + + + + - props.onOptionsMenuOpen(event, it)} className={props.classes.moreOptionsButton}> + props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}> diff --git a/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx index d8725461..70f97daf 100644 --- a/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx +++ b/src/views/virtual-machine-panel/virtual-machine-user-panel.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip } from '@material-ui/core'; +import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, Chip } from '@material-ui/core'; import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles'; import { ArvadosTheme } from 'common/custom-theme'; import { compose, Dispatch } from 'redux'; @@ -15,8 +15,11 @@ import { HelpIcon } from 'components/icon/icon'; import { SESSION_STORAGE } from "services/auth-service/auth-service"; // import * as CopyToClipboard from 'react-copy-to-clipboard'; import parse from "parse-duration"; +import { CopyIcon } from 'components/icon/icon'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions'; -type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon'; +type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'webshellButton'; const EXTRA_TOKEN = "exraToken"; @@ -56,7 +59,22 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ icon: { textAlign: "right", marginTop: theme.spacing.unit - } + }, + chipsRoot: { + margin: `0px -${theme.spacing.unit / 2}px`, + }, + copyIcon: { + marginLeft: theme.spacing.unit, + color: theme.palette.grey["500"], + cursor: 'pointer', + display: 'inline', + '& svg': { + fontSize: '1rem' + } + }, + webshellButton: { + textTransform: "initial", + }, }); const mapStateToProps = (state: RootState) => { @@ -73,9 +91,16 @@ const mapStateToProps = (state: RootState) => { }; }; -const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ +const mapDispatchToProps = (dispatch: Dispatch): Pick => ({ saveRequestedDate: () => dispatch(saveRequestedDate()), loadVirtualMachinesData: () => dispatch(loadVirtualMachinesUserData()), + onCopy: (message: string) => { + dispatch(snackbarActions.OPEN_SNACKBAR({ + message, + hideDuration: 2000, + kind: SnackbarKind.SUCCESS + })); + }, }); interface VirtualMachinesPanelDataProps { @@ -94,6 +119,7 @@ interface VirtualMachinesPanelDataProps { interface VirtualMachinesPanelActionProps { saveRequestedDate: () => void; loadVirtualMachinesData: () => string; + onCopy: (message: string) => void; } type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles; @@ -109,7 +135,7 @@ export const VirtualMachineUserPanel = compose( render() { const { virtualMachines, links } = this.props; return ( - + {virtualMachines.itemsAvailable === 0 && } {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && } {} @@ -169,11 +195,12 @@ const virtualMachineSendRequest = (props: VirtualMachineProps) => ; const virtualMachinesTable = (props: VirtualMachineProps) => -
+
Host name Login name + Groups Command line Web shell @@ -181,23 +208,48 @@ const virtualMachinesTable = (props: VirtualMachineProps) => {props.virtualMachines.items.map(it => props.links.items.map(lk => { - if (lk.tailUuid === props.userUuid) { + if (lk.tailUuid === props.userUuid && lk.headUuid === it.uuid) { const username = lk.properties.username; const command = `ssh ${username}@${it.hostname}${props.hostSuffix}`; let tokenParam = ""; if (props.tokenLocation === SESSION_STORAGE || props.tokenLocation === EXTRA_TOKEN) { tokenParam = `&token=${encodeURIComponent(props.token)}`; } + const loginHref = `/webshell/?host=${encodeURIComponent(props.webshellUrl + '/' + it.hostname)}&timeout=${props.idleTimeout}&login=${encodeURIComponent(username)}${tokenParam}`; return {it.hostname} {username} + + + { + (lk.properties.groups || []).map((group, i) => ( + + + + )) + } + + {command} + + + props.onCopy!("Copied")}> + + + + - - Log in as {username} - + ; } diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx index e7bb048f..49922202 100644 --- a/src/views/workbench/workbench.tsx +++ b/src/views/workbench/workbench.tsx @@ -68,12 +68,14 @@ import { RemoveApiClientAuthorizationDialog } from 'views-components/api-client- import { RemoveKeepServiceDialog } from 'views-components/keep-services-dialog/remove-dialog'; import { RemoveLinkDialog } from 'views-components/links-dialog/remove-dialog'; import { RemoveSshKeyDialog } from 'views-components/ssh-keys-dialog/remove-dialog'; +import { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog'; import { RemoveVirtualMachineDialog } from 'views-components/virtual-machines-dialog/remove-dialog'; +import { RemoveVirtualMachineLoginDialog } from 'views-components/virtual-machines-dialog/remove-login-dialog'; +import { VirtualMachineAddLoginDialog } from 'views-components/virtual-machines-dialog/add-login-dialog'; import { AttributesApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/attributes-dialog'; import { AttributesKeepServiceDialog } from 'views-components/keep-services-dialog/attributes-dialog'; import { AttributesLinkDialog } from 'views-components/links-dialog/attributes-dialog'; import { AttributesSshKeyDialog } from 'views-components/ssh-keys-dialog/attributes-dialog'; -import { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog'; import { UserPanel } from 'views/user-panel/user-panel'; import { UserAttributesDialog } from 'views-components/user-dialog/attributes-dialog'; import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog'; @@ -251,6 +253,8 @@ export const WorkbenchPanel = + +