21720:
[arvados.git] / services / workbench2 / src / views / process-panel / process-log-code-snippet.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React, { useEffect, useRef, useState } from 'react';
6 import { CustomStyleRulesCallback } from 'common/custom-theme';
7 import { ThemeProvider, Theme, StyledEngineProvider, createTheme, adaptV4Theme } from '@mui/material/styles';
8 import { WithStyles } from '@mui/styles';
9 import withStyles from '@mui/styles/withStyles';
10 import { ArvadosTheme } from 'common/custom-theme';
11 import { Link, Typography } from '@mui/material';
12 import { navigationNotAvailable } from 'store/navigation/navigation-action';
13 import { Dispatch } from 'redux';
14 import { connect, DispatchProp } from 'react-redux';
15 import classNames from 'classnames';
16 import { FederationConfig, getNavUrl } from 'routes/routes';
17 import { RootState } from 'store/store';
18 import { grey } from '@mui/material/colors';
19
20
21 declare module '@mui/styles/defaultTheme' {
22   // eslint-disable-next-line @typescript-eslint/no-empty-interface
23   interface DefaultTheme extends Theme {}
24 }
25
26
27 type CssRules = 'root' | 'wordWrapOn' | 'wordWrapOff' | 'logText';
28
29 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
30     root: {
31         boxSizing: 'border-box',
32         overflow: 'auto',
33         backgroundColor: '#000',
34         height: `calc(100% - ${theme.spacing(4)})`, // so that horizontal scollbar is visible
35         "& a": {
36             color: theme.palette.primary.main,
37         },
38     },
39     logText: {
40         color: '#fff',
41         paddingY: theme.spacing(0.5),
42     },
43     wordWrapOn: {
44         overflowWrap: 'anywhere',
45     },
46     wordWrapOff: {
47         whiteSpace: 'nowrap',
48     },
49 });
50
51 const theme = createTheme(adaptV4Theme({
52     overrides: {
53         MuiTypography: {
54             body2: {
55                 color: grey["200"]
56             }
57         }
58     },
59     typography: {
60         fontFamily: 'monospace',
61     }
62 }));
63
64 interface ProcessLogCodeSnippetProps {
65     lines: string[];
66     fontSize: number;
67     wordWrap?: boolean;
68 }
69
70 interface ProcessLogCodeSnippetAuthProps {
71     auth: FederationConfig;
72 }
73
74 const renderLinks = (fontSize: number, auth: FederationConfig, dispatch: Dispatch) => (text: string) => {
75     // Matches UUIDs & PDHs
76     const REGEX = /[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}|[0-9a-f]{32}\+\d+/g;
77     const links = text.match(REGEX);
78     if (!links) {
79         return <Typography style={{ fontSize: fontSize }}>{text}</Typography>;
80     }
81     return <Typography style={{ fontSize: fontSize }}>
82         {text.split(REGEX).map((part, index) =>
83             <React.Fragment key={index}>
84                 {part}
85                 {links[index] &&
86                     <Link onClick={() => {
87                         const url = getNavUrl(links[index], auth)
88                         if (url) {
89                             window.open(`${window.location.origin}${url}`, '_blank', "noopener");
90                         } else {
91                             dispatch(navigationNotAvailable(links[index]));
92                         }
93                     }}
94                         style={{ cursor: 'pointer' }}>
95                         {links[index]}
96                     </Link>}
97             </React.Fragment>
98         )}
99     </Typography>;
100 };
101
102 const mapStateToProps = (state: RootState): ProcessLogCodeSnippetAuthProps => ({
103     auth: state.auth,
104 });
105
106 export const ProcessLogCodeSnippet = withStyles(styles)(connect(mapStateToProps)(
107     ({ classes, lines, fontSize, auth, dispatch, wordWrap }: ProcessLogCodeSnippetProps & WithStyles<CssRules> & ProcessLogCodeSnippetAuthProps & DispatchProp) => {
108         const [followMode, setFollowMode] = useState<boolean>(true);
109         const scrollRef = useRef<HTMLDivElement>(null);
110
111         useEffect(() => {
112             if (followMode && scrollRef.current && lines.length > 0) {
113                 // Scroll to bottom
114                 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
115             }
116         }, [followMode, lines, scrollRef]);
117
118         return (
119             <StyledEngineProvider injectFirst>
120                 <ThemeProvider theme={theme}>
121                     <div ref={scrollRef} className={classes.root}
122                         onScroll={(e) => {
123                             const elem = e.target as HTMLDivElement;
124                             if (elem.scrollTop + (elem.clientHeight * 1.1) >= elem.scrollHeight) {
125                                 setFollowMode(true);
126                             } else {
127                                 setFollowMode(false);
128                             }
129                         }}>
130                         {lines.map((line: string, index: number) =>
131                             <Typography key={index} component="span"
132                                 className={classNames(classes.logText, wordWrap ? classes.wordWrapOn : classes.wordWrapOff)}>
133                                 {renderLinks(fontSize, auth, dispatch)(line)}
134                             </Typography>
135                         )}
136                     </div>
137                 </ThemeProvider>
138             </StyledEngineProvider>
139         );
140     }));