Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / views / virtual-machine-panel / virtual-machine-user-panel.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import { connect } from 'react-redux';
7 import { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip, Chip } from '@material-ui/core';
8 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
9 import { ArvadosTheme } from 'common/custom-theme';
10 import { compose, Dispatch } from 'redux';
11 import { saveRequestedDate, loadVirtualMachinesUserData } from 'store/virtual-machines/virtual-machines-actions';
12 import { RootState } from 'store/store';
13 import { ListResults } from 'services/common-service/common-service';
14 import { HelpIcon } from 'components/icon/icon';
15 import { SESSION_STORAGE } from "services/auth-service/auth-service";
16 // import * as CopyToClipboard from 'react-copy-to-clipboard';
17 import parse from "parse-duration";
18 import { CopyIcon } from 'components/icon/icon';
19 import CopyToClipboard from 'react-copy-to-clipboard';
20 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
21 import { sanitizeHTML } from 'common/html-sanitize';
22
23 type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton';
24
25 const EXTRA_TOKEN = "exraToken";
26
27 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
28     button: {
29         marginTop: theme.spacing.unit,
30         marginBottom: theme.spacing.unit
31     },
32     codeSnippet: {
33         borderRadius: theme.spacing.unit * 0.5,
34         border: '1px solid',
35         borderColor: theme.palette.grey["400"],
36     },
37     link: {
38         textDecoration: 'none',
39         color: theme.palette.primary.main,
40         "&:hover": {
41             color: theme.palette.primary.dark,
42             transition: 'all 0.5s ease'
43         }
44     },
45     linkIcon: {
46         textDecoration: 'none',
47         color: theme.palette.grey["500"],
48         textAlign: 'right',
49         "&:hover": {
50             color: theme.palette.common.black,
51             transition: 'all 0.5s ease'
52         }
53     },
54     rightAlign: {
55         textAlign: "right"
56     },
57     cardWithoutMachines: {
58         display: 'flex'
59     },
60     icon: {
61         textAlign: "right",
62         marginTop: theme.spacing.unit
63     },
64     chipsRoot: {
65         margin: `0px -${theme.spacing.unit / 2}px`,
66     },
67     copyIcon: {
68         marginLeft: theme.spacing.unit,
69         color: theme.palette.grey["500"],
70         cursor: 'pointer',
71         display: 'inline',
72         '& svg': {
73             fontSize: '1rem'
74         }
75     },
76     tableWrapper: {
77         overflowX: 'auto',
78     },
79     webshellButton: {
80         textTransform: "initial",
81     },
82 });
83
84 const mapStateToProps = (state: RootState) => {
85     return {
86         requestedDate: state.virtualMachines.date,
87         userUuid: state.auth.user!.uuid,
88         helpText: state.auth.config.clusterConfig.Workbench.SSHHelpPageHTML,
89         hostSuffix: state.auth.config.clusterConfig.Workbench.SSHHelpHostSuffix || "",
90         token: state.auth.extraApiToken || state.auth.apiToken || '',
91         tokenLocation: state.auth.extraApiToken ? EXTRA_TOKEN : (state.auth.apiTokenLocation || ''),
92         webshellUrl: state.auth.config.clusterConfig.Services.WebShell.ExternalURL,
93         idleTimeout: parse(state.auth.config.clusterConfig.Workbench.IdleTimeout, 's') || 0,
94         ...state.virtualMachines
95     };
96 };
97
98 const mapDispatchToProps = (dispatch: Dispatch): Pick<VirtualMachinesPanelActionProps, 'loadVirtualMachinesData' | 'saveRequestedDate' | 'onCopy'> => ({
99     saveRequestedDate: () => dispatch<any>(saveRequestedDate()),
100     loadVirtualMachinesData: () => dispatch<any>(loadVirtualMachinesUserData()),
101     onCopy: (message: string) => {
102         dispatch(snackbarActions.OPEN_SNACKBAR({
103             message,
104             hideDuration: 2000,
105             kind: SnackbarKind.SUCCESS
106         }));
107     },
108 });
109
110 interface VirtualMachinesPanelDataProps {
111     requestedDate: string;
112     virtualMachines: ListResults<any>;
113     userUuid: string;
114     links: ListResults<any>;
115     helpText: string;
116     hostSuffix: string;
117     token: string;
118     tokenLocation: string;
119     webshellUrl: string;
120     idleTimeout: number;
121 }
122
123 interface VirtualMachinesPanelActionProps {
124     saveRequestedDate: () => void;
125     loadVirtualMachinesData: () => string;
126     onCopy: (message: string) => void;
127 }
128
129 type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
130
131 export const VirtualMachineUserPanel = compose(
132     withStyles(styles),
133     connect(mapStateToProps, mapDispatchToProps))(
134         class extends React.Component<VirtualMachineProps> {
135             componentDidMount() {
136                 this.props.loadVirtualMachinesData();
137             }
138
139             render() {
140                 const { virtualMachines, links } = this.props;
141                 return (
142                     <Grid container spacing={16} data-cy="vm-user-panel">
143                         {virtualMachines.itemsAvailable === 0 && <CardContentWithoutVirtualMachines {...this.props} />}
144                         {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && <CardContentWithVirtualMachines {...this.props} />}
145                         {<CardSSHSection {...this.props} />}
146                     </Grid>
147                 );
148             }
149         }
150     );
151
152 const CardContentWithoutVirtualMachines = (props: VirtualMachineProps) =>
153     <Grid item xs={12}>
154         <Card>
155             <CardContent className={props.classes.cardWithoutMachines}>
156                 <Grid item xs={6}>
157                     <Typography variant='body1'>
158                         You do not have access to any virtual machines. Some Arvados features require using the command line. You may request access to a hosted virtual machine with the command line shell.
159                     </Typography>
160                 </Grid>
161                 <Grid item xs={6} className={props.classes.rightAlign}>
162                     {virtualMachineSendRequest(props)}
163                 </Grid>
164             </CardContent>
165         </Card>
166     </Grid>;
167
168 const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
169     <Grid item xs={12}>
170         <Card>
171             <CardContent>
172                 <span>
173                     <div className={props.classes.rightAlign}>
174                         {virtualMachineSendRequest(props)}
175                     </div>
176                     <div className={props.classes.icon}>
177                         <a href="https://doc.arvados.org/user/getting_started/vm-login-with-webshell.html" target="_blank" rel="noopener noreferrer" className={props.classes.linkIcon}>
178                             <Tooltip title="Access VM using webshell">
179                                 <HelpIcon />
180                             </Tooltip>
181                         </a>
182                     </div>
183                     <div className={props.classes.tableWrapper}>
184                         {virtualMachinesTable(props)}
185                     </div>
186                 </span>
187
188             </CardContent>
189         </Card>
190     </Grid>;
191
192 const virtualMachineSendRequest = (props: VirtualMachineProps) =>
193     <span>
194         <Button variant="contained" color="primary" className={props.classes.button} onClick={props.saveRequestedDate}>
195             SEND REQUEST FOR SHELL ACCESS
196         </Button>
197         {props.requestedDate &&
198             <Typography >
199                 A request for shell access was sent on {props.requestedDate}
200             </Typography>}
201     </span>;
202
203 const virtualMachinesTable = (props: VirtualMachineProps) =>
204     <Table data-cy="vm-user-table">
205         <TableHead>
206             <TableRow>
207                 <TableCell>Host name</TableCell>
208                 <TableCell>Login name</TableCell>
209                 <TableCell>Groups</TableCell>
210                 <TableCell>Command line</TableCell>
211                 <TableCell>Web shell</TableCell>
212             </TableRow>
213         </TableHead>
214         <TableBody>
215             {props.virtualMachines.items.map(it =>
216                 props.links.items.map(lk => {
217                     if (lk.tailUuid === props.userUuid && lk.headUuid === it.uuid) {
218                         const username = lk.properties.username;
219                         const command = `ssh ${username}@${it.hostname}${props.hostSuffix}`;
220                         let tokenParam = "";
221                         if (props.tokenLocation === SESSION_STORAGE || props.tokenLocation === EXTRA_TOKEN) {
222                           tokenParam = `&token=${encodeURIComponent(props.token)}`;
223                         }
224                         const loginHref = `/webshell/?host=${encodeURIComponent(props.webshellUrl + '/' + it.hostname)}&timeout=${props.idleTimeout}&login=${encodeURIComponent(username)}${tokenParam}`;
225                         return <TableRow key={lk.uuid}>
226                             <TableCell>{it.hostname}</TableCell>
227                             <TableCell>{username}</TableCell>
228                             <TableCell>
229                                 <Grid container spacing={8} className={props.classes.chipsRoot}>
230                                     {
231                                     (lk.properties.groups || []).map((group, i) => (
232                                         <Grid item key={i}>
233                                             <Chip label={group} />
234                                         </Grid>
235                                     ))
236                                     }
237                                 </Grid>
238                             </TableCell>
239                             <TableCell>
240                                 {command}
241                                 <Tooltip title="Copy to clipboard">
242                                     <span className={props.classes.copyIcon}>
243                                         <CopyToClipboard text={command || ""} onCopy={() => props.onCopy!("Copied")}>
244                                             <CopyIcon />
245                                         </CopyToClipboard>
246                                     </span>
247                                 </Tooltip>
248                             </TableCell>
249                             <TableCell>
250                                 <Button
251                                     className={props.classes.webshellButton}
252                                     variant="contained"
253                                     size="small"
254                                     href={loginHref}
255                                     target="_blank"
256                                     rel="noopener noreferrer">
257                                         Log in as {username}
258                                 </Button>
259                             </TableCell>
260                         </TableRow>;
261                     }
262                     return null;
263                 }
264                 ))}
265         </TableBody>
266     </Table>;
267
268 const CardSSHSection = (props: VirtualMachineProps) =>
269     <Grid item xs={12}>
270         <Card>
271             <CardContent>
272                 <Typography>
273                     <div dangerouslySetInnerHTML={{ __html: sanitizeHTML(props.helpText) }} style={{ margin: "1em" }} />
274                 </Typography>
275             </CardContent>
276         </Card>
277     </Grid>;