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