18284: Resolve bugs in VM listing and add login administration functions
authorStephen Smith <stephen@curii.com>
Tue, 25 Jan 2022 21:58:12 +0000 (16:58 -0500)
committerStephen Smith <stephen@curii.com>
Tue, 25 Jan 2022 21:58:12 +0000 (16:58 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

14 files changed:
src/components/breadcrumbs/breadcrumbs.tsx
src/components/chips-input/chips-input.tsx
src/components/icon/icon.tsx
src/models/permission.ts
src/routes/route-change-handlers.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/views-components/data-explorer/renderers.tsx
src/views-components/virtual-machines-dialog/add-login-dialog.tsx [new file with mode: 0644]
src/views-components/virtual-machines-dialog/group-array-input.tsx [new file with mode: 0644]
src/views-components/virtual-machines-dialog/remove-login-dialog.tsx [new file with mode: 0644]
src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
src/views/virtual-machine-panel/virtual-machine-user-panel.tsx
src/views/workbench/workbench.tsx

index 21211460b7df265be2a5b47cffe4384612700002..3d668856ecd5d1c20e4faebc3b51d54466045277 100644 (file)
@@ -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<CssRules> = theme => ({
     item: {
@@ -23,7 +26,11 @@ const styles: StyleRulesCallback<CssRules> = 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 (
                 <React.Fragment key={index}>
                     {isFirstItem ? null : <IllegalNamingWarning name={item.label} />}
@@ -54,6 +62,7 @@ export const Breadcrumbs = withStyles(styles)(
                             className={isLastItem ? classes.currentItem : classes.item}
                             onClick={() => onClick(item)}
                             onContextMenu={event => onContextMenu(event, item)}>
+                            <Icon className={classes.icon} />
                             <Typography
                                 noWrap
                                 color="inherit"
index 32077fbedf3a5560f1fdf4de1549bf64203ad1a5..31e123330e3570262369942a8a32b60b25aa33d1 100644 (file)
@@ -12,6 +12,9 @@ interface ChipsInputProps<Value> {
     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?: InputProps;
@@ -52,10 +55,11 @@ export const ChipsInput = withStyles(styles)(
             this.setState({ text: event.target.value });
         }
 
-        handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
-            if (key === 'Enter') {
+        handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+            if (e.key === 'Enter') {
                 this.createNewValue();
-            } else if (key === 'Backspace') {
+                e.preventDefault();
+            } else if (e.key === 'Backspace') {
                 this.deleteLastValue();
             }
         }
@@ -104,7 +108,7 @@ export const ChipsInput = withStyles(styles)(
 
         renderChips() {
             const { classes, ...props } = this.props;
-            return <div className={classes.chips}>
+            return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
                 <Chips
                     {...props}
                     clickable={!props.disabled}
@@ -121,6 +125,8 @@ export const ChipsInput = withStyles(styles)(
                 onChange={this.setText}
                 disabled={this.props.disabled}
                 onKeyDown={this.handleKeyPress}
+                onFocus={this.props.handleFocus}
+                onBlur={this.props.handleBlur}
                 inputProps={{
                     ...(InputProps && InputProps.inputProps),
                     className: classes.input,
index 4f7305f740e524b87745c1c0b82ef84e4f28305a..54b7bee6cd827b044af7dd88197ae7c76e4a8d82 100644 (file)
@@ -172,3 +172,4 @@ export const FolderSharedIcon: IconType = (props) => <FolderShared {...props} />
 export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
 export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
 export const CanManageIcon: IconType = (props) => <Computer {...props} />;
+export const AddUserIcon: IconType = (props) => <PersonAdd {...props} />;
index f340c5023adad5365421231c794452d2ec58182f..1d6038010b77093336687e7b0ce60cb279fef0a9 100644 (file)
@@ -13,4 +13,5 @@ export enum PermissionLevel {
     CAN_READ = 'can_read',
     CAN_WRITE = 'can_write',
     CAN_MANAGE = 'can_manage',
+    CAN_LOGIN = 'can_login',
 }
index 70f65cb46a8e72dd4d2962236a915b06392f63c9..044a38bf20df04d3822a5325990256a89b9295ec 100644 (file)
@@ -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) {
index e3b69d8dab4df73f797a2e44895a9780343d08ff..08654a4468b6b97e9597bba45315b9f84b7f1199 100644 (file)
@@ -14,6 +14,10 @@ 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";
 
 export const virtualMachinesActions = unionize({
     SET_REQUESTED_DATE: ofType<string>(),
@@ -27,6 +31,13 @@ export type VirtualMachineActions = UnionOf<typeof virtualMachinesActions>;
 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_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 +70,29 @@ const loadRequestedDate = () =>
 export const loadVirtualMachinesAdminData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(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 +111,79 @@ export const loadVirtualMachinesUserData = () =>
         dispatch(virtualMachinesActions.SET_LINKS(links));
     };
 
+export const openAddVirtualMachineLoginDialog = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {[VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: uuid, [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: ['docker']}));
+        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {}} ));
+    }
+
+export interface AddLoginFormData {
+    [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: string;
+    [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: Participant;
+    [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: string[];
+}
+
+
+export const addVirtualMachineLogin = ({vmUuid, user, groups}: AddLoginFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            // Get user
+            const userResource = await services.userService.get(user.uuid);
+
+            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<any>(loadVirtualMachinesAdminData());
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `Permissions 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<any>(deleteResources([uuid]));
+
+            dispatch<any>(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());
index 527d9d74bfe5ec60631774ca24c7aec953ae8527..58d8d5f15fac47d4d8938167e77a1a9004e5ab76 100644 (file)
@@ -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';
 
@@ -492,6 +493,12 @@ export const loadVirtualMachines = handleFirstTimeLoad(
         dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
     });
 
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadVirtualMachinesPanel());
+        dispatch(setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }]));
+    });
+
 export const loadRepositories = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadRepositoriesPanel());
index 901704d9feafc906acd4535fa3c980a2ff88c280..ce6c02ca2cc29d589a3e954ead70fc4192874fd4 100644 (file)
@@ -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) => {
 
@@ -221,7 +222,7 @@ const renderIsHidden = (props: {
                             permissionLinkUuid: string,
                             visible: boolean,
                             canManage: boolean,
-                            setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void 
+                            setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void
                         }) => {
     if (props.memberLinkUuid) {
         return <Checkbox
@@ -271,15 +272,37 @@ export const ResourceIsAdmin = connect(
     }, { toggleIsAdmin }
 )(renderIsAdmin);
 
-const renderUsername = (item: { username: string }) =>
-    <Typography noWrap>{item.username}</Typography>;
+const renderUsername = (item: { username: string, uuid: string }) =>
+    <Typography noWrap>{item.username || item.uuid}</Typography>;
 
 export const ResourceUsername = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { username: '' };
+        return resource || { username: '', uuid: props.uuid };
     })(renderUsername);
 
+// Virtual machine resource
+
+const renderHostname = (item: { hostname: string }) =>
+    <Typography noWrap>{item.hostname}</Typography>;
+
+export const VirtualMachineHostname = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<VirtualMachinesResource>(props.uuid)(state.resources);
+        return resource || { hostname: '' };
+    })(renderHostname);
+
+const renderVirtualMachineLogin = (login: {user: string}) =>
+    <Typography noWrap>{login.user}</Typography>
+
+export const VirtualMachineLogin = connect(
+    (state: RootState, props: { linkUuid: string }) => {
+        const permission = getResource<LinkResource>(props.linkUuid)(state.resources);
+        const user = getResource<UserResource>(permission?.tailUuid || '')(state.resources);
+
+        return {user: user?.username || permission?.tailUuid || ''};
+    })(renderVirtualMachineLogin);
+
 // Common methods
 const renderCommonData = (data: string) =>
     <Typography noWrap>{data}</Typography>;
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 (file)
index 0000000..8d54340
--- /dev/null
@@ -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, addVirtualMachineLogin, 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<AddLoginFormData>({
+        form: VIRTUAL_MACHINE_ADD_LOGIN_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(addVirtualMachineLogin(data));
+        }
+    })
+)(
+    (props: CreateGroupDialogComponentProps) =>
+        <FormDialog
+            dialogTitle='Add login permissions'
+            formFields={AddLoginFormFields}
+            submitLabel='Add'
+            {...props}
+        />
+);
+
+type CreateGroupDialogComponentProps = WithDialogProps<{}> & InjectedFormProps<AddLoginFormData>;
+
+const AddLoginFormFields = () =>
+    <>
+        <UserField />
+        <GroupArrayInput
+            name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
+            input={{id:"Add groups to VM login", disabled:false}}
+            required={false}
+        />
+    </>;
+
+const UserField = () =>
+    <Field
+        name={VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD}
+        component={UserSelect}
+        />;
+
+const UserSelect = ({ input, meta }: WrappedFieldProps) =>
+    <ParticipantSelect
+        onlyPeople
+        label='Search for users to grant login permission'
+        items={input.value ? [input.value] : []}
+        onSelect={input.onChange}
+        onDelete={() => (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 (file)
index 0000000..12a7301
--- /dev/null
@@ -0,0 +1,79 @@
+// 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) =>
+    <Field
+        name={name}
+        commandInput={input}
+        component={StringArrayInputComponent as any}
+        />;
+
+const StringArrayInputComponent = (props: GenericInputProps) => {
+  return <FormGroup>
+        <FormControl fullWidth error={props.meta.error}>
+          <InputLabel shrink={props.meta.active || props.input.value.length > 0}>{props.commandInput.id}</InputLabel>
+          <StyledInputComponent {...props} />
+        </FormControl>
+    </FormGroup>;
+    };
+
+const StyledInputComponent = withStyles(styles)(
+  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules>>{
+      render() {
+          const { classes } = this.props;
+          const { commandInput, input, meta } = this.props;
+          return <ChipsInput
+              deletable={!commandInput.disabled}
+              orderable={!commandInput.disabled}
+              disabled={commandInput.disabled}
+              values={input.value}
+              onChange={this.handleChange}
+              handleFocus={input.onFocus}
+              handleBlur={this.handleBlur}
+              createNewValue={identity}
+              inputComponent={Input}
+              chipsClassName={classes.chips}
+              inputProps={{
+                  error: meta.error,
+              }} />;
+      }
+
+      handleChange = (values: {}[]) => {
+        const { input, meta } = this.props;
+          if (!meta.touched) {
+              input.onBlur(values);
+          }
+          input.onChange(values);
+      }
+
+      handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
+        const { input } = this.props;
+        if (!input.value?.length) {
+          input.onBlur(e);
+        }
+      }
+  }
+);
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 (file)
index 0000000..60a485f
--- /dev/null
@@ -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<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeVirtualMachineLogin(props.data.uuid));
+        dispatch<any>(loadVirtualMachinesAdminData());
+    }
+});
+
+export const RemoveVirtualMachineLoginDialog = compose(
+    withDialog(VIRTUAL_MACHINE_REMOVE_LOGIN_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
index a6ad24a7dbbc6c7206dcbe48d390efac7a24d6b9..0f2c00334e69ffccca2e6fe695a6e8a9fd647b8f 100644 (file)
@@ -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 } 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<CssRules> = (theme: ArvadosTheme) => ({
     moreOptionsButton: {
@@ -27,6 +28,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             paddingRight: 0
         }
     },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -36,22 +40,31 @@ const mapStateToProps = (state: RootState) => {
     };
 };
 
-const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'onOptionsMenuOpen'> => ({
+const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'onOptionsMenuOpen' | 'onAddLogin' | 'onDeleteLogin'> => ({
     loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesAdminData()),
     onOptionsMenuOpen: (event, virtualMachine) => {
         dispatch<any>(openVirtualMachinesContextMenu(event, virtualMachine));
     },
+    onAddLogin: (uuid: string) => {
+        dispatch<any>(openAddVirtualMachineLoginDialog(uuid));
+    },
+    onDeleteLogin: (uuid: string) => {
+        dispatch<any>(openRemoveVirtualMachineLoginDialog(uuid));
+    },
 });
 
 interface VirtualMachinesPanelDataProps {
     virtualMachines: ListResults<any>;
     logins: VirtualMachineLogins;
+    links: ListResults<any>;
     userUuid: string;
 }
 
 interface VirtualMachinesPanelActionProps {
     loadVirtualMachinesData: () => string;
     onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, virtualMachine: VirtualMachinesResource) => void;
+    onAddLogin: (uuid: string) => void;
+    onDeleteLogin: (uuid: string) => void;
 }
 
 type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
@@ -92,17 +105,33 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
                 <TableCell>Host name</TableCell>
                 <TableCell>Logins</TableCell>
                 <TableCell />
+                <TableCell />
             </TableRow>
         </TableHead>
         <TableBody>
-            {props.logins.items.length > 0 && props.virtualMachines.items.map((it, index) =>
+            {props.logins.items.length > 0 && props.virtualMachines.items.map((machine, index) =>
                 <TableRow key={index}>
-                    <TableCell>{it.uuid}</TableCell>
-                    <TableCell>{it.hostname}</TableCell>
-                    <TableCell>["{props.logins.items.map(it => it.userUuid === props.userUuid ? it.username : '')}"]</TableCell>
+                    <TableCell><ResourceUuid uuid={machine.uuid} /></TableCell>
+                    <TableCell><VirtualMachineHostname uuid={machine.uuid} /></TableCell>
+                    <TableCell>
+                        <Grid container spacing={8} className={props.classes.chipsRoot}>
+                            {props.links.items.filter((link) => (link.headUuid === machine.uuid)).map((permission, i) => (
+                                <Grid item key={i}>
+                                    <Chip label={<VirtualMachineLogin linkUuid={permission.uuid} />} onDelete={event => props.onDeleteLogin(permission.uuid)} />
+                                </Grid>
+                            ))}
+                        </Grid>
+                    </TableCell>
+                    <TableCell>
+                        <Tooltip title="Add Login Permission" disableFocusListener>
+                            <IconButton onClick={event => props.onAddLogin(machine.uuid)} className={props.classes.moreOptionsButton}>
+                                <AddUserIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </TableCell>
                     <TableCell className={props.classes.moreOptions}>
                         <Tooltip title="More options" disableFocusListener>
-                            <IconButton onClick={event => props.onOptionsMenuOpen(event, it)} className={props.classes.moreOptionsButton}>
+                            <IconButton onClick={event => props.onOptionsMenuOpen(event, machine)} className={props.classes.moreOptionsButton}>
                                 <MoreOptionsIcon />
                             </IconButton>
                         </Tooltip>
index d8725461efb1df2d92a2a52956da7c0524278af5..091a81981536cd678885264b9e531645f481c21d 100644 (file)
@@ -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';
@@ -16,7 +16,7 @@ import { SESSION_STORAGE } from "services/auth-service/auth-service";
 // import * as CopyToClipboard from 'react-copy-to-clipboard';
 import parse from "parse-duration";
 
-type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon';
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot';
 
 const EXTRA_TOKEN = "exraToken";
 
@@ -56,7 +56,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     icon: {
         textAlign: "right",
         marginTop: theme.spacing.unit
-    }
+    },
+    chipsRoot: {
+        margin: `0px -${theme.spacing.unit / 2}px`,
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -174,6 +177,7 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
             <TableRow>
                 <TableCell>Host name</TableCell>
                 <TableCell>Login name</TableCell>
+                <TableCell>Groups</TableCell>
                 <TableCell>Command line</TableCell>
                 <TableCell>Web shell</TableCell>
             </TableRow>
@@ -181,7 +185,7 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
         <TableBody>
             {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 = "";
@@ -191,6 +195,17 @@ const virtualMachinesTable = (props: VirtualMachineProps) =>
                         return <TableRow key={lk.uuid}>
                             <TableCell>{it.hostname}</TableCell>
                             <TableCell>{username}</TableCell>
+                            <TableCell>
+                                <Grid container spacing={8} className={props.classes.chipsRoot}>
+                                    {
+                                    (lk.properties.groups || []).map((group, i) => (
+                                        <Grid item key={i}>
+                                            <Chip label={group} />
+                                        </Grid>
+                                    ))
+                                    }
+                                </Grid>
+                            </TableCell>
                             <TableCell>
                                 {command}
                             </TableCell>
index e7bb048fb0edcb6a2f632c8b69e377d8e42a6d6e..49922202517c27f5dadb9a01364b6bb66377d441 100644 (file)
@@ -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 =
             <RemoveRepositoryDialog />
             <RemoveSshKeyDialog />
             <RemoveVirtualMachineDialog />
+            <RemoveVirtualMachineLoginDialog />
+            <VirtualMachineAddLoginDialog />
             <RenameFileDialog />
             <RepositoryAttributesDialog />
             <RepositoriesSampleGitDialog />