diff --git a/.env b/.env index 3ccb8fff3ef022e5adf5166117b885f96292f080..6f57ceae5a604a3032d31492dc684f08985b7bd0 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -EXTEND_ESLINT=true \ No newline at end of file +EXTEND_ESLINT=true +FAST_REFRESH=true \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index a07f51e6770e756c50518e784fefe34c8eacd9b7..f6bb6fa908c06aa7a4a3ac2dad1c091396b869c0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,6 +34,8 @@ module.exports = { 'max-len': 'off', 'implicit-arrow-linebreak': 'off', 'function-paren-newline': 'off', + 'react/jsx-curly-newline': 'off', + 'react/jsx-wrap-multilines': 'off', }, parserOptions: { ecmaVersion: 2020, diff --git a/.vscode/settings.json b/.vscode/settings.json index aa04e679c831027c621517cf6afff9fb16269880..780df2f968e5562ad9c74e2c6afd5c1a5f20280a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "app/dist": true, "app/main.prod.js": true, "app/main.prod.js.map": true, + "app/node_modules": true, "bower_components": true, "dll": true, "release": true, @@ -25,7 +26,30 @@ "npm-debug.log.*": true, "test/**/__snapshots__": true, "yarn.lock": true, - "*.{css,sass,scss}.d.ts": true + "*.{css,sass,scss}.d.ts": true, + "**/node_modules/**": true, + "**/.hg/store/**": true, + "app/package-lock.json": true }, + "files.watcherExclude": { + ".git": true, + ".eslintcache": true, + "app/dist": true, + "app/main.prod.js": true, + "app/main.prod.js.map": true, + "app/node_modules": true, + "bower_components": true, + "dll": true, + "release": true, + "node_modules": true, + "npm-debug.log.*": true, + "test/**/__snapshots__": true, + "yarn.lock": true, + "*.{css,sass,scss}.d.ts": true, + "**/node_modules/**": true, + "**/.hg/store/**": true, + "app/package-lock.json": true + }, + "editor.tabSize": 2 } diff --git a/app/App.tsx b/app/App.tsx index b4350105949cdb9c5e08472f708c6de431c57847..bdb35ee292871071511a8f32d0a8313923fc3721 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { createMuiTheme, ThemeProvider } from '@material-ui/core'; import { HashRouter as Router, Switch, Route } from 'react-router-dom'; -import { LoadingProvider } from './context/LoadingContext'; import routes from './routes'; import LoginScreen from './containers/LoginScreen'; import Dashboard from './containers/Dashboard'; @@ -19,9 +18,9 @@ const theme = createMuiTheme({ contrastText: '#FFFFFF', }, text: { - primary: 'rgba(255, 255, 255, 0,9)', - secondary: 'rgba(255, 255, 255, 0,8)', - disabled: 'rgba(255, 255, 255, 0,6)', + primary: 'rgba(255, 255, 255, 0.9)', + secondary: 'rgba(255, 255, 255, 0.7)', + disabled: 'rgba(255, 255, 255, 0.6)', }, background: { default: '#404040', // Dark 2 @@ -33,6 +32,9 @@ const theme = createMuiTheme({ button: { textTransform: 'capitalize', }, + subtitle2: { + color: 'rgba(255, 255, 255, 0.7)', + }, }, spacing: 8, props: { @@ -43,26 +45,32 @@ const theme = createMuiTheme({ margin: 'dense', }, }, + overrides: { + MuiSnackbarContent: { + root: { + backgroundColor: '#4B4B4B', // Dark 3 + color: 'rgba(255, 255, 255, 0.9)', + }, + }, + }, }); export default function App() { return ( <div className="content"> <ThemeProvider theme={theme}> - <LoadingProvider> - <LoginDetailsProvider> - <Router> - <Switch> - <Route exact path={routes.login}> - <LoginScreen /> - </Route> - <Route path={routes.dashboard}> - <Dashboard /> - </Route> - </Switch> - </Router> - </LoginDetailsProvider> - </LoadingProvider> + <LoginDetailsProvider> + <Router> + <Switch> + <Route exact path={routes.login}> + <LoginScreen /> + </Route> + <Route path={routes.dashboard}> + <Dashboard /> + </Route> + </Switch> + </Router> + </LoginDetailsProvider> </ThemeProvider> </div> ); diff --git a/app/QueryOutput.ts b/app/QueryOutput.ts index 1a7a89fc75f67c31b5bfe5a5508683ae23a44924..feaec6bacdec06713e081dcac2210de9440a22b6 100644 --- a/app/QueryOutput.ts +++ b/app/QueryOutput.ts @@ -1,6 +1,6 @@ import { Result } from '../backend/utils/mysql'; export interface QueryOutput { - results: Result | undefined; - error: undefined; + results?: Result; + error?: undefined; } diff --git a/app/app.global.css b/app/app.global.css index eed1a3f2d664da147cb1875058b76488799089b5..55a420d6b0ba5e7e880e01f2ca765cf33ff7adb4 100644 --- a/app/app.global.css +++ b/app/app.global.css @@ -20,3 +20,33 @@ body #root { min-height: 100%; min-width: 100%; } + +::-webkit-resizer { + border: 9px solid rgba(255, 255, 255, 0.1); + border-bottom-color: rgba(255, 255, 255, 0.5); + border-right-color: rgba(255, 255, 255, 0.5); + outline: 1px solid rgba(255, 255, 255, 0.1); + background-color: #636363; +} + +/* width */ +::-webkit-scrollbar { + width: 12px; +} + +/* Track */ +::-webkit-scrollbar-track { + background: #404040; + border-radius: 8px; +} + +/* Handle */ +::-webkit-scrollbar-thumb { + background: #636363; + border-radius: 8px; +} + +/* Handle on hover */ +::-webkit-scrollbar-thumb:hover { + background: #595959; +} diff --git a/app/app.html b/app/app.html index 29649a36b3c9af33ca7058fec9543efeebd2c21e..05642a16b61f0c0934a49b5fb0eebafb4ca55439 100644 --- a/app/app.html +++ b/app/app.html @@ -16,6 +16,10 @@ href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> + <link + rel="stylesheet" + href="https://fonts.googleapis.com/icon?family=Material+Icons" + /> <script> (() => { if ( diff --git a/app/components/ColorPicker.tsx b/app/components/ColorPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..455bd0c255cc8cb46719046e461639d327057ae4 --- /dev/null +++ b/app/components/ColorPicker.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { ChromePicker, ColorResult } from 'react-color'; +import { isNaN } from 'lodash'; +import { IconButton, Icon, Popover, TextField } from '@material-ui/core'; + +export default function ColorPicker(props: { + currentColor: string; + setColor: (value: string) => void; +}) { + const [colorPickerAnchor, setColorPickerAnchor] = useState<Element | null>( + null + ); + const { setColor, currentColor } = props; + + const [colorError, setColorError] = useState(''); + + function isHexColor(hex: string) { + return hex.length === 6 && !isNaN(Number(`0x${hex}`)); + } + + function handleChangedColor(color?: ColorResult, colorString?: string) { + if (color) { + setColor(color.hex); + setColorError(''); + } else if (colorString) { + if (isHexColor(colorString.replace('#', ''))) { + const convertedColorString = colorString.includes('#') + ? colorString + : `#${colorString}`; + setColor(convertedColorString); + setColorError(''); + } else { + setColorError('Must be valid hex color'); + } + } + } + + return ( + <div> + <TextField + variant="filled" + label="Color" + value={currentColor} + error={colorError.length > 0} + helperText={colorError} + onFocus={() => setColorError('')} + onChange={(event) => handleChangedColor(undefined, event.target.value)} + /> + <IconButton + onClick={(event) => setColorPickerAnchor(event.currentTarget)} + > + <Icon>palette</Icon> + </IconButton> + <Popover + open={Boolean(colorPickerAnchor)} + anchorEl={colorPickerAnchor} + onClose={() => setColorPickerAnchor(null)} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + > + <ChromePicker + color={currentColor} + onChange={(color) => handleChangedColor(color)} + onChangeComplete={(color) => handleChangedColor(color)} + /> + </Popover> + </div> + ); +} diff --git a/app/components/ErrorView.tsx b/app/components/ErrorView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a4e789a77abcedbf08a50bfe71cd81818a1ff2b --- /dev/null +++ b/app/components/ErrorView.tsx @@ -0,0 +1,38 @@ +import { Paper, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import React, { useContext } from 'react'; +import { RecordingContext } from '../context/RecordingContext'; + +export default function ErrorView() { + const recordingContext = useContext(RecordingContext); + const error = recordingContext?.activeRecording?.error; + + const useStyles = makeStyles((theme) => ({ + content: { + display: 'flex', + flexFlow: 'column nowrap', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + }, + })); + + const classes = useStyles(); + + return ( + <div> + {error && ( + <Paper className={classes.content} elevation={1}> + <Typography variant="h5">Errors</Typography> + <Typography + variant="subtitle1" + style={{ + whiteSpace: 'pre-line', + }} + color="error" + > + {error} + </Typography> + </Paper> + )} + </div> + ); +} diff --git a/app/components/ExplainAnalyzeTable.tsx b/app/components/ExplainAnalyzeTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..07cc3a257e0e9a69ba55173acfc9054424a7d0b0 --- /dev/null +++ b/app/components/ExplainAnalyzeTable.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Table from '@material-ui/core/Table'; +import TableBody from '@material-ui/core/TableBody'; +import TableCell from '@material-ui/core/TableCell'; +import TableContainer from '@material-ui/core/TableContainer'; +import TableRow from '@material-ui/core/TableRow'; +import Paper from '@material-ui/core/Paper'; + +const useStyles = makeStyles({ + table: { + // minWidth: 650, + }, +}); + +function createData(description: string, data: string) { + return { description, data }; +} + +export default function ExplainAnalyzeTable(data: { node: any }) { + const classes = useStyles(); + const { node } = data; + + if (node.name !== 'root') { + const rows = [ + createData('Command', node.name), + createData('Actual time', node.time), + createData('Rows', node.rows.toString()), + createData('Loops', node.loops.toString()), + ]; + + // TODO: Fix this: + // eslint-disable-next-line no-restricted-globals + if (!isNaN(node.cost_est) && !isNaN(node.rows_est)) { + rows.splice( + 1, + 0, + createData('Est. cost', node.cost_est?.toString()), + createData('Est. rows', node.rows_est?.toString()) + ); + } + + return ( + <TableContainer component={Paper}> + <Table className={classes.table} aria-label="simple table"> + <TableBody> + {rows.map((row) => ( + <TableRow key={row.description}> + <TableCell>{row.description}</TableCell> + <TableCell align="left">{row.data}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </TableContainer> + ); + } + return null; +} diff --git a/app/components/IDInfo.tsx b/app/components/IDInfo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fe032c8c0dce173baf80046c5bd97c5a4e537b9 --- /dev/null +++ b/app/components/IDInfo.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { makeStyles, Paper, Typography } from '@material-ui/core'; +import SqlManagerSingleton from '../../backend/recorder/SqlManagerSingleton'; + +const useStyles = makeStyles((theme) => ({ + wrapper: { + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + }, +})); + +export default function IDInfo() { + const manager = SqlManagerSingleton.getInstance(); + + const classes = useStyles(); + + return ( + <Paper elevation={1} className={classes.wrapper}> + <Typography variant="subtitle2"> + {`Runner ConnectionID: ${manager.runner?.connectionID} Runner ThreadID: ${manager.runner?.threadID}`} + </Typography> + <Typography variant="subtitle2"> + {`Monitor ConnectionID: ${manager.monitor?.connectionID} Monitor ThreadID: ${manager.monitor?.threadID}`} + </Typography> + <Typography variant="subtitle2"> + {`Agent ConnectionID: ${manager.agent?.connectionID} Agent ThreadID: ${manager.agent?.threadID}`} + </Typography> + </Paper> + ); +} diff --git a/app/components/Login.tsx b/app/components/Login.tsx index d8aa34fc491a7052f03d672196f730580560fd56..dbf33ab080f4ff5d6445a694cd9f9353843d40ab 100644 --- a/app/components/Login.tsx +++ b/app/components/Login.tsx @@ -6,11 +6,12 @@ import { Typography, makeStyles, LinearProgress, + FormControlLabel, + Checkbox, } from '@material-ui/core'; import { useHistory, useLocation } from 'react-router'; import clsx from 'clsx'; import SqlManagerSingleton from '../../backend/recorder/SqlManagerSingleton'; -import { LoadingContext } from '../context/LoadingContext'; import routes from '../routes'; import { LoginDetailsContext } from '../context/LoginDetailsContext'; @@ -59,7 +60,6 @@ const DefaultProps = { }; export default function Login({ onConnect = undefined }: LoginProps) { - const loadingContext = useContext(LoadingContext); const loginDetailsContext = useContext(LoginDetailsContext); const history = useHistory(); @@ -82,20 +82,27 @@ export default function Login({ onConnect = undefined }: LoginProps) { ); const [connecting, setConnecting] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [savePassword, setSavePassword] = useState( + loginDetailsContext?.loginDetails?.savePassword || false + ); async function handleConnectByLogin() { setConnecting(true); const manager = SqlManagerSingleton.getInstance(); try { - loadingContext?.setConnected(false); - loadingContext?.setConnecting(true); - await manager.connect({ host, user, password, port }, database); + await manager.connect( + { host, user, password, port }, + database, + savePassword + ); + loginDetailsContext?.setLoginDetails({ + ...{ host, user, password: savePassword ? password : '', port }, + database, + savePassword, + }); setErrorMessage(''); - loadingContext?.setConnecting(false); - loadingContext?.setConnected(true); if (location.pathname === routes.login) { - console.log('Routing to dashboard'); history.push(routes.dashboard); } else if (onConnect) { onConnect(); @@ -126,8 +133,6 @@ export default function Login({ onConnect = undefined }: LoginProps) { } else { setErrorMessage('Failed to connect'); } - loadingContext?.setConnecting(false); - loadingContext?.setConnected(false); setConnecting(false); } @@ -193,6 +198,16 @@ export default function Login({ onConnect = undefined }: LoginProps) { onKeyUp={handleKeyUp} /> </div> + <FormControlLabel + label="Save password" + control={ + <Checkbox + color="primary" + checked={savePassword} + onChange={(event) => setSavePassword(event.target.checked)} + /> + } + /> {!!errorMessage && ( <Typography color="error" diff --git a/app/components/MemoryChart.tsx b/app/components/MemoryChart.tsx index 0d19382f39abeff45394b9e3ebbf4a3314b8fa1e..5ae42c553cda33d08b7d7651c4168c9b22145f36 100644 --- a/app/components/MemoryChart.tsx +++ b/app/components/MemoryChart.tsx @@ -1,7 +1,20 @@ +import { + Backdrop, + Button, + Checkbox, + Fade, + ListItem, + ListItemIcon, + ListItemText, + makeStyles, + Modal, + TextField, + Typography, +} from '@material-ui/core'; import _ from 'lodash'; -import React, { useState, useContext } from 'react'; +import React, { useState, useContext, CSSProperties } from 'react'; +import { FixedSizeList } from 'react-window'; import { - Legend, LineChart, Line, CartesianGrid, @@ -9,54 +22,65 @@ import { YAxis, Tooltip, ResponsiveContainer, - Surface, - Symbols, ReferenceLine, Label, } from 'recharts'; import { RecordingContext } from '../context/RecordingContext'; -interface Payload { - value: string; - id: number; - type: string; - color: string; -} +const useStyles = makeStyles((theme) => ({ + lineChart: { + zIndex: 999, + }, + buttonWrapper: { + display: 'flex', + flexFlow: 'row nowrap', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + }, + element: { + marginBottom: theme.spacing(1), + }, + paper: { + position: 'absolute', + width: 500, + backgroundColor: theme.palette.background.paper, + padding: theme.spacing(2, 4, 3), + outline: 'none', + borderRadius: 4, + }, + listItemRoot: { + padding: 0, + }, +})); -interface ChartColors { - [key: string]: string; +function getModalStyle() { + return { + top: '50%', + left: '50%', + transform: 'translate(-50%,-50%)', + }; } export default function MemoryChart() { + const classes = useStyles(); const [disabled, setDisabled] = useState<Array<string>>([]); - const [chartColors, setChartColors] = useState({ total: '#82ca9d' }); - const validStages = [ - 'stage/sql/preparing', - 'stage/sql/optimizing', - 'stage/sql/executing', - 'stage/sql/end', - ]; + const [validStages, setValidStages] = useState<Array<string>>([]); + // const validStages = [ + // 'stage/sql/preparing', + // 'stage/sql/optimizing', + // 'stage/sql/executing', + // 'stage/sql/end', + // ]; + const [modalStyle] = React.useState(getModalStyle); + const [openEvent, setOpenEvent] = React.useState(false); + const [openStage, setOpenStage] = React.useState(false); + const [checked, setChecked] = useState<Array<string>>([]); + const [filteredMemory, setFilteredMemory] = useState<Array<string>>([]); + const [filteredStages, setFilteredStages] = useState<Array<string>>([]); const recordingContext = useContext(RecordingContext); - const stageTimes = recordingContext?.stageTimes; - const chartData = recordingContext?.chartData; - - function getColor() { - return `#${(0x1000000 + Math.random() * 0xffffff) - .toString(16) - .substr(1, 6)}`; - } - - function assignColor(key: string) { - const tempObject: ChartColors = {}; - tempObject[key] = getColor(); - return tempObject; - } - - if (chartData && _.keys(chartColors).length === 1) { - const dataKeys = _.keys(chartData[0]).slice(1); - _.forEach(dataKeys, (key) => _.assign(chartColors, assignColor(key))); - } + const stageTimes = recordingContext?.activeRecording?.stageTimes; + const chartData = recordingContext?.activeRecording?.chartData; + const testColors = recordingContext?.activeRecording?.chartColors; function getMaxTime() { // eslint-disable-next-line prefer-spread @@ -64,7 +88,7 @@ export default function MemoryChart() { } // When a legend is clicked the datakey is added to the disabled list, which will remove it form the line chart - function handleClick(dataKey: string) { + function eventToggle(dataKey: string) { if (_.includes(disabled, dataKey)) { setDisabled(disabled.filter((obj) => obj !== dataKey)); } else { @@ -72,38 +96,12 @@ export default function MemoryChart() { } } - function renderCustomizedLegend(props: { payload: Array<Payload> }) { - const { payload } = props; - return ( - <div className="customized-legend"> - {payload.map((entry: { value: string; color: string }) => { - const { value, color } = entry; - const active = _.includes(disabled, value); - const style = { - marginRight: 10, - color: active ? '#AAA' : '#000', - }; - - return ( - <span - role="presentation" - className="legend-item" - onClick={() => handleClick(value)} - style={style} - key={value} - > - <Surface width={10} height={10}> - <Symbols cx={5} cy={5} type="circle" size={50} fill={color} /> - {active && ( - <Symbols cx={5} cy={5} type="circle" size={25} fill="#FFF" /> - )} - </Surface> - <span style={{ color: '#FFF' }}>{value}</span> - </span> - ); - })} - </div> - ); + function validStageToggle(stage: string) { + if (_.includes(validStages, stage)) { + setValidStages(validStages.filter((obj) => obj !== stage)); + } else { + setValidStages(validStages.concat(stage)); + } } function formatBytes(byte: number) { @@ -116,79 +114,298 @@ export default function MemoryChart() { return `${Math.floor(byte)} B`; } - return ( - <div className="highlight-bar-charts"> - <ResponsiveContainer height={400}> - <LineChart - width={800} - height={500} - data={chartData} - margin={{ top: 20, right: 30, left: 20, bottom: 5 }} - > - <CartesianGrid strokeDasharray="3 10" /> - {/* TODO: Create our own Axis tick component like this: http://recharts.org/en-US/examples/CustomizedLabelLineChart */} - <XAxis - dataKey="relative_time" - type="number" - tick={{ stroke: '#FFFFFF', strokeWidth: '0.5px' }} - domain={[0, getMaxTime()]} - tickCount={20} - /> - <YAxis - type="number" - tickFormatter={formatBytes} - tick={{ stroke: '#FFFFFF', strokeWidth: '0.5px' }} - /> - <Tooltip - formatter={(value) => formatBytes(Number(value))} - offset={100} + function handleToggle(value: string, memory: boolean) { + const curIndex = checked.indexOf(value); + const newChecked = [...checked]; + + if (curIndex === -1) { + if (memory) { + eventToggle(value); + } else { + validStageToggle(value); + } + newChecked.push(value); + } else { + if (memory) { + eventToggle(value); + } else { + validStageToggle(value); + } + newChecked.splice(curIndex, 1); + } + setChecked(newChecked); + } + + // Opens the modal to select event + function handleOpenEvent() { + if (chartData) { + setFilteredMemory(_.keys(chartData[0]).slice(1)); + } + setOpenEvent(true); + } + + function handleCloseEvent() { + setOpenEvent(false); + } + + // Opens the modal to select event + function handleOpenStage() { + if (stageTimes) { + setFilteredStages(_.map(_.uniqBy(stageTimes, 'stage'), 'stage')); + } + setOpenStage(true); + } + + function handleCloseStage() { + setOpenStage(false); + } + + // Sets filteredMemory with a list of the datakeys that matches the text in the search bar + function searchBarFilter(search: string, memory: boolean) { + let filteredList = ['']; + if (memory) { + if (chartData) { + filteredList = _.filter(_.keys(chartData[0]).slice(1), (o) => + _.includes(o.toLowerCase(), search.toLowerCase()) + ); + } + setFilteredMemory(filteredList); + } else { + if (stageTimes) { + filteredList = _.filter( + _.map(_.uniqBy(stageTimes, 'stage'), 'stage'), + (o) => _.includes(o.toLowerCase(), search.toLowerCase()) + ); + } + setFilteredStages(filteredList); + } + } + + function renderEventRow(props: { index: number; style: CSSProperties }) { + const { index, style } = props; + + const memoryType = filteredMemory[index]; + + const labelId = `checkbox-list-label-${memoryType}`; + return ( + <ListItem + classes={{ root: classes.listItemRoot }} + style={style} + key={memoryType} + role={undefined} + dense + button + onClick={() => handleToggle(memoryType, true)} + > + <ListItemIcon> + <Checkbox + edge="start" + checked={checked.indexOf(memoryType) === -1} + tabIndex={-1} + disableRipple + inputProps={{ 'aria-labelledby': labelId }} + color="primary" /> - <Legend - verticalAlign="bottom" - height={36} - align="center" - payload={_.toPairs(chartColors).map((pair, i) => ({ - value: pair[0], - id: i, - type: 'line', - color: pair[1], - }))} - content={renderCustomizedLegend} + </ListItemIcon> + <ListItemText id={labelId} primary={`${memoryType}`} /> + </ListItem> + ); + } + + function renderStageRow(props: { index: number; style: CSSProperties }) { + const { index, style } = props; + + const stage = filteredStages[index]; + + const labelId = `checkbox-list-label-${stage}`; + return ( + <ListItem + classes={{ root: classes.listItemRoot }} + style={style} + key={stage} + role={undefined} + dense + button + onClick={() => handleToggle(stage, false)} + > + <ListItemIcon> + <Checkbox + edge="start" + checked={checked.indexOf(stage) !== -1} + tabIndex={-1} + disableRipple + inputProps={{ 'aria-labelledby': labelId }} + color="primary" /> + </ListItemIcon> + <ListItemText id={labelId} primary={`${stage}`} /> + </ListItem> + ); + } - {/* Creates an array from the key:value pairs in chartColors. - The keys that are not disabled becomes a line */} - {_.toPairs(chartColors) - .filter((pair) => !_.includes(disabled, pair[0])) - .map((pair) => ( - <Line - type="monotone" - dataKey={pair[0]} - key={pair[0]} - stroke={pair[1]} - dot={false} - /> - ))} - {/* Filters stageTimes to only include stages that are in validStages and only the first instance of them. - Then creates reference lines from the data that remains */} - - {_.forEach(_.uniqBy(stageTimes, 'stage')) - ?.filter((stage) => _.includes(validStages, stage.stage)) - ?.map((item) => ( - <ReferenceLine - x={item.startTime} - stroke="#769CFF" - key={_.uniqueId('ref_')} + return ( + <div className={classes.lineChart}> + <div className={classes.buttonWrapper}> + <div style={{ flexGrow: 1 }}> + <Typography variant="h6">Memory performance</Typography> + </div> + <Button + variant="outlined" + color="primary" + onClick={handleOpenEvent} + style={{ marginRight: 16 }} + > + Pick Events + </Button> + <Modal + open={openEvent} + onClose={handleCloseEvent} + aria-labelledby="simple-modal-title" + closeAfterTransition + BackdropComponent={Backdrop} + BackdropProps={{ + timeout: 500, + }} + > + <Fade in={openEvent}> + <div style={modalStyle} className={classes.paper}> + <div className={classes.element}> + <Typography variant="h5">Pick events to show</Typography> + </div> + <form> + <TextField + id="filled-basic" + label="Search event..." + variant="filled" + onChange={(e) => searchBarFilter(e.target.value, true)} + /> + </form> + <FixedSizeList + height={400} + width="100%" + itemSize={48} + itemCount={filteredMemory.length} > - <Label - value={item.stage} - position="top" - style={{ color: '#FFFFFF' }} + {renderEventRow} + </FixedSizeList> + </div> + </Fade> + </Modal> + {/* Stage modal */} + <Button variant="outlined" color="primary" onClick={handleOpenStage}> + Pick Stages + </Button> + <Modal + open={openStage} + onClose={handleCloseStage} + aria-labelledby="simple-modal-title" + closeAfterTransition + BackdropComponent={Backdrop} + BackdropProps={{ + timeout: 500, + }} + > + <Fade in={openStage}> + <div style={modalStyle} className={classes.paper}> + <div className={classes.element}> + <Typography variant="h5">Pick stages to show</Typography> + </div> + <form> + <TextField + id="filled-basic" + label="Search stage" + variant="filled" + onChange={(e) => searchBarFilter(e.target.value, false)} /> - </ReferenceLine> - ))} - </LineChart> - </ResponsiveContainer> + </form> + <FixedSizeList + height={400} + width="100%" + itemSize={48} + itemCount={filteredStages.length} + > + {renderStageRow} + </FixedSizeList> + </div> + </Fade> + </Modal> + </div> + {chartData && chartData.length > 2 ? ( + <div> + <ResponsiveContainer height={400}> + <LineChart + width={800} + height={500} + data={chartData} + margin={{ top: 20, right: 30, left: 20, bottom: 5 }} + style={{ fontFamily: 'Roboto, sans-serif' }} + > + <CartesianGrid strokeDasharray="3 10" /> + {/* TODO: Create our own Axis tick component like this: http://recharts.org/en-US/examples/CustomizedLabelLineChart */} + <XAxis + dataKey="relative_time" + type="number" + domain={[0, getMaxTime()]} + tickCount={20} + stroke="#FFFFFF" + /> + <YAxis + type="number" + tickFormatter={formatBytes} + stroke="#FFFFFF" + /> + <Tooltip + formatter={(value) => formatBytes(Number(value))} + offset={150} + /> + + {/* Creates an array from the key:value pairs in chartColors. + The keys that are not disabled becomes a line */} + {_.toPairs(testColors) + .filter((pair) => !_.includes(disabled, pair[0])) + .map((pair) => ( + <Line + type="monotone" + dataKey={pair[0]} + key={pair[0]} + stroke={pair[1]} + dot={false} + /> + ))} + {/* Filters stageTimes to only include stages that are in validStages and only the first instance of them. + en creates reference lines from the data that remains */} + + {_.forEach(_.uniqBy(stageTimes, 'stage')) + ?.filter((stage) => _.includes(validStages, stage.stage)) + ?.map((item) => ( + <ReferenceLine + x={item.startTime} + stroke="#769CFF" + key={_.uniqueId('ref_')} + > + <Label + value={item.stage} + position="top" + style={{ color: '#FFFFFF' }} + /> + </ReferenceLine> + ))} + </LineChart> + </ResponsiveContainer> + </div> + ) : ( + <div> + {chartData ? ( + <Typography variant="subtitle1" style={{ padding: '8px 16px' }}> + There are not enough data points to show a meaningful graph + </Typography> + ) : ( + <Typography variant="subtitle1" style={{ padding: '8px 16px' }}> + No data + </Typography> + )} + </div> + )} </div> ); } diff --git a/app/components/OptimizerTrace.tsx b/app/components/OptimizerTrace.tsx index cb2f869e48bb67e5f11a4768dc369b8ab2919b4c..529d9c92e69700564767120bbaa740f4cdb5f672 100644 --- a/app/components/OptimizerTrace.tsx +++ b/app/components/OptimizerTrace.tsx @@ -1,21 +1,86 @@ -import React, { useContext } from 'react'; +import { Button, Typography } from '@material-ui/core'; +import React, { useState, useContext } from 'react'; import ReactJson from 'react-json-view'; +import { makeStyles } from '@material-ui/core/styles'; import { RecordingContext } from '../context/RecordingContext'; export default function OptimizerTrace() { + const [expandAll, setExpandAll] = useState<boolean | undefined>(undefined); + + function getHeight() { + if (typeof expandAll === 'undefined' || expandAll) return 211; + return 'auto'; + } + + const useStyles = makeStyles((theme) => ({ + container: { + resize: 'vertical', + overflowY: 'scroll', + minHeight: 70, + }, + header: { + display: 'flex', + flexDirection: 'row', + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + justifyContent: 'space-between', + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: '#4B4B4B', + }, + trace: { zIndex: 0 }, + })); + const recordingContext = useContext(RecordingContext); - const optimizerTrace = recordingContext?.optimizerTrace; + const optimizerTrace = recordingContext?.activeRecording?.optimizerTrace; + + function handleButtonClick() { + if (typeof expandAll === 'undefined') { + setExpandAll(true); + return; + } + setExpandAll(!expandAll); + } + + function getCollapseLevel() { + if (typeof expandAll === 'undefined') return 5; + return !expandAll; + } + + const classes = useStyles(); + return ( - <div> - {optimizerTrace && ( - <ReactJson - src={optimizerTrace} - theme="twilight" - name="OptimizerTrace" - collapsed={5} - indentWidth={2} - /> - )} + <div + style={{ + height: getHeight(), + }} + className={classes.container} + > + <div className={classes.header}> + <Typography variant="h6">Optimizer trace</Typography> + <div> + <Button + variant="contained" + color="primary" + onClick={() => handleButtonClick()} + > + {expandAll ? 'Collapse all' : 'Expand all'} + </Button> + </div> + </div> + <div className={classes.trace}> + {optimizerTrace && ( + <ReactJson + src={optimizerTrace} + theme="twilight" + name="OptimizerTrace" + collapsed={getCollapseLevel()} + indentWidth={2} + /> + )} + </div> </div> ); } diff --git a/app/components/QueryRecorder.tsx b/app/components/QueryRecorder.tsx index 1889d6e910f17e8e3eceef27ad52cd5b7b8457a2..4b21aad916a989e76800be4dcf6bdc6a5eceb4e3 100644 --- a/app/components/QueryRecorder.tsx +++ b/app/components/QueryRecorder.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/jsx-wrap-multilines */ import React, { useState, useContext } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { @@ -9,23 +8,28 @@ import { FormControlLabel, Checkbox, CircularProgress, + Slider, + IconButton, + Icon, + Snackbar, } from '@material-ui/core'; +import Switch from '@material-ui/core/Switch'; +import Collapse from '@material-ui/core/Collapse'; import clsx from 'clsx'; +import { v4 as uuid } from 'uuid'; import { RecordingContext } from '../context/RecordingContext'; -import ResultJson from './ResultJson'; import SqlManagerSingleton from '../../backend/recorder/SqlManagerSingleton'; +import { RawRecording } from '../../backend/recorder/SqlManager'; +import ColorPicker from './ColorPicker'; const useStyles = makeStyles((theme) => ({ container: { - maxWidth: 600 + theme.spacing(4), + // maxWidth: 600 + theme.spacing(4), }, content: { display: 'flex', flexFlow: 'column nowrap', - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(1), - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, }, element: { marginBottom: theme.spacing(1), @@ -46,41 +50,126 @@ const useStyles = makeStyles((theme) => ({ code: { fontFamily: 'Roboto Mono !important', }, + slider: { + marginRight: theme.spacing(2), + marginLeft: theme.spacing(2), + }, })); +const defaultTimestep = 200; // ms + export default function QueryRecorder() { - const [changedQuery, setChangedQuery] = useState(''); - const [output, setOutput] = useState(''); + const [query, setQuery] = useState(''); const [explainAnalyze, setExplainAnalyze] = useState(false); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + const [timeStep, setTimeStepValue] = useState( + (defaultTimestep as number) / 1000 + ); + const [recordingLabel, setRecordingLabel] = useState(''); + const [recordingColor, setRecordingColor] = useState(''); const [waitRecording, setWaitRecording] = useState(false); + const [userQueryIsRunning, setUserQueryIsRunning] = useState(false); + const [managerState, setManagerState] = useState(''); + const [donePopupOpen, setDonePopupOpen] = useState(false); + const [recordingTime, setRecordingTime] = useState(0); const manager = SqlManagerSingleton.getInstance(); const recordingContext = useContext(RecordingContext); + function updateContext( + result: RawRecording, + elapsed: number, + label: string, + color: string, + error?: string + ) { + const uniqueID = uuid(); + recordingContext?.recordingListItems?.forEach((listItem) => { + listItem.viewing = false; + }); + const existingListItems = recordingContext?.recordingListItems || []; + const existingRecordings = recordingContext?.recordings || []; + const newRecording = { + queryOutput: result.result, + error, + stageTimes: !error ? result.stageTimes : undefined, + chartData: !error ? result.memoryPerformance : undefined, + optimizerTrace: !error ? result.optimizerTrace : undefined, + chartColors: !error ? result.chartColors : undefined, + explainAnalyze: + explainAnalyze || query.toLowerCase().includes('explain analyze'), + explainAnalyzeTree: !error ? result.explainAnalyze : undefined, + label, + uuid: uniqueID, + }; + const newListItem = { + query, + elapsed, + label, + color, + uuid: uniqueID, + viewing: true, + }; + recordingContext?.setActiveRecording(newRecording); + recordingContext?.setRecordingListItems([ + newListItem, + ...existingListItems, + ]); + recordingContext?.setRecordings([newRecording, ...existingRecordings]); + setDonePopupOpen(true); + } + async function record() { setWaitRecording(true); - if (!manager) { - console.log('not connected'); + if (!manager.client) { + console.error('QueryRecorder: Not connected.'); + setWaitRecording(false); return; } - try { - const result = await manager.record(changedQuery, explainAnalyze); - if (result?.error) { - console.log(`QueryRecorder: Result: ${result}`); - recordingContext?.setQueryOutput(result.result.error); - const errorcode = JSON.stringify(result.error.info.code, undefined, 2); - const errormsg = JSON.stringify(result.error.info.msg, undefined, 2); - setOutput(`Errorcode: ${errorcode}\n${errormsg}`); + // TODO: Get time elapsed from result + const t0 = performance.now(); + const result = await manager.record( + query, + setUserQueryIsRunning, + timeStep, + explainAnalyze, + setManagerState + ); + const t1 = performance.now(); + setRecordingTime(t1 - t0); + if (result.error === 'cancelled') { + updateContext( + result, + t1 - t0, + recordingLabel, + recordingColor, + 'Query was cancelled' + ); + } else if (result?.error) { + let error; + if (result.error?.info) { + const errorcode = JSON.stringify( + result?.error?.info?.code, + undefined, + 2 + ); + const errormsg = JSON.stringify( + result?.error?.info?.msg, + undefined, + 2 + ); + error = `Errorcode: ${errorcode}\n${errormsg}`; + } else { + error = String(result.error); + } + updateContext(result, t1 - t0, recordingLabel, recordingColor, error); } else { - recordingContext?.setQueryOutput(result.result); - setOutput(JSON.stringify(result?.result, undefined, 2)); - recordingContext?.setOptimizerTrace(result.optimizerTrace); - recordingContext?.setChartData(result.memoryPerformance); - recordingContext?.setStageTimes(result.stageTimes); + updateContext(result, t1 - t0, recordingLabel, recordingColor); } + setRecordingLabel(''); } catch (error) { - console.log(`QueryRecorder: Error: ${error}`); + console.error(`QueryRecorder: Error: ${error}`); } finally { setWaitRecording(false); } @@ -88,12 +177,35 @@ export default function QueryRecorder() { const classes = useStyles(); - function handleKeyUp(e: { key: string; ctrlKey: boolean }) { + function checkShortcut(e: { key: string; ctrlKey: boolean }) { if (e.key === 'Enter' && e.ctrlKey) { record(); } } + function handleSliderChange(_: unknown, newTimeStep: number | number[]) { + setTimeStepValue((newTimeStep as number) / 1000); + } + + function getSliderValueText(value: number) { + return `${value} ms`; + } + + const marks = [ + { + value: 100, + label: '100 ms', + }, + { + value: 1000, + label: '1000 ms', + }, + ]; + + function toggleAdvancedOptions() { + setShowAdvancedOptions((prev) => !prev); + } + return ( <div className={classes.container}> <Paper className={classes.content} elevation={1}> @@ -113,9 +225,9 @@ export default function QueryRecorder() { label="Query" rows={2} rowsMax={10} - value={changedQuery} - onChange={(event) => setChangedQuery(event.target.value)} - onKeyUp={handleKeyUp} + value={query} + onChange={(event) => setQuery(event.target.value)} + onKeyUp={checkShortcut} helperText="Ctrl + Enter: Run" /> </div> @@ -131,6 +243,60 @@ export default function QueryRecorder() { label="Explain analyze" /> </div> + <div className={classes.element}> + <FormControlLabel + control={ + <Switch + checked={showAdvancedOptions} + onChange={toggleAdvancedOptions} + onKeyUp={(event) => + event.key === 'Enter' && toggleAdvancedOptions() + } + color="primary" + inputProps={{ 'aria-label': 'primary checkbox' }} + /> + } + label="Advanced options" + /> + </div> + <div className={classes.element}> + <Collapse in={showAdvancedOptions}> + <div className={classes.element}> + <div> + <Typography variant="subtitle1"> + Recording timestep (ms) + </Typography> + </div> + <div className={classes.slider}> + <Slider + defaultValue={defaultTimestep} + aria-labelledby="discrete-slider-small-steps" + getAriaValueText={getSliderValueText} + onChange={handleSliderChange} + step={50} + marks={marks} + min={100} + max={1000} + valueLabelDisplay="auto" + /> + </div> + </div> + <div className={classes.element}> + <TextField + variant="filled" + label="Recording label" + value={recordingLabel} + onChange={(event) => setRecordingLabel(event.target.value)} + onKeyUp={checkShortcut} + /> + </div> + <ColorPicker + currentColor={recordingColor} + setColor={setRecordingColor} + /> + </Collapse> + </div> + <div className={clsx(classes.element, classes.recordButtonWrapper)}> <Button disabled={waitRecording} @@ -140,6 +306,16 @@ export default function QueryRecorder() { > Record </Button> + <Button + style={{ margin: '8px' }} + disabled={!userQueryIsRunning} + variant="outlined" + color="primary" + onClick={() => manager.cancelQuery()} + > + Cancel + </Button> + {waitRecording && ( <CircularProgress style={{ marginLeft: '16px' }} @@ -148,9 +324,30 @@ export default function QueryRecorder() { /> )} </div> - <div className={clsx(classes.element, classes.input)}> - <ResultJson result={output} /> - </div> + {managerState.length > 0 && ( + <div className={classes.element}> + <Typography variant="subtitle2">{managerState}</Typography> + </div> + )} + <Snackbar + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + autoHideDuration={6000} + open={donePopupOpen} + onClose={() => setDonePopupOpen(false)} + message={`Recording finished in ${recordingTime.toFixed(1)} ms`} + action={ + <> + <IconButton + size="small" + aria-label="close" + color="inherit" + onClick={() => setDonePopupOpen(false)} + > + <Icon>close</Icon> + </IconButton> + </> + } + /> </Paper> </div> ); diff --git a/app/components/RecordingOptions/ChangeColor.tsx b/app/components/RecordingOptions/ChangeColor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a666fe53a8c619d5da1d29cac47f1c16c65c29f1 --- /dev/null +++ b/app/components/RecordingOptions/ChangeColor.tsx @@ -0,0 +1,58 @@ +import React, { useState, useContext } from 'react'; +import { Dialog, DialogTitle, Button, MenuItem } from '@material-ui/core'; +import { RecordingContext } from '../../context/RecordingContext'; +import ColorPicker from '../ColorPicker'; + +export default function ChangeColor(props: { + recordinguuid: string; + handleIconMenuClose: () => void; +}) { + const { recordinguuid, handleIconMenuClose } = props; + + const [changedColor, setChangedColor] = useState(''); + const [changeColorDialogOpen, setChangeColorDialogOpen] = useState(false); + + const recordingContext = useContext(RecordingContext); + const recordingListItems = recordingContext?.recordingListItems || []; + + function handleChangeColorDialogClose() { + setChangeColorDialogOpen(false); + } + + function handleChangeColorOptionClick() { + handleIconMenuClose(); + setChangeColorDialogOpen(true); + } + + function handleChangeColorButtonClick() { + const newRecordingListItems = [...recordingListItems]; + newRecordingListItems.forEach((r) => { + if (r.uuid === recordinguuid) { + r.color = changedColor; + } + }); + recordingContext?.setRecordingListItems(newRecordingListItems); + + handleChangeColorDialogClose(); + } + + return ( + <div> + <MenuItem onClick={handleChangeColorOptionClick}>Change color</MenuItem> + <Dialog + onClose={handleChangeColorDialogClose} + open={changeColorDialogOpen} + > + <DialogTitle>Change color</DialogTitle> + <ColorPicker currentColor={changedColor} setColor={setChangedColor} /> + <Button + variant="contained" + color="primary" + onClick={() => handleChangeColorButtonClick()} + > + Change + </Button> + </Dialog> + </div> + ); +} diff --git a/app/components/RecordingOptions/ChangeLabel.tsx b/app/components/RecordingOptions/ChangeLabel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..612e07c209b549b507667a520530e615f7412957 --- /dev/null +++ b/app/components/RecordingOptions/ChangeLabel.tsx @@ -0,0 +1,77 @@ +import React, { useState, useContext } from 'react'; +import { + Dialog, + DialogTitle, + TextField, + Button, + MenuItem, +} from '@material-ui/core'; +import { RecordingContext } from '../../context/RecordingContext'; + +export default function ChangeLabel(props: { + recordinguuid: string; + handleIconMenuClose: () => void; +}) { + const { recordinguuid, handleIconMenuClose } = props; + const [changedLabel, setChangedLabel] = useState(''); + const [changeLabelDialogOpen, setChangeLabelDialogOpen] = useState(false); + + const recordingContext = useContext(RecordingContext); + const recordingListItems = recordingContext?.recordingListItems || []; + const recordings = recordingContext?.recordings || []; + + function handleChangeLabelDialogClose() { + setChangeLabelDialogOpen(false); + } + + function handleChangeLabelOptionClick() { + handleIconMenuClose(); + setChangeLabelDialogOpen(true); + } + + function handleChangeLabelButtonClick() { + const newRecordingListItems = [...recordingListItems]; + newRecordingListItems.forEach((r) => { + if (r.uuid === recordinguuid) { + r.label = changedLabel; + } + }); + recordingContext?.setRecordingListItems(newRecordingListItems); + + const newRecordings = [...recordings]; + newRecordings.forEach((r) => { + if (r.uuid === recordinguuid) { + r.label = changedLabel; + } + }); + recordingContext?.setRecordings(newRecordings); + + handleChangeLabelDialogClose(); + } + + return ( + <div> + <MenuItem onClick={handleChangeLabelOptionClick}>Change label</MenuItem> + <Dialog + onClose={handleChangeLabelDialogClose} + open={changeLabelDialogOpen} + > + <DialogTitle>Change label</DialogTitle> + <TextField + autoFocus + variant="filled" + label="New label" + value={changedLabel} + onChange={(event) => setChangedLabel(event.target.value)} + /> + <Button + variant="contained" + color="primary" + onClick={() => handleChangeLabelButtonClick()} + > + Change + </Button> + </Dialog> + </div> + ); +} diff --git a/app/components/RecordingOptions/RecordingOptions.tsx b/app/components/RecordingOptions/RecordingOptions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..785c3e351ff144fb18401fc65465074eaa580889 --- /dev/null +++ b/app/components/RecordingOptions/RecordingOptions.tsx @@ -0,0 +1,93 @@ +import React, { useState, useContext } from 'react'; +import { + Icon, + IconButton, + Menu, + MenuItem, + ListItemIcon, +} from '@material-ui/core'; +import ChangeLabel from './ChangeLabel'; +import ChangeColor from './ChangeColor'; +import { RecordingContext } from '../../context/RecordingContext'; + +export default function RecordingOptions(props: { recordinguuid: string }) { + const [iconAnchorEl, setIconAnchorEl] = useState<null | HTMLElement>(null); + + const { recordinguuid } = props; + + const recordingContext = useContext(RecordingContext); + const recordingListItems = recordingContext?.recordingListItems || []; + const recordings = recordingContext?.recordings || []; + + function handleIconButtonClick(event: React.MouseEvent<HTMLButtonElement>) { + setIconAnchorEl(event.currentTarget); + } + + function handleIconMenuClose() { + setIconAnchorEl(null); + } + + function handleCopyQuery() { + const recordingListItem = recordingListItems.find( + (r) => r.uuid === recordinguuid + ); + navigator.clipboard.writeText(recordingListItem?.query || ''); + handleIconMenuClose(); + } + + function handleRemoveRecording() { + const activeRecordinguuid = recordingContext?.activeRecording?.uuid || ''; + recordingContext?.setRecordingListItems( + recordingListItems.filter((r) => r.uuid !== recordinguuid) + ); + recordingContext?.setRecordings( + recordings.filter((r) => r.uuid !== recordinguuid) + ); + if (activeRecordinguuid === recordinguuid) { + const newActiveRecording = recordings.find( + (r) => r.uuid !== recordinguuid + ); + recordingContext?.setActiveRecording(newActiveRecording); + if (newActiveRecording) { + const newActiveRecordingListItem = recordingListItems.find( + (r) => r.uuid === newActiveRecording.uuid + ); + if (newActiveRecordingListItem) { + newActiveRecordingListItem.viewing = true; + } + } + } + handleIconMenuClose(); + } + + return ( + <ListItemIcon> + <IconButton + edge="end" + aria-controls="icon-menu" + aria-haspopup="true" + onClick={(event) => handleIconButtonClick(event)} + > + <Icon>more_vert</Icon> + </IconButton> + <Menu + id="icon-menu" + anchorEl={iconAnchorEl} + keepMounted + open={Boolean(iconAnchorEl)} + onClose={handleIconMenuClose} + > + <ChangeLabel + recordinguuid={recordinguuid} + handleIconMenuClose={handleIconMenuClose} + /> + <ChangeColor + recordinguuid={recordinguuid} + handleIconMenuClose={handleIconMenuClose} + /> + <MenuItem onClick={handleCopyQuery}>Copy query</MenuItem> + <MenuItem onClick={handleRemoveRecording}>Remove</MenuItem> + </Menu> + </ListItemIcon> + ); +} diff --git a/app/components/Recordings.tsx b/app/components/Recordings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7efb8e002c06b1d1ee2620ef5488b26bd97aebb5 --- /dev/null +++ b/app/components/Recordings.tsx @@ -0,0 +1,308 @@ +import { + Button, + ListItem, + ListItemText, + makeStyles, + Paper, + TextField, + Tooltip, + Typography, +} from '@material-ui/core'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import SearchIcon from '@material-ui/icons/Search'; +import React, { CSSProperties, useContext, useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import useDeepCompareEffect from 'use-deep-compare-effect'; +import { RecordingContext } from '../context/RecordingContext'; +import RecordingOptions from './RecordingOptions/RecordingOptions'; + +const useStyles = makeStyles((theme) => ({ + wrapper: { + // maxWidth: 600 + theme.spacing(4), + }, + content: { + display: 'flex', + flexFlow: 'column nowrap', + padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`, + }, + listItemTextPrimary: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + // textAlign: 'left', + }, + listItemTextRoot: { + margin: 0, + padding: '12px 0px', + }, + listItemRoot: { + padding: 0, + }, +})); + +export default function Recordings() { + const recordingContext = useContext(RecordingContext); + const recordings = recordingContext?.recordingListItems || []; + + const [search, setSearch] = useState(''); + const [displayedRecordings, setDisplayedRecordings] = useState(recordings); + + const [activeRecording, setActiveRecording] = useState<string | undefined>( + recordings.find((recording) => recording.viewing)?.uuid + ); + + useDeepCompareEffect(() => { + // Using useDeepCompareEffect because we want this to happen when 'recordings' + // changes, even if it is an array (useEffect only supports primitive values) + const newActiveRecording = recordings.find((recording) => recording.viewing) + ?.uuid; + setActiveRecording(newActiveRecording); + setDisplayedRecordings(recordings); + }, [recordings]); + + const classes = useStyles(); + + function handleRecordingClick(index: number) { + if (index < displayedRecordings.length) { + const { uuid } = displayedRecordings[index]; + const originalIndex = recordings.findIndex( + (value) => value.uuid === uuid + ); + const newRecordingListItems = recordings; + if (activeRecording) { + const activeItemIndex = newRecordingListItems.findIndex( + (value) => value.uuid === activeRecording + ); + newRecordingListItems[activeItemIndex].viewing = false; + } + newRecordingListItems[originalIndex].viewing = true; + recordingContext?.setRecordingListItems(newRecordingListItems); + setActiveRecording(uuid); + if (recordingContext?.recordings) { + recordingContext?.setActiveRecording( + recordingContext.recordings[originalIndex] + ); + } + } + } + + function handleSaveClick(index: number) { + // TODO: Implement + console.log(`Clicked ${index} button`); + } + + function formatElapsed(elapsed: number) { + // Assuming a query will not run for longer than a few hours + if (elapsed > 1000 * 60 * 60) { + return `${(elapsed / (1000 * 60 * 60)).toFixed(1)} h`; + } + if (elapsed > 1000 * 60) { + return `${(elapsed / (1000 * 60)).toFixed(1)} min`; + } + if (elapsed > 1000) { + return `${(elapsed / 1000).toFixed(1)} s`; + } + return `${elapsed.toFixed(1)} ms`; + } + + function filterRecordings() { + const filtered = recordings.filter((recording) => + recording.label.toLowerCase().includes(search.toLowerCase()) + ); + setDisplayedRecordings(filtered); + } + + function checkShortcut(e: { key: string }) { + if (e.key === 'Enter') { + filterRecordings(); + } + } + + function renderRow(props: { index: number; style: CSSProperties }) { + const { index, style } = props; + const recording = displayedRecordings[index]; + + return ( + <ListItem + classes={{ root: classes.listItemRoot }} + style={style} + key={index} + button + selected={recording.uuid === activeRecording} + > + <Tooltip title={index + 1}> + <ListItemText + primary={index + 1} + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 1, + flexShrink: 1, + flexBasis: 30, + height: 30, + padding: 0, + marginRight: 4, + backgroundColor: recording.color, + borderRadius: 100, + textAlign: 'center', + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + }} + onClick={() => handleRecordingClick(index)} + /> + </Tooltip> + <Tooltip title={recording.label}> + <ListItemText + primary={recording.label} + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 4, + flexShrink: 1, + flexBasis: 226, + paddingRight: 4, + }} + onClick={() => handleRecordingClick(index)} + /> + </Tooltip> + <Tooltip title={formatElapsed(recording.elapsed)}> + <ListItemText + primary={formatElapsed(recording.elapsed)} + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 2, + flexShrink: 1, + flexBasis: 70, + paddingRight: 4, + }} + onClick={() => handleRecordingClick(index)} + /> + </Tooltip> + <Tooltip title={recording.query}> + <ListItemText + primary={recording.query} + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ flexGrow: 1, flexShrink: 1, flexBasis: 150 }} + onClick={() => handleRecordingClick(index)} + /> + </Tooltip> + <Button + variant="outlined" + color="primary" + onClick={() => handleSaveClick(index)} + > + Save + </Button> + <RecordingOptions recordinguuid={recording.uuid} /> + </ListItem> + ); + } + + return ( + <div className={classes.wrapper}> + <Paper className={classes.content} elevation={1}> + <Typography variant="h5">Recordings</Typography> + <TextField + id="outlined-search" + label="Search label..." + type="search" + variant="outlined" + value={search} + onChange={(event) => setSearch(event.target.value)} + onKeyUp={checkShortcut} + InputProps={{ + endAdornment: ( + <InputAdornment position="start"> + <SearchIcon /> + </InputAdornment> + ), + }} + style={{ + marginTop: 8, + width: 170, + }} + /> + <ListItem classes={{ root: classes.listItemRoot }}> + <ListItemText + primary="#" + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 1, + flexShrink: 1, + flexBasis: 30, + height: 30, + padding: 0, + marginRight: 4, + borderRadius: 100, + textAlign: 'center', + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + }} + /> + <ListItemText + primary="Label" + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 4, + flexShrink: 1, + flexBasis: 226, + paddingRight: 4, + }} + /> + <ListItemText + primary="Elapsed" + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 2, + flexShrink: 1, + flexBasis: 70, + paddingRight: 4, + }} + /> + <ListItemText + primary="Query" + classes={{ + primary: classes.listItemTextPrimary, + root: classes.listItemTextRoot, + }} + style={{ + flexGrow: 1, + flexShrink: 1, + flexBasis: 150, + }} + /> + <div style={{ minWidth: 120, maxWidth: 120, height: 48 }} /> + </ListItem> + <FixedSizeList + height={400} + width="100%" + itemSize={48} + itemCount={displayedRecordings.length} + > + {renderRow} + </FixedSizeList> + </Paper> + </div> + ); +} diff --git a/app/components/ResultJson.tsx b/app/components/ResultJson.tsx index 9cdb8ebb1f3c72ba951a1f46bc2a3d59bb621418..0a7bf7b10de339f66580516b9cbdf554501f33d0 100644 --- a/app/components/ResultJson.tsx +++ b/app/components/ResultJson.tsx @@ -1,41 +1,44 @@ import React from 'react'; import { makeStyles } from '@material-ui/core/styles'; import { TextField } from '@material-ui/core'; + import clsx from 'clsx'; interface TResultProps { result: string; + textColor: string; } -const useStyles = makeStyles((theme) => ({ - container: { - maxWidth: 600 + theme.spacing(4), - }, - content: { - display: 'flex', - flexFlow: 'column nowrap', - paddingTop: theme.spacing(1), - paddingBottom: theme.spacing(1), - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - }, - element: { - marginBottom: theme.spacing(1), - }, - input: { - display: 'flex', - flexFlow: 'column nowrap', - flexGrow: 1, - }, - result: { - userSelect: 'none', - }, - code: { - fontFamily: 'Roboto Mono !important', - }, -})); - export default function ResultJson(props: TResultProps) { + const useStyles = makeStyles((theme) => ({ + container: { + maxWidth: 600 + theme.spacing(4), + }, + content: { + display: 'flex', + flexFlow: 'column nowrap', + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + }, + element: { + marginBottom: theme.spacing(1), + }, + input: { + display: 'flex', + flexFlow: 'column nowrap', + flexGrow: 1, + }, + result: { + userSelect: 'none', + }, + code: { + fontFamily: 'Roboto Mono !important', + color: props.textColor, + }, + })); + const classes = useStyles(); const { result } = props; return ( diff --git a/app/components/ResultTable.tsx b/app/components/ResultTable.tsx index 90a9675ca10cd8cbdb7418eca66bfe2ef01af666..57638a6dbb93cc492d4434fc3c35859efe745ee1 100644 --- a/app/components/ResultTable.tsx +++ b/app/components/ResultTable.tsx @@ -1,39 +1,60 @@ -/* eslint-disable prefer-template */ import React, { useContext } from 'react'; -import { - makeStyles, - withStyles, - createStyles, - Theme, -} from '@material-ui/core/styles'; -import Table from '@material-ui/core/Table'; -import TableBody from '@material-ui/core/TableBody'; -import TableCell from '@material-ui/core/TableCell'; -import TableContainer from '@material-ui/core/TableContainer'; -import TableHead from '@material-ui/core/TableHead'; -import TableRow from '@material-ui/core/TableRow'; -import Paper from '@material-ui/core/Paper'; -import { ResultValue } from '@mysql/xdevapi'; +import { makeStyles, withStyles, createStyles } from '@material-ui/core/styles'; +import { TableCell, Typography } from '@material-ui/core'; +import { AutoSizer, Column, Table } from 'react-virtualized'; +import { uniqueId } from 'lodash'; +import clsx from 'clsx'; import { RecordingContext } from '../context/RecordingContext'; -// interface TResultProps { -// result: TResult | undefined; -// } - -const useStyles = makeStyles({ - table: { - minWidth: 650, +const useStyles = makeStyles((theme) => ({ + tableWrapper: { + minHeight: 200, + height: '100%', + width: '100%', + resize: 'vertical', + overflowY: 'scroll', + scrollbarWidth: 'none', + paddingBottom: theme.spacing(2), + }, + flexContainer: { + display: 'flex', + alignItems: 'center', + boxSizing: 'border-box', + }, + tableHeader: { + flex: '1 1 !important', + backgroundColor: '#404040', + }, + tableRow: { + display: 'flex', + flex: '1 1 !important', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + '& .ReactVirtualized__Table__rowColumn': { + flex: '1 1 !important', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, + }, + tableRowHover: { + '&:hover': { + backgroundColor: '#404040', + }, }, - container: { - maxHeight: 211, + tableCell: { + flex: '1 1 !important', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', }, -}); +})); -const StyledTableCell = withStyles((theme: Theme) => +const StyledTableCell = withStyles(() => createStyles({ head: { backgroundColor: '#404040', - color: theme.palette.common.white, }, body: { fontSize: 14, @@ -46,66 +67,116 @@ export default function ResultTable() { const classes = useStyles(); - const result = recordingContext?.queryOutput; + const result = recordingContext?.activeRecording?.queryOutput; + + if (!result || !result.results) { + return ( + <Typography style={{ padding: '8px 16px' }} variant="subtitle1"> + No data to show + </Typography> + ); + } - const tableHeader: string[] | undefined = result?.results?.labels; - const tableBody: ResultValue[][] | undefined = result?.results?.values; - const types: number[] | undefined = result?.results?.types; + const { labels, types, values } = result.results; function formatDate(d: Date) { - let month = '' + (d.getMonth() + 1); - let day = '' + d.getDate(); + let month = `${d.getMonth() + 1}`; + let day = `${d.getDate()}`; const year = d.getFullYear(); - if (month.length < 2) month = '0' + month; - if (day.length < 2) day = '0' + day; + if (month.length < 2) month = `0${month}`; + if (day.length < 2) day = `0${day}`; return [year, month, day].join('-'); } - function format(index: number, entry: any): string { + function format(index: number, entry: unknown): string { let datatype = 0; if (types) { datatype = types[index]; } if (datatype === 12) { if (typeof entry === 'number') { - const date = new Date(entry); + const date = new Date(Number(entry)); return formatDate(date); } - const date = new Date(entry); + const date = new Date(String(entry)); return formatDate(date); } - return entry; + return String(entry); + } + + function headerRenderer(label: string, columnIndex: number) { + return ( + <StyledTableCell + key={columnIndex} + component="div" + variant="head" + className={clsx(classes.tableCell, classes.flexContainer)} + style={{ height: 48 }} + > + {label} + </StyledTableCell> + ); + } + + function cellRenderer(rowIndex: number, columnIndex: number) { + return ( + <TableCell + key={`${columnIndex}-${rowIndex}`} + component="div" + variant="body" + className={clsx(classes.tableCell, classes.flexContainer)} + > + {format(columnIndex, values[rowIndex][columnIndex])} + </TableCell> + ); } return ( - <TableContainer className={classes.container} component={Paper}> - <Table className={classes.table} aria-label="simple table" stickyHeader> - <TableHead> - <TableRow> - {tableHeader?.map((column) => ( - <StyledTableCell key={column}>{column}</StyledTableCell> - ))} - </TableRow> - </TableHead> - <TableBody> - {tableBody?.map((row) => ( - <TableRow key={row.join('-')}> - {row.map((cell) => ( - <TableCell - key={tableBody - .indexOf(row) - .toString() - .concat(row.indexOf(cell).toString())} - > - {format(row.indexOf(cell), cell)} - </TableCell> + <div className={classes.tableWrapper}> + <div + style={{ + minHeight: 201, + height: '100%', + width: '100%', + paddingBottom: 1, + }} + > + <AutoSizer> + {({ height, width }) => ( + <Table + height={height} + width={width} + rowHeight={48} + headerHeight={48} + gridStyle={{ + direction: 'inherit', + }} + rowCount={values.length} + estimatedColumnSize={100} + rowGetter={({ index }) => values[Number(index)]} + rowClassName={({ index }) => + clsx(classes.tableRow, classes.flexContainer, { + [classes.tableRowHover]: index !== -1, + }) + } + > + {labels?.map((label, index) => ( + <Column + key={`${label}+${uniqueId()}`} + headerRenderer={() => headerRenderer(label, index)} + headerClassName={classes.tableHeader} + className={classes.flexContainer} + cellRenderer={({ rowIndex }) => cellRenderer(rowIndex, index)} + dataKey={label} + width={100} + /> ))} - </TableRow> - ))} - </TableBody> - </Table> - </TableContainer> + </Table> + )} + </AutoSizer> + </div> + </div> ); } diff --git a/app/components/TreeViewExplainAnalyze.tsx b/app/components/TreeViewExplainAnalyze.tsx index 55261710a27b8893050683a83ee0b01ff94de916..2d80da70e94b33a2379ac5adad8c9e5dab9a589e 100644 --- a/app/components/TreeViewExplainAnalyze.tsx +++ b/app/components/TreeViewExplainAnalyze.tsx @@ -1,32 +1,11 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext } from 'react'; import { makeStyles } from '@material-ui/core/styles'; import TreeView from '@material-ui/lab/TreeView'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import ChevronRightIcon from '@material-ui/icons/ChevronRight'; import TreeItem from '@material-ui/lab/TreeItem'; import { RecordingContext } from '../context/RecordingContext'; - -interface Node { - id: string; - offset: number; - command: string; - cost_est?: number; - rows_est?: number; - time: string; - rows: number; - loops: number; - children: Node[]; -} - -const rootNode: Node = { - id: '0', - offset: -1, - command: '', - time: '', - rows: -1, - loops: -1, - children: [], -}; +import { ExplainAnalyzeNode } from '../../backend/data-processor/types/ExplainAnalyzeNode'; const useStyles = makeStyles({ root: { @@ -34,88 +13,24 @@ const useStyles = makeStyles({ }, }); -/* - Helper function - INPUT: EXPLAIN ANALYZE data (string) - RETURN: List of Node objects - */ -function extractData(data: string) { - rootNode.children = []; - const regexp = /([ ]*)?-> (.+?)( \(cost=([\d.]+) rows=(\d+)\))? \(actual time=([\d.]+) rows=(\d+) loops=(\d+)\)/gm; - const matches = [...data.matchAll(regexp)]; - const nodes: Node[] = [rootNode]; - // Create object for each line. Root has ID 0 - let idIterator = 1; - matches.forEach((element) => { - const node: Node = { - id: idIterator.toString(), - offset: element[1] === undefined ? 0 : element[1].length, - command: element[2], - cost_est: Number(element[4]), - rows_est: Number(element[5]), - time: element[6], - rows: Number(element[7]), - loops: Number(element[8]), - children: [], - }; - nodes.push(node); - idIterator += 1; - }); - return nodes; -} - -/* - INPUT: EXPLAIN ANALYZE data - ACTION: Assigns children of root node, and to all child nodes, based on offset and order - RETURN: List of Node objects -*/ -function buildTree(explainAnalyzeData: string) { - // Extract relevant data - const nodes: Node[] = extractData(explainAnalyzeData); - // Assign children of each object - // Iterate through all objects - for (let i = 1; i < nodes.length; i += 1) { - // Set current node as child if its offset is smaller - if (nodes[i - 1].offset < nodes[i].offset) { - nodes[i - 1].children.push(nodes[i]); - } else { - // If current node's offset is smaller then it cannot be child of the previous node - // Iterate backwards from current node until a node with smaller offset has been reached - for (let j = i - 1; i > 0; j -= 1) { - if (nodes[j].offset < nodes[i].offset) { - nodes[j].children.push(nodes[i]); - break; - } - } - } - } -} - export default function RecursiveTreeView() { const recordingContext = useContext(RecordingContext); + const data = recordingContext?.activeRecording?.explainAnalyzeTree; const classes = useStyles(); - const [previousData, setPreviousData] = useState(''); - const data = JSON.stringify(recordingContext?.queryOutput, undefined, 2); - if (!data) return null; - // TODO: Only run when EXPLAIN ANALYZE detected - if (data !== previousData) { - buildTree(data); - setPreviousData(data); - } - const RenderTree = (nodes: Node) => ( - <TreeItem key={nodes.id} nodeId={nodes.id} label={nodes.command}> + const RenderTree = (nodes: ExplainAnalyzeNode) => ( + <TreeItem key={nodes.id} nodeId={nodes.id} label={nodes.name}> {Array.isArray(nodes.children) ? nodes.children.map((node) => RenderTree(node)) : null} </TreeItem> ); - const RenderTreeWithoutRoot = (root: Node) => { + const RenderTreeWithoutRoot = (root?: ExplainAnalyzeNode) => { // Renders only the children of root const elements: JSX.Element[] = []; - root.children.forEach((child) => { + root?.children.forEach((child) => { elements.push(RenderTree(child)); }); return elements; @@ -129,7 +44,7 @@ export default function RecursiveTreeView() { defaultExpanded={['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']} defaultExpandIcon={<ChevronRightIcon />} > - {[RenderTreeWithoutRoot(rootNode)]} + {[RenderTreeWithoutRoot(data)]} </TreeView> ); } diff --git a/app/containers/Dashboard.tsx b/app/containers/Dashboard.tsx index 9142121b71a68a3ae4be50c081f08d8dcca83271..c719061ba04b177d61ccdf1430d987d0fcf23bf8 100644 --- a/app/containers/Dashboard.tsx +++ b/app/containers/Dashboard.tsx @@ -1,14 +1,16 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Button, Dialog, makeStyles } from '@material-ui/core'; import { useLocation, useHistory } from 'react-router'; import PanelGroup from 'react-panelgroup'; import QueryRecorder from '../components/QueryRecorder'; -import { LoadingContext } from '../context/LoadingContext'; import routes from '../routes'; import Login from '../components/Login'; import { LoginDetailsContext } from '../context/LoginDetailsContext'; import { RecordingProvider } from '../context/RecordingContext'; import ResultView from './ResultView'; +import Recordings from '../components/Recordings'; +import ErrorView from '../components/ErrorView'; +import IDInfo from '../components/IDInfo'; const useStyles = makeStyles((theme) => ({ wrapper: { @@ -25,28 +27,30 @@ const useStyles = makeStyles((theme) => ({ width: '100%', overflowY: 'auto', }, + item: { + margin: `${theme.spacing(1)}px ${theme.spacing(0)}`, + }, })); export default function Dashboard() { const [changeConnectionOpen, setChangeConnectionOpen] = useState(false); - const loadingContext = useContext(LoadingContext); const loginDetailsContext = useContext(LoginDetailsContext); const location = useLocation(); const history = useHistory(); - if ( - location.pathname === routes.dashboard && - !loadingContext?.connected && - !loadingContext?.connecting && - !changeConnectionOpen - ) { - history.push(routes.login); - } + useEffect(() => { + if ( + location.pathname === routes.dashboard && + !changeConnectionOpen && + !loginDetailsContext?.loginDetails + ) { + history.push(routes.login); + } + }, []); function openConnectionDialog() { - loginDetailsContext?.setLoginDetails(undefined); setChangeConnectionOpen(true); } @@ -64,7 +68,7 @@ export default function Dashboard() { ]} > <div className={classes.column}> - <div> + <div className={classes.item}> <Button variant="outlined" color="primary" @@ -79,7 +83,18 @@ export default function Dashboard() { <Login onConnect={() => setChangeConnectionOpen(false)} /> </Dialog> </div> - <QueryRecorder /> + <div className={classes.item}> + <IDInfo /> + </div> + <div className={classes.item}> + <QueryRecorder /> + </div> + <div className={classes.item}> + <ErrorView /> + </div> + <div className={classes.item}> + <Recordings /> + </div> </div> <div className={classes.column}> <ResultView /> diff --git a/app/containers/ResultView.tsx b/app/containers/ResultView.tsx index 27a909d41af83256334fd2ceec7e67d6d61b8ac2..d82ef6212ff009785bec41fe9637ab70ffe88fbe 100644 --- a/app/containers/ResultView.tsx +++ b/app/containers/ResultView.tsx @@ -1,33 +1,58 @@ -/* eslint-disable react/jsx-wrap-multilines */ import { makeStyles, Checkbox, FormControlLabel, Paper, + Typography, + Tooltip, } from '@material-ui/core'; import React, { useContext, useState } from 'react'; +import { FlameGraph } from 'react-flame-graph'; import MemoryChart from '../components/MemoryChart'; import OptimizerTrace from '../components/OptimizerTrace'; import ResultTable from '../components/ResultTable'; import { RecordingContext } from '../context/RecordingContext'; import TreeViewExplainAnalyze from '../components/TreeViewExplainAnalyze'; +import ExplainAnalyzeTable from '../components/ExplainAnalyzeTable'; +import { ExplainAnalyzeNode } from '../../backend/data-processor/types/ExplainAnalyzeNode'; const useStyles = makeStyles((theme) => ({ wrapper: { display: 'flex', flexFlow: 'column nowrap', alignContent: 'center', + paddingBottom: 300, }, item: { margin: `${theme.spacing(1)}px ${theme.spacing(0)}`, }, + title: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, })); +const emptyNode: ExplainAnalyzeNode = { + id: '0', + offset: -1, + name: 'root', + time: '', + rows: -1, + value: 0, + timeFirstRow: 0, + timeAllRows: 0, + loops: -1, + children: [], +}; + export default function ResultView() { const [showMemoryChart, setShowMemoryChart] = useState(true); const [showOptimizerTrace, setShowOptimizerTrace] = useState(false); const [showResultTable, setShowResultTable] = useState(false); const [showExplainAnalyze, setShowExplainAnalyze] = useState(false); + const [selectedNode, setSelectedNode] = useState(emptyNode); + const [hoveredNode, setHoveredNode] = useState(emptyNode); const recordingContext = useContext(RecordingContext); @@ -35,6 +60,15 @@ export default function ResultView() { return ( <div className={classes.wrapper} style={{ minWidth: '200px' }}> + <div className={classes.item}> + <Tooltip title={recordingContext?.activeRecording?.label || ''}> + <Typography variant="h4" className={classes.title}> + {`Results for recording ${ + recordingContext?.activeRecording?.label || '' + }`} + </Typography> + </Tooltip> + </div> <div> <FormControlLabel label="Show result table" @@ -47,7 +81,7 @@ export default function ResultView() { } /> <FormControlLabel - label="Show memory chart" + label="Show memory performance" control={ <Checkbox color="primary" @@ -66,16 +100,20 @@ export default function ResultView() { /> } /> - <FormControlLabel - label="Show explain analyze" - control={ - <Checkbox - color="primary" - checked={showExplainAnalyze} - onChange={(event) => setShowExplainAnalyze(event.target.checked)} - /> - } - /> + {recordingContext?.activeRecording?.explainAnalyze && ( + <FormControlLabel + label="Show explain analyze" + control={ + <Checkbox + color="primary" + checked={showExplainAnalyze} + onChange={(event) => + setShowExplainAnalyze(event.target.checked) + } + /> + } + /> + )} </div> {showResultTable && ( <Paper className={classes.item}> @@ -85,16 +123,42 @@ export default function ResultView() { {showMemoryChart && ( <Paper className={classes.item} - style={{ paddingBottom: 80, paddingTop: 8 }} + style={{ paddingBottom: 8, paddingTop: 8 }} > <MemoryChart /> </Paper> )} - {showOptimizerTrace && recordingContext?.optimizerTrace && ( + {showOptimizerTrace && recordingContext?.activeRecording?.optimizerTrace && ( <Paper className={classes.item} style={{ padding: 8 }}> <OptimizerTrace /> </Paper> )} + {showExplainAnalyze && + recordingContext?.activeRecording?.explainAnalyzeTree && ( + <Paper className={classes.item}> + <FlameGraph + data={recordingContext?.activeRecording?.explainAnalyzeTree} + height={200} + width={800} + disableDefaultTooltips + onChange={(node: { + source: React.SetStateAction<ExplainAnalyzeNode>; + }) => { + setSelectedNode(node.source); + }} + onMouseOver={( + event: any, + itemData: React.SetStateAction<ExplainAnalyzeNode> + ) => { + setHoveredNode(itemData); + }} + onMouseOut={(event: any, itemData: any) => { + setHoveredNode(selectedNode); + }} + /> + <ExplainAnalyzeTable node={hoveredNode} /> + </Paper> + )} {showExplainAnalyze && ( <Paper className={classes.item}> <TreeViewExplainAnalyze /> diff --git a/app/context/LoadingContext.tsx b/app/context/LoadingContext.tsx deleted file mode 100644 index 6b08e087f83b7ad0e92c071eae4a821082e14e3d..0000000000000000000000000000000000000000 --- a/app/context/LoadingContext.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useState, createContext } from 'react'; - -type ChildrenType = { - children: React.ReactNode; -}; - -type ContextType = { - connecting: boolean; - setConnecting: (value: boolean) => void; - connected: boolean; - setConnected: (value: boolean) => void; - querying: boolean; - setQuerying: (value: boolean) => void; -}; - -export const LoadingContext = createContext<ContextType | undefined>(undefined); - -export const LoadingProvider = ({ children }: ChildrenType) => { - const [connecting, setConnecting] = useState(false); - const [connected, setConnected] = useState(false); - const [querying, setQuerying] = useState(false); - - return ( - <LoadingContext.Provider - value={{ - connecting, - setConnecting, - connected, - setConnected, - querying, - setQuerying, - }} - > - {children} - </LoadingContext.Provider> - ); -}; diff --git a/app/context/RecordingContext.tsx b/app/context/RecordingContext.tsx index b64e3d9f270ed8054b8cff117755305a77f30b5a..6eae3d1d8ae259d0009a81d3e6ca384f6aecd8a9 100644 --- a/app/context/RecordingContext.tsx +++ b/app/context/RecordingContext.tsx @@ -1,24 +1,18 @@ -import { ResultValue } from '@mysql/xdevapi'; import React, { useState, createContext } from 'react'; -import { - ChartData, - StageTimes, -} from '../../backend/data-processor/DataProcessor'; -import { QueryOutput } from '../QueryOutput'; +import { Recording } from '../types/Recording'; +import { RecordingListItem } from '../types/RecordingListItem'; type ChildrenType = { children: React.ReactNode; }; type ContextType = { - chartData: ChartData | undefined; - setChartData: (value: ChartData | undefined) => void; - stageTimes: StageTimes | undefined; - setStageTimes: (value: StageTimes | undefined) => void; - optimizerTrace: ResultValue[][] | undefined; - setOptimizerTrace: (value: ResultValue[][] | undefined) => void; - queryOutput: QueryOutput | undefined; - setQueryOutput: (value: QueryOutput | undefined) => void; + activeRecording?: Recording; + setActiveRecording: (value?: Recording) => void; + recordings?: Recording[]; + setRecordings: (value?: Recording[]) => void; + recordingListItems?: RecordingListItem[]; + setRecordingListItems: (value?: RecordingListItem[]) => void; }; export const RecordingContext = createContext<ContextType | undefined>( @@ -26,27 +20,23 @@ export const RecordingContext = createContext<ContextType | undefined>( ); export const RecordingProvider = ({ children }: ChildrenType) => { - const [chartData, setChartData] = useState<ChartData | undefined>(undefined); - const [stageTimes, setStageTimes] = useState<StageTimes | undefined>( - undefined - ); - const [optimizerTrace, setOptimizerTrace] = useState< - ResultValue[][] | undefined - >(undefined); - const [queryOutput, setQueryOutput] = useState<QueryOutput | undefined>( + const [activeRecording, setActiveRecording] = useState<Recording | undefined>( undefined ); + const [recordings, setRecordings] = useState<Recording[] | undefined>([]); + const [recordingListItems, setRecordingListItems] = useState< + RecordingListItem[] | undefined + >([]); + return ( <RecordingContext.Provider value={{ - chartData, - setChartData, - stageTimes, - setStageTimes, - optimizerTrace, - setOptimizerTrace, - queryOutput, - setQueryOutput, + activeRecording, + setActiveRecording, + recordings, + setRecordings, + recordingListItems, + setRecordingListItems, }} > {children} diff --git a/app/package.json b/app/package.json index 44aac567f561e3768c9b6a6891f61250672b2248..1241e56ab6d00bdc5cd12cab7348fa55d2833ae2 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "mysql-query-profiler", "productName": "MySQL Query Profiler", - "version": "0.2.0", + "version": "0.3.0", "description": "A profiler for MySQL queries using Electron and React", "main": "./main.prod.js", "author": { diff --git a/app/types/Recording.ts b/app/types/Recording.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e9aed6268bd87345fd161235d8d9494d7e60bb7 --- /dev/null +++ b/app/types/Recording.ts @@ -0,0 +1,19 @@ +import { ResultValue } from '@mysql/xdevapi'; +import { ChartColors } from '../../backend/data-processor/types/ChartColors'; +import { ChartData } from '../../backend/data-processor/types/ChartData'; +import { ExplainAnalyzeNode } from '../../backend/data-processor/types/ExplainAnalyzeNode'; +import { StageTimes } from '../../backend/data-processor/types/StageTimes'; +import { QueryOutput } from '../QueryOutput'; + +export interface Recording { + chartData?: ChartData; + stageTimes?: StageTimes; + chartColors?: ChartColors; + optimizerTrace?: ResultValue[][]; + queryOutput?: QueryOutput; + explainAnalyze: boolean; + explainAnalyzeTree: ExplainAnalyzeNode; + error?: string; + label: string; // On both in order to avoid having to create an 'activeRecordingListItem' + uuid: string; +} diff --git a/app/types/RecordingListItem.ts b/app/types/RecordingListItem.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc61436c2d49e3a2665e798e3f41d3fb30c318eb --- /dev/null +++ b/app/types/RecordingListItem.ts @@ -0,0 +1,8 @@ +export interface RecordingListItem { + uuid: string; + query: string; + elapsed: number; + label: string; + color: string; + viewing: boolean; +} diff --git a/babel.config.js b/babel.config.js index efad5dcd9e4a0e2bdd617f3a0e9f91fce62ae0da..c4cdae93b8881e927475047612c055d14d67ca12 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,8 +2,6 @@ const developmentEnvironments = ['development', 'test']; -const developmentPlugins = [require('react-hot-loader/babel')]; - const productionPlugins = [ require('babel-plugin-dev-expression'), @@ -56,7 +54,7 @@ module.exports = (api) => { [require('@babel/plugin-proposal-class-properties'), { loose: true }], require('@babel/plugin-proposal-json-strings'), - ...(development ? developmentPlugins : productionPlugins), + ...(development ? [] : productionPlugins), ], }; }; diff --git a/backend/data-processor/DataProcessor.ts b/backend/data-processor/DataProcessor.ts index 2cb653aa1b5b75ac61bc4955e008564befbe5e6d..c3b0b6be4509db3c0499611fd87d0ffc7fbe4e84 100644 --- a/backend/data-processor/DataProcessor.ts +++ b/backend/data-processor/DataProcessor.ts @@ -1,33 +1,15 @@ /* eslint-disable class-methods-use-this */ import { ResultValue } from '@mysql/xdevapi'; +import lodash from 'lodash'; import { Result } from '../utils/mysql'; +import { ChartColors } from './types/ChartColors'; +import { ChartData } from './types/ChartData'; +import { ExplainAnalyzeNode } from './types/ExplainAnalyzeNode'; +import { MemoryPerformance } from './types/MemoryPerformance'; +import { MemoryPerformanceDataPoint } from './types/MemoryPerformanceDataPoint'; +import { StageTimes } from './types/StageTimes'; -export interface MemoryPerformanceTimeline { - eventName: string; - isBytesUsedZero: boolean; - times: Array<number>; - bytesUsed: Array<number>; -} - -export interface MemoryPerformanceDataPoint { - relative_time: number; - [eventName: string]: number; -} - -export interface StageTimePoint { - stage: string; - source: string; - startTime: number; - endTime: number; -} - -export type MemoryPerformance = Array<MemoryPerformanceTimeline>; - -export type ChartData = Array<MemoryPerformanceDataPoint>; - -export type StageTimes = Array<StageTimePoint>; - -export class DataProcessor { +export default class DataProcessor { static processMemoryPerformance(performanceData?: Result) { // TODO: Add more restrictive tests if (performanceData && performanceData.labels && performanceData.values) { @@ -77,7 +59,6 @@ export class DataProcessor { timeLine.isBytesUsedZero = timeLine.bytesUsed.reduce((a, b) => a + b, 0) === 0; }); - console.log(result); return DataProcessor.getChartData(result); } throw new Error('DataProcessor: performanceData had wrong format'); @@ -99,8 +80,7 @@ export class DataProcessor { }); result.push(memoryPerformanceDataPoint); }); - - console.log(result); + DataProcessor.log(`Memory data points: ${result.length}`); return result; } @@ -116,7 +96,6 @@ export class DataProcessor { endTime: Number(resultValue[3]), }); }); - console.log(result); return result; } throw new Error('DataProcessor: stageTimesRaw had wrong format'); @@ -126,11 +105,6 @@ export class DataProcessor { let traceResult; if (optimizerTrace && optimizerTrace.values) { traceResult = optimizerTrace; - console.log( - `Dataprocessor.processOptimizerTrace: Traceresult: ${JSON.stringify( - traceResult - )}` - ); if (traceResult.values.length > 0 && traceResult.values[0].length > 1) { traceResult.values[0][1] = JSON.parse(String(traceResult.values[0][1])); } @@ -138,4 +112,127 @@ export class DataProcessor { } throw new Error('DataProcessor: optimizerTrace had wrong format'); } + + static assignColor(key: string) { + const tempObject: ChartColors = {}; + tempObject[key] = `#${(0x1000000 + Math.random() * 0xffffff) + .toString(16) + .substr(1, 6)}`; + return tempObject; + } + + static processChartColors(chartData: ChartData) { + const chartColors: ChartColors = {}; + const dataKeys = lodash.keys(chartData[0]).slice(1); + lodash.forEach(dataKeys, (key) => + lodash.assign(chartColors, DataProcessor.assignColor(key)) + ); + return chartColors; + } + + // -> Limit: 10 row(s) (actual time=0.098..0.103 rows=10 loops=1) + // -> Table scan on employees (cost=30192.25 rows=299600) (actual time=0.095..0.099 rows=10 loops=1) + + // " Table scan on ints (actual time=0.006..0.029 rows=5 loops=1)" + // " Materialize (actual time=2.662..2.697 rows=5 loops=1) " + // " Rows fetched before execution (actual time=0.00 + static processExplainAnalyze(explainAnalyze?: Result) { + if ( + !explainAnalyze || + explainAnalyze?.values.length === 0 || + explainAnalyze.values[0].length === 0 + ) { + throw Error('DataProcessor: Explain analyze had no values'); + } + const explainText = String(explainAnalyze?.values[0][0]); + const rootNode: ExplainAnalyzeNode = { + id: '0', + offset: -1, + name: 'root', + time: '', + rows: -1, + value: 0, + timeFirstRow: 0, + timeAllRows: 0, + loops: -1, + children: [], + }; + const regexp = /([ ]*)?-> (.+?)( \(cost=([\d.]+) rows=(\d+)\))? \(actual time=([\d.]+) rows=(\d+) loops=(\d+)\)/gm; + const regexpTime = /(\d*\.?\d*)\.\.(\d*\.?\d*)/; + const matches = [...explainText.matchAll(regexp)]; + const nodes: ExplainAnalyzeNode[] = [rootNode]; + // Create object for each line. Root has ID 0 + let idIterator = 1; + matches.forEach((element) => { + const timeResult = element[6].match(regexpTime); + if (timeResult != null) { + const timeFirst = Number(timeResult[1]) * Number(element[8]); + const timeAll = Number(timeResult[2]) * Number(element[8]); + + const node: ExplainAnalyzeNode = { + id: idIterator.toString(), + offset: element[1] === undefined ? 0 : element[1].length, + name: element[2], + cost_est: Number(element[4]), + rows_est: Number(element[5]), + time: element[6], + value: timeAll, + timeFirstRow: timeFirst, + timeAllRows: timeAll, + rows: Number(element[7]), + loops: Number(element[8]), + children: [], + }; + nodes.push(node); + idIterator += 1; + } + }); + + // Assign children of each object + // Iterate through all objects + for (let i = 1; i < nodes.length; i += 1) { + // Set current node as child if its offset is smaller + if (nodes[i - 1].offset < nodes[i].offset) { + nodes[i - 1].children.push(nodes[i]); + } else { + // If current node's offset is smaller then it cannot be child of the previous node + // Iterate backwards from current node until a node with smaller offset has been reached + for (let j = i - 1; i > 0; j -= 1) { + if (nodes[j].offset < nodes[i].offset) { + nodes[j].children.push(nodes[i]); + break; + } + } + } + } + + const node = DataProcessor.fixNodeTimes(nodes[0]); + + DataProcessor.log(explainText); + return node; + } + + /** + * Makes sure total time is represented accurately in the flame graph + * If a node has smaller total time than its children, the total time of its children are added + * @param node + */ + static fixNodeTimes(node: ExplainAnalyzeNode) { + if (!node.children.length) { + return node; + } + let sumChildren = 0; + node.children.forEach((element) => { + const nextNode = DataProcessor.fixNodeTimes(element); + sumChildren += nextNode.value; + }); + if (node.value < sumChildren) { + node.value += sumChildren; + } + return node; + } + + static log(message: string) { + console.log(`DataProcessor: ${String(message)}`); + } } diff --git a/backend/data-processor/types/ChartColors.ts b/backend/data-processor/types/ChartColors.ts new file mode 100644 index 0000000000000000000000000000000000000000..94914f6bfec5ce87a0359cc03cb3a06f2215dc49 --- /dev/null +++ b/backend/data-processor/types/ChartColors.ts @@ -0,0 +1,3 @@ +export interface ChartColors { + [key: string]: string; +} diff --git a/backend/data-processor/types/ChartData.ts b/backend/data-processor/types/ChartData.ts new file mode 100644 index 0000000000000000000000000000000000000000..1485bfbe52b6361d2c9d11d487a51ecd8d798431 --- /dev/null +++ b/backend/data-processor/types/ChartData.ts @@ -0,0 +1,3 @@ +import { MemoryPerformanceDataPoint } from './MemoryPerformanceDataPoint'; + +export type ChartData = Array<MemoryPerformanceDataPoint>; diff --git a/backend/data-processor/types/ExplainAnalyzeNode.ts b/backend/data-processor/types/ExplainAnalyzeNode.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e5706165df24e59e6e4021758cb2d11b3b0d14c --- /dev/null +++ b/backend/data-processor/types/ExplainAnalyzeNode.ts @@ -0,0 +1,15 @@ +export interface ExplainAnalyzeNode { + id: string; + offset: number; + // Endret fra command til name, siden koponenten krever det + name: string; + cost_est?: number; + rows_est?: number; + time: string; + value: number; + timeFirstRow: number; + timeAllRows: number; + rows: number; + loops: number; + children: ExplainAnalyzeNode[]; +} diff --git a/backend/data-processor/types/MemoryPerformance.ts b/backend/data-processor/types/MemoryPerformance.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8e93473ba2ecff420758169ead5c1c01b85d831 --- /dev/null +++ b/backend/data-processor/types/MemoryPerformance.ts @@ -0,0 +1,3 @@ +import { MemoryPerformanceTimeline } from './MemoryPerformanceTimeline'; + +export type MemoryPerformance = Array<MemoryPerformanceTimeline>; diff --git a/backend/data-processor/types/MemoryPerformanceDataPoint.ts b/backend/data-processor/types/MemoryPerformanceDataPoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8c4832b60f0707c4c3a8f9238557b8f3d18fe38 --- /dev/null +++ b/backend/data-processor/types/MemoryPerformanceDataPoint.ts @@ -0,0 +1,4 @@ +export interface MemoryPerformanceDataPoint { + relative_time: number; + [eventName: string]: number; +} diff --git a/backend/data-processor/types/MemoryPerformanceTimeline.ts b/backend/data-processor/types/MemoryPerformanceTimeline.ts new file mode 100644 index 0000000000000000000000000000000000000000..437bdbcb5e15780e2bc1abb7c43c7ab94a790e20 --- /dev/null +++ b/backend/data-processor/types/MemoryPerformanceTimeline.ts @@ -0,0 +1,6 @@ +export interface MemoryPerformanceTimeline { + eventName: string; + isBytesUsedZero: boolean; + times: Array<number>; + bytesUsed: Array<number>; +} diff --git a/backend/data-processor/types/StageTime.ts b/backend/data-processor/types/StageTime.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a6215914a5a186c795a4e58476e97f31924f380 --- /dev/null +++ b/backend/data-processor/types/StageTime.ts @@ -0,0 +1,6 @@ +export interface StageTime { + stage: string; + source: string; + startTime: number; + endTime: number; +} diff --git a/backend/data-processor/types/StageTimes.ts b/backend/data-processor/types/StageTimes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b9215c9def496ffba0c7859e33a95671090d187 --- /dev/null +++ b/backend/data-processor/types/StageTimes.ts @@ -0,0 +1,3 @@ +import { StageTime } from './StageTime'; + +export type StageTimes = Array<StageTime>; diff --git a/backend/recorder/SqlAgent.ts b/backend/recorder/SqlAgent.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bc7eed2007d9833766d8a0ca536cdc3ad4b5290 --- /dev/null +++ b/backend/recorder/SqlAgent.ts @@ -0,0 +1,84 @@ +import { Session, Client } from '@mysql/xdevapi'; +import { createSession, runQuery } from '../utils/mysql'; +import SqlMonitor from './SqlMonitor'; + +/** + * A class responsible for performing actions when Runner and Monitor are busy. + * Example actions: + * - Aborting a query + * - Checking whether a procedure is running + * - Checking the health of the other sessions + */ +export default class SqlAgent { + session?: Session; + + connectionID?: string; + + threadID?: string; + + /** + * Connects the Agent to a session through a given client + * @param client + * @param database + */ + async connect(client?: Client, database?: string) { + this.session = await createSession(client, database); + let resultSet = await this.executeQuery(`SELECT connection_id();`); + this.connectionID = String(resultSet?.results?.values[0]); + resultSet = await this.executeQuery( + `select thread_id from performance_schema.threads where processlist_id=${this.connectionID}` + ); + this.threadID = String(resultSet?.results?.values[0]); + console.log( + `SqlAgent: ConnectionID: ${this.connectionID}, ThreadID: ${this.threadID}` + ); + } + + /** + * Waits for a session with the given connectionID to begin running the specified query + * @param connectionID + * @param query + */ + async waitRunning(threadID: string) { + await this.executeQuery('DROP PROCEDURE IF EXISTS wait_running;'); + await this.executeQuery(` + CREATE PROCEDURE wait_running( + IN thd_id BIGINT UNSIGNED + ) + BEGIN + DECLARE state VARCHAR(16); + REPEAT + SET state = (SELECT PROCESSLIST_COMMAND FROM performance_schema.threads WHERE THREAD_ID=thd_id); + UNTIL state = 'Query' END REPEAT; + END + `); + await this.executeQuery(`CALL wait_running(${threadID});`); + } + + async checkThreadState(threadID: string) { + return this.executeQuery( + `SELECT PROCESSLIST_COMMAND FROM performance_schema.threads WHERE THREAD_ID=${threadID}` + ); + } + + async forceSleepMonitor( + sqlMonitor: SqlMonitor, + client?: Client, + database?: string + ) { + await this.killQuery(sqlMonitor.connectionID || ''); + await sqlMonitor.connect(client, database); + } + + async killQuery(connectionID: string) { + return this.executeQuery(`KILL QUERY ${connectionID}`); + } + + async killConnection(connectionID: string) { + return this.executeQuery(`KILL CONNECTION ${connectionID};`); + } + + async executeQuery(query: string) { + return runQuery(query, this.session); + } +} diff --git a/backend/recorder/SqlManager.ts b/backend/recorder/SqlManager.ts index ab5ca284fb3d55bd6eea8783e175b91063523e65..b3d37d1a560fc38754f75ac93976000a3abacfd6 100644 --- a/backend/recorder/SqlManager.ts +++ b/backend/recorder/SqlManager.ts @@ -1,9 +1,25 @@ -import { URI, Client } from '@mysql/xdevapi'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { URI, Client, ResultValue } from '@mysql/xdevapi'; import FileIO from '../utils/FileIO'; import SqlRunner from './SqlRunner'; import SqlMonitor from './SqlMonitor'; -import { closeConnection } from '../utils/mysql'; -import { DataProcessor } from '../data-processor/DataProcessor'; +import { closeConnection, Result } from '../utils/mysql'; +import { ChartData } from '../data-processor/types/ChartData'; +import { StageTimes } from '../data-processor/types/StageTimes'; +import DataProcessor from '../data-processor/DataProcessor'; +import SqlAgent from './SqlAgent'; +import { ChartColors } from '../data-processor/types/ChartColors'; +import { ExplainAnalyzeNode } from '../data-processor/types/ExplainAnalyzeNode'; + +export interface RawRecording { + result?: { results?: Result; error?: any }; + memoryPerformance?: ChartData; + chartColors?: ChartColors; + error?: any; + optimizerTrace?: ResultValue[][]; + stageTimes?: StageTimes; + explainAnalyze?: ExplainAnalyzeNode; +} export default class SqlManager { connection?: URI; @@ -16,38 +32,107 @@ export default class SqlManager { monitor?: SqlMonitor; - async connect(connection?: URI, database?: string) { + agent?: SqlAgent; + + userQueryIsRunning = false; + + cancel = false; + + async connect(connection?: URI, database?: string, savePassword?: boolean) { this.connection = connection; this.database = database; this.runner = new SqlRunner(); this.client = await this.runner.connect(connection, database); this.monitor = new SqlMonitor(); await this.monitor.connect(this.client, database); - await FileIO.saveLoginDetails({ ...connection, database }); + this.agent = new SqlAgent(); + await this.agent.connect(this.client, database); + await FileIO.saveLoginDetails({ ...connection, database, savePassword }); } - async record(query: string, explainAnalyze?: boolean) { - if (!this.runner || !this.monitor || !this.runner.connectionID) { + async record( + query: string, + setUserQueryIsRunning: (running: boolean) => void, + monitorTimeStep: number, + explainAnalyze?: boolean, + stateCallback?: (state: string) => void + ): Promise<RawRecording> { + if ( + !this.runner || + !this.monitor || + !this.runner.connectionID || + !this.runner.threadID || + !this.monitor.connectionID || + !this.monitor.threadID || + !this.agent + ) { throw new Error( - 'SqlManager.record: Runner, monitor or connectionID was missing. Maybe you forgot to call connect()?' + 'SqlManager.record: Runner, monitor, agent or connectionID was missing. Maybe you forgot to call connect()?' ); } - await this.monitor.prepareMonitoring(this.runner.connectionID); - this.monitor.monitorConnection(this.runner.connectionID); + const stageIgnoreBeforeResult = await this.monitor.getStageIgnoreBeforeResult( + this.runner.threadID + ); + const monitoring = this.monitor.monitorConnection( + this.runner.connectionID, + stageIgnoreBeforeResult, + monitorTimeStep + ); - // Wait to make sure monitor is ready for runner to execute query - // TODO: Find a more optimal solution than waiting 1 second - // eslint-disable-next-line compat/compat - await new Promise((resolve) => setTimeout(resolve, 1000)); + stateCallback?.call(undefined, 'Waiting for monitor to be ready'); + await this.agent.waitRunning(this.monitor.threadID); + stateCallback?.call(undefined, 'Running query'); + setUserQueryIsRunning(true); + const t0 = performance.now(); const result = await this.runner.executeQuery(query, explainAnalyze); + const t1 = performance.now(); + setUserQueryIsRunning(false); + + stateCallback?.call(undefined, 'Query done'); + const queryTime = t1 - t0; + SqlManager.log( + `Query took ${queryTime} ms, required at least ${ + monitorTimeStep * 1000 * 2 + } ms` + ); + + if (this.cancel) { + stateCallback?.call(undefined, 'Cancel detected, reconnecting'); + await this.connect(this.connection, this.database); + this.cancel = false; + return { error: 'cancelled' }; + } + + // Handling error, aborting if (result?.error) { + stateCallback?.call(undefined, 'Error detected, aborting monitoring'); await this.reset(); - return { result, memoryPerformance: undefined, error: result.error }; + stateCallback?.call(undefined, 'Getting optimizer trace'); + const optimizerTrace = await this.runner.getOptimizerTrace(); + stateCallback?.call(undefined, ''); + return { + result, + optimizerTrace: DataProcessor.processOptimizerTrace( + optimizerTrace.results + ), + error: result.error, + explainAnalyze: DataProcessor.processExplainAnalyze(result.results), + }; } + stateCallback?.call(undefined, 'Getting optimizer trace'); const optimizerTrace = await this.runner.getOptimizerTrace(); + + stateCallback?.call( + undefined, + 'Waiting for monitoring to finish (may take up to 5 minutes)' + ); + await monitoring; + stateCallback?.call(undefined, 'Getting memory performance'); const memoryPerformance = await this.monitor.dumpData(); + stateCallback?.call(undefined, 'Getting stage times'); const stageTimes = await this.monitor.getStageTimes(); + stateCallback?.call(undefined, ''); return { result, optimizerTrace: DataProcessor.processOptimizerTrace( @@ -57,20 +142,56 @@ export default class SqlManager { memoryPerformance.results ), stageTimes: DataProcessor.processStageTimes(stageTimes.results), + chartColors: DataProcessor.processChartColors( + DataProcessor.processMemoryPerformance(memoryPerformance.results) + ), error: undefined, + explainAnalyze: DataProcessor.processExplainAnalyze(result.results), }; } async reset() { - if (!this.runner || !this.monitor || !this.monitor.connectionID) { - throw new Error( - `SqlManager.reset: Runner, monitor or connectionID was missing. Invalid attempt at reset?` + await this.disconnect(); + await this.connect(this.connection, this.database); + } + + async disconnect() { + if ( + !this.runner || + !this.runner.connectionID || + !this.monitor || + !this.monitor.connectionID || + !this.agent + ) { + console.error( + `SqlManager.disconnect: Runner ${Boolean( + this.runner + )}, Monitor ${Boolean(this.monitor)}, Agent ${Boolean(this.agent)}` ); + return; } - // Kill monitor manually from runner as client.close won't interfere if monitor has called its procedure - await this.runner.executeQuery(`kill ${this.monitor.connectionID};`); + // Kill monitor manually from agent as client.close won't interfere if monitor has called its procedure + await this.agent?.killConnection(this.monitor.connectionID); + await this.agent?.killConnection(this.runner.connectionID); await closeConnection(this.client); - await this.connect(this.connection, this.database); + SqlManager.log('disconnect(): Disconnected successfully'); + } + + async cancelQuery() { + this.cancel = true; + await this.disconnect(); + } + + async checkThreadStates() { + const runnerState = await this.agent?.checkThreadState( + this.runner?.threadID || '' + ); + const monitorState = await this.agent?.checkThreadState( + this.monitor?.threadID || '' + ); + SqlManager.log( + `Runner: ${runnerState?.results?.values}\nMonitor: ${monitorState?.results?.values}` + ); } async executeQuery(query: string) { @@ -78,4 +199,8 @@ export default class SqlManager { const result = await this.runner.executeQuery(query); return result; } + + static log(message: any) { + console.log(`SqlManager: ${JSON.stringify(message)}`); + } } diff --git a/backend/recorder/SqlMonitor.ts b/backend/recorder/SqlMonitor.ts index 6c9af3285bc41aacbf7335c430bbf01fe4096788..9065b624516cbca444a5b4be7713ea934287c548 100644 --- a/backend/recorder/SqlMonitor.ts +++ b/backend/recorder/SqlMonitor.ts @@ -1,60 +1,55 @@ import { Session, Client } from '@mysql/xdevapi'; import { createSession, runQuery } from '../utils/mysql'; +/** + * A class responsible for monitoring the resource usage of another session + */ export default class SqlMonitor { session?: Session; connectionID?: string; + threadID?: string; + async connect(client?: Client, database?: string) { + SqlMonitor.log('Connecting'); this.session = await createSession(client, database); - const resultSet = await runQuery(`SELECT connection_id();`, this.session); + let resultSet = await this.executeQuery(`SELECT connection_id();`); this.connectionID = String(resultSet?.results?.values[0]); - console.log(`SqlMonitor: ConnectionID: ${this.connectionID}`); - } - - async prepareMonitoring(connectionID: string) { - await this.enableStageLogging(); - const stageIgnoreBeforeResult = await runQuery( - `SELECT MAX(TIMER_END) FROM performance_schema.events_stages_history_long WHERE THREAD_ID=(SELECT THREAD_ID FROM performance_schema.threads WHERE PROCESSLIST_ID=${connectionID});`, - this.session - ); - await this.createMonitorProcedure( - Number(stageIgnoreBeforeResult.results?.values[0]) + resultSet = await this.executeQuery( + `SELECT thread_id FROM performance_schema.threads WHERE processlist_id=${this.connectionID}` ); + this.threadID = String(resultSet?.results?.values[0]); + await this.enableStageLogging(); + await this.createMonitorProcedure(); } async enableStageLogging() { // Allow logging of stages - await runQuery( - `update performance_schema.setup_instruments set enabled="YES", timed="yes" where name like "stage/%";`, - this.session + await this.executeQuery( + `UPDATE performance_schema.setup_instruments SET enabled="YES", timed="yes" WHERE name LIKE "stage/%";` ); - await runQuery( - `update performance_schema.setup_consumers set enabled="YES" where name like "events_stages%";`, - this.session + await this.executeQuery( + `UPDATE performance_schema.setup_consumers SET enabled="YES" WHERE name LIKE "events_stages%";` ); } - async createMonitorProcedure(stageIgnoreBefore: number) { - await runQuery( - 'DROP PROCEDURE IF EXISTS monitor_connection;', - this.session - ); + async createMonitorProcedure() { + await this.executeQuery('DROP PROCEDURE IF EXISTS monitor_connection;'); // Create monitoring procedure - await runQuery( + await this.executeQuery( `CREATE PROCEDURE monitor_connection( - IN conn_id BIGINT UNSIGNED + IN conn_id BIGINT UNSIGNED, + IN stage_ignore_before BIGINT UNSIGNED, + IN timestep_size FLOAT ) BEGIN - DECLARE thd_id BIGINT UNSIGNED; + DECLARE thd_ID BIGINT UNSIGNED; DECLARE state VARCHAR(16); - DECLARE stage_ignore_before BIGINT UNSIGNED; DECLARE stage_min_ts BIGINT UNSIGNED; - SET thd_id = (SELECT THREAD_ID FROM performance_schema.threads WHERE PROCESSLIST_ID=conn_id); + SET thd_id = (SELECT thread_id FROM performance_schema.threads WHERE processlist_id=conn_id); - SET stage_ignore_before = ${stageIgnoreBefore}; DROP TABLE IF EXISTS monitoring_data; CREATE TABLE monitoring_data ( TS DATETIME(6), @@ -95,7 +90,7 @@ export default class SqlMonitor { HIGH_NUMBER_OF_BYTES_USED FROM performance_schema.memory_summary_by_thread_by_event_name WHERE THREAD_ID = thd_id; - DO SLEEP(0.1); + DO SLEEP(timestep_size); UNTIL state = 'Sleep' END REPEAT; SET stage_min_ts = (SELECT MIN(timer_start) FROM performance_schema.events_stages_history_long WHERE THREAD_ID=thd_id AND timer_start > stage_ignore_before); @@ -111,33 +106,55 @@ export default class SqlMonitor { (timer_end - stage_min_ts) / 1000000000000 AS end FROM performance_schema.events_stages_history_long WHERE THREAD_ID = thd_id AND timer_start > stage_ignore_before ORDER BY timer_start; - END`, - this.session + END` + ); + } + + async getStageIgnoreBeforeResult(threadID: string) { + const stageIgnoreBeforeResult = await this.executeQuery( + `SELECT MAX(TIMER_END) FROM performance_schema.events_stages_history_long WHERE THREAD_ID=${threadID};` ); + return Number(stageIgnoreBeforeResult.results?.values[0]); } - async monitorConnection(connectionID: string) { - await runQuery(`call monitor_connection(${connectionID});`, this.session); + async monitorConnection( + connectionID: string, + stageIgnoreBeforeResult: number, + timestep: number + ) { + SqlMonitor.log( + `Monitoring with timestep of ${timestep} seconds, connectionID ${connectionID}` + ); + await this.executeQuery( + `call monitor_connection(${connectionID}, ${stageIgnoreBeforeResult}, ${timestep});` + ); } async dumpData() { - await runQuery( - 'SET @min_ts = (SELECT UNIX_TIMESTAMP(MIN(TS)) FROM monitoring_data);', - this.session + await this.executeQuery( + 'SET @min_ts = (SELECT UNIX_TIMESTAMP(MIN(TS)) FROM monitoring_data);' ); - const data = await runQuery( - `SELECT UNIX_TIMESTAMP(TS) - @min_ts, EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED FROM monitoring_data ORDER BY 1;`, - this.session + const data = await this.executeQuery( + `SELECT UNIX_TIMESTAMP(TS) - @min_ts, EVENT_NAME, CURRENT_NUMBER_OF_BYTES_USED FROM monitoring_data ORDER BY 1;` ); + await this.executeQuery(`DROP TABLE IF EXISTS monitoring_data;`); return data; } async getStageTimes() { - const stageTimesRaw = await runQuery( - `select event_name, source, start, end from monitoring_stages;`, - this.session + const stageTimesRaw = await this.executeQuery( + `select event_name, source, start, end from monitoring_stages;` ); + await this.executeQuery(`DROP TABLE IF EXISTS monitoring_stages;`); return stageTimesRaw; } + + async executeQuery(query: string) { + return runQuery(query, this.session); + } + + static log(message: unknown) { + console.log(`SqlMonitor: ${String(message)}`); + } } diff --git a/backend/recorder/SqlRunner.ts b/backend/recorder/SqlRunner.ts index 1f94564e0a95c48102b4125a28518df4a2cb6a15..ccea83e7616c2d46115bc9fb35edc859f6c179ca 100644 --- a/backend/recorder/SqlRunner.ts +++ b/backend/recorder/SqlRunner.ts @@ -1,19 +1,31 @@ import { URI, Session } from '@mysql/xdevapi'; import { createConnection, createSession, runQuery } from '../utils/mysql'; +/** + * A class responsible for running queries which will be recorded + */ export default class SqlRunner { session?: Session; connectionID?: string; + threadID?: string; + async connect(connection?: URI, database?: string) { const client = await createConnection(connection, { - pooling: { enabled: true, maxSize: 2 }, + pooling: { enabled: true }, }); this.session = await createSession(client, database); - const resultSet = await runQuery(`SELECT connection_id();`, this.session); + let resultSet = await runQuery(`SELECT connection_id();`, this.session); this.connectionID = String(resultSet?.results?.values[0]); - console.log(`SqlRunner: ConnectionID: ${this.connectionID}`); + resultSet = await runQuery( + `select thread_id from performance_schema.threads where processlist_id=${this.connectionID}`, + this.session + ); + this.threadID = String(resultSet?.results?.values[0]); + console.log( + `SqlRunner: ConnectionID: ${this.connectionID}, ThreadID: ${this.threadID}` + ); return client; } diff --git a/backend/utils/FileIO.ts b/backend/utils/FileIO.ts index 7dfc66e3a28c05342c9bed78897c2e293fa34e9f..8cc94ed2d50f7f910f384867133f130e2fff20cd 100644 --- a/backend/utils/FileIO.ts +++ b/backend/utils/FileIO.ts @@ -6,9 +6,13 @@ import { LoginDetails } from './LoginDetails'; export default class FileIO { static async saveLoginDetails(loginDetails: LoginDetails) { const loginDetailsLocation = 'loginDetails.json'; + const fixedLoginDetails = loginDetails; + if (!loginDetails.savePassword) { + fixedLoginDetails.password = ''; + } await fs.promises.writeFile( loginDetailsLocation, - JSON.stringify(loginDetails, undefined, 2) + JSON.stringify(fixedLoginDetails, undefined, 2) ); } @@ -22,5 +26,3 @@ export default class FileIO { return undefined; } } - -// export default { saveLoginDetails, loadLoginDetails }; diff --git a/backend/utils/LoginDetails.ts b/backend/utils/LoginDetails.ts index 9b33d6393321662ee9ca08fc1e4013657dcff80e..2016fd683e310bfb7ef034d45af3bc5149a267f1 100644 --- a/backend/utils/LoginDetails.ts +++ b/backend/utils/LoginDetails.ts @@ -2,4 +2,5 @@ import { URI } from '@mysql/xdevapi'; export interface LoginDetails extends URI { database?: string; + savePassword?: boolean; } diff --git a/backend/utils/mysql.ts b/backend/utils/mysql.ts index 79a45106b8040805e5efe117a5a77246a8b6033b..5a1e03ef3d471d80681092ecc615a51a1128cfcb 100644 --- a/backend/utils/mysql.ts +++ b/backend/utils/mysql.ts @@ -138,7 +138,6 @@ export async function createSession(client?: Client, database?: string) { const result = await runQuery(`USE ${database};`, session); if (result.error) throw result.error; } - console.log(session.inspect()); return session; } diff --git a/backend/utils/mysqlx.d.ts b/backend/utils/mysqlx.d.ts index 8f16fecc90f25ab72c4d918af4a2a98e003362cb..1c016529fd9ec8ee4a9eabd20ea642dff4a740da 100644 --- a/backend/utils/mysqlx.d.ts +++ b/backend/utils/mysqlx.d.ts @@ -44,7 +44,7 @@ declare module '@mysql/xdevapi' { * Might be wrong, docs were unclear here */ interface PoolingOptions { - pooling: { enabled: boolean; maxSize: number }; + pooling: { enabled: boolean; maxSize?: number }; enforceJSON?: boolean; allowUndefined?: boolean; } diff --git a/configs/webpack.config.base.js b/configs/webpack.config.base.js index c4c004a735464b3243ff50b0a493a106de416da9..b59295684e305d5cf32f9117d48b8edff1d7d9e9 100644 --- a/configs/webpack.config.base.js +++ b/configs/webpack.config.base.js @@ -1,14 +1,15 @@ /** * Base webpack config used across other specific configs */ - import path from 'path'; import webpack from 'webpack'; import { dependencies as externals } from '../app/package.json'; +const isDevelopment = process.env.NODE_ENV !== 'production'; + export default { externals: [...Object.keys(externals || {})], - + mode: isDevelopment ? 'development' : 'production', module: { rules: [ { @@ -18,6 +19,9 @@ export default { loader: 'babel-loader', options: { cacheDirectory: true, + plugins: [ + isDevelopment && require.resolve('react-refresh/babel'), + ].filter(Boolean), }, }, }, @@ -38,11 +42,13 @@ export default { modules: [path.join(__dirname, '..', 'app'), 'node_modules'], }, + optimization: { + namedModules: true, + }, + plugins: [ new webpack.EnvironmentPlugin({ NODE_ENV: 'production', }), - - new webpack.NamedModulesPlugin(), - ], + ].filter(Boolean), }; diff --git a/configs/webpack.config.renderer.dev.babel.js b/configs/webpack.config.renderer.dev.babel.js index 093ba545ce711309d077a9bb953e462d6f342b06..37cec9b9539889cc0652964e3f5c0b706c0825eb 100644 --- a/configs/webpack.config.renderer.dev.babel.js +++ b/configs/webpack.config.renderer.dev.babel.js @@ -11,6 +11,7 @@ import webpack from 'webpack'; import chalk from 'chalk'; import { merge } from 'webpack-merge'; import { spawn, execSync } from 'child_process'; +import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import baseConfig from './webpack.config.base'; import CheckNodeEnv from '../internals/scripts/CheckNodeEnv'; @@ -50,7 +51,6 @@ export default merge(baseConfig, { entry: [ 'core-js', 'regenerator-runtime/runtime', - ...(process.env.PLAIN_HMR ? [] : ['react-hot-loader/patch']), `webpack-dev-server/client?http://localhost:${port}/`, 'webpack/hot/only-dev-server', require.resolve('../app/index.tsx'), @@ -191,10 +191,8 @@ export default merge(baseConfig, { }, ], }, - resolve: { - alias: { - 'react-dom': '@hot-loader/react-dom', - }, + optimization: { + noEmitOnErrors: true, }, plugins: [ requiredByDLLConfig @@ -208,8 +206,7 @@ export default merge(baseConfig, { new webpack.HotModuleReplacementPlugin({ multiStep: true, }), - - new webpack.NoEmitOnErrorsPlugin(), + new ReactRefreshWebpackPlugin(), /** * Create global constants which can be configured at compile time. diff --git a/package.json b/package.json index d7cb1fce85f735472d4c1c2162de312c09442c8c..48174a9ebbb95491a876af70944f3d963c9d6107 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@babel/preset-react": "^7.10.4", "@babel/preset-typescript": "^7.10.4", "@babel/register": "^7.10.5", + "@pmmmwh/react-refresh-webpack-plugin": "^0.4.2", "@types/electron-devtools-installer": "^2.2.0", "@types/enzyme": "^3.10.5", "@types/enzyme-adapter-react-16": "^1.0.6", @@ -150,13 +151,17 @@ "@types/mysql": "^2.15.15", "@types/node": "12", "@types/react": "^16.9.49", + "@types/react-color": "^3.0.4", "@types/react-dom": "^16.9.8", "@types/react-panelgroup": "^1.0.1", "@types/react-router": "^5.1.8", "@types/react-router-dom": "^5.1.5", "@types/react-test-renderer": "^16.9.2", + "@types/react-virtualized": "^9.21.10", + "@types/react-window": "^1.8.2", "@types/recharts": "^1.8.15", "@types/regenerator-runtime": "^0.13.0", + "@types/uuid": "^8.3.0", "@types/webpack": "^4.41.21", "@types/webpack-env": "^1.15.2", "@typescript-eslint/eslint-plugin": "^4.0.1", @@ -202,6 +207,7 @@ "opencollective-postinstall": "^2.0.3", "optimize-css-assets-webpack-plugin": "^5.0.3", "prettier": "^2.1.1", + "react-refresh": "^0.8.3", "react-test-renderer": "^16.12.0", "rimraf": "^3.0.0", "sass-loader": "^9.0.2", @@ -214,21 +220,22 @@ "testcafe-browser-provider-electron": "^0.0.15", "testcafe-react-selectors": "^4.0.0", "ts-node": "^9.0.0", + "type-fest": "^0.17.0", "typescript": "^3.9.7", "typings-for-css-modules-loader": "^1.7.0", "url-loader": "^4.1.0", - "webpack": "^4.43.0", - "webpack-bundle-analyzer": "^3.8.0", + "webpack": "^4.44.2", + "webpack-bundle-analyzer": "^3.9.0", "webpack-cli": "^3.3.12", "webpack-dev-server": "^3.11.0", - "webpack-merge": "^5.0.9", - "yarn": "^1.22.5" + "webpack-merge": "^5.1.4", + "yarn": "^1.22.10" }, "dependencies": { "@hot-loader/react-dom": "^16.13.0", "@material-ui/core": "^4.11.0", - "@material-ui/lab": "^4.0.0-alpha.56", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.56", "@mysql/xdevapi": "8.0.21", "concurrently": "^5.3.0", "electron-log": "^4.2.4", @@ -238,14 +245,19 @@ "lodash": "^4.17.20", "react": "^16.13.1", "react-dom": "^16.12.0", + "react-flame-graph": "^1.4.0", "react-hot-loader": "^4.12.21", + "react-color": "^2.18.1", "react-json-view": "^1.19.1", "react-panelgroup": "^1.0.12", "react-router-dom": "^5.2.0", + "react-virtualized": "^9.22.2", + "react-window": "^1.8.5", "recharts": "^1.8.5", "regenerator-runtime": "^0.13.7", "source-map-support": "^0.5.19", - "use-deep-compare-effect": "^1.4.0" + "use-deep-compare-effect": "^1.4.0", + "uuid": "^8.3.0" }, "devEngines": { "node": ">=7.x", diff --git a/yarn.lock b/yarn.lock index d79c754ed012a760f7173780032ef7e955876933..8d6bf039776667ca71bdb72e180a7a7e851633a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1105,7 +1105,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.11.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== @@ -1237,6 +1237,11 @@ prop-types "^15.6.2" scheduler "^0.19.0" +"@icons/material@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" + integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1565,6 +1570,18 @@ dependencies: mkdirp "^1.0.4" +"@pmmmwh/react-refresh-webpack-plugin@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.4.2.tgz#1f9741e0bde9790a0e13272082ed7272a083620d" + integrity sha512-Loc4UDGutcZ+Bd56hBInkm6JyjyCwWy4t2wcDXzN8EDPANgVRj0VP8Nxn0Zq2pc+WKauZwEivQgbDGg4xZO20A== + dependencies: + ansi-html "^0.0.7" + error-stack-parser "^2.0.6" + html-entities "^1.2.1" + native-url "^0.2.6" + schema-utils "^2.6.5" + source-map "^0.7.3" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -1860,6 +1877,14 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react-color@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.4.tgz#c63daf012ad067ac0127bdd86725f079d02082bd" + integrity sha512-EswbYJDF1kkrx93/YU+BbBtb46CCtDMvTiGmcOa/c5PETnwTiSWoseJ1oSWeRl/4rUXkhME9bVURvvPg0W5YQw== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + "@types/react-dom@^16.9.8": version "16.9.8" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423" @@ -1905,6 +1930,21 @@ dependencies: "@types/react" "*" +"@types/react-virtualized@^9.21.10": + version "9.21.10" + resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.10.tgz#cd072dc9c889291ace2c4c9de8e8c050da8738b7" + integrity sha512-f5Ti3A7gGdLkPPFNHTrvKblpsPNBiQoSorOEOD+JPx72g/Ng2lOt4MYfhvQFQNgyIrAro+Z643jbcKafsMW2ag== + dependencies: + "@types/prop-types" "*" + "@types/react" "*" + +"@types/react-window@^1.8.2": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.2.tgz#a5a6b2762ce73ffaab7911ee1397cf645f2459fe" + integrity sha512-gP1xam68Wc4ZTAee++zx6pTdDAH08rAkQrWm4B4F/y6hhmlT9Mgx2q8lTCXnrPHXsr15XjRN9+K2DLKcz44qEQ== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.49": version "16.9.49" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.49.tgz#09db021cf8089aba0cdb12a49f8021a69cce4872" @@ -1913,6 +1953,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/reactcss@*": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.3.tgz#af28ae11bbb277978b99d04d1eedfd068ca71834" + integrity sha512-d2gQQ0IL6hXLnoRfVYZukQNWHuVsE75DzFTLPUuyyEhJS8G2VvlE+qfQQ91SJjaMqlURRCNIsX7Jcsw6cEuJlA== + dependencies: + "@types/react" "*" + "@types/recharts@^1.8.15": version "1.8.15" resolved "https://registry.yarnpkg.com/@types/recharts/-/recharts-1.8.15.tgz#02bc06085c9a31a58c00194d15377b45cf506bbf" @@ -1965,6 +2012,11 @@ dependencies: "@types/react" "*" +"@types/uuid@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" + integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== + "@types/webpack-env@^1.15.2": version "1.15.3" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.15.3.tgz#fb602cd4c2f0b7c0fb857e922075fdf677d25d84" @@ -2454,7 +2506,7 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: dependencies: type-fest "^0.11.0" -ansi-html@0.0.7: +ansi-html@0.0.7, ansi-html@^0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4= @@ -5612,7 +5664,7 @@ dom-helpers@^3.4.0: dependencies: "@babel/runtime" "^7.1.2" -dom-helpers@^5.0.1: +dom-helpers@^5.0.1, dom-helpers@^5.1.3: version "5.2.0" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ== @@ -6060,6 +6112,13 @@ error-stack-parser@^1.3.3, error-stack-parser@^1.3.6: dependencies: stackframe "^0.3.1" +error-stack-parser@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" + integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== + dependencies: + stackframe "^1.1.1" + es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4, es-abstract@^1.17.5: version "1.17.6" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a" @@ -6919,6 +6978,11 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== +flow-bin@^0.118.0: + version "0.118.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.118.0.tgz#fb706364a58c682d67a2ca7df39396467dc397d1" + integrity sha512-jlbUu0XkbpXeXhan5xyTqVK1jmEKNxE8hpzznI3TThHTr76GiFwK0iRzhDo4KNy+S9h/KxHaqVhTP86vA6wHCg== + flush-write-stream@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" @@ -7622,7 +7686,7 @@ html-encoding-sniffer@^2.0.1: dependencies: whatwg-encoding "^1.0.5" -html-entities@^1.3.1: +html-entities@^1.2.1, html-entities@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== @@ -9462,7 +9526,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -"lodash@4.6.1 || ^4.16.1", lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.4: +"lodash@4.6.1 || ^4.16.1", lodash@^4.0.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.17.5, lodash@~4.17.4: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== @@ -9627,6 +9691,11 @@ matcher@^3.0.0: dependencies: escape-string-regexp "^4.0.0" +material-colors@^1.2.1: + version "1.2.6" + resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" + integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== + math-expression-evaluator@^1.2.14: version "1.2.22" resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz#c14dcb3d8b4d150e5dcea9c68c8dad80309b0d5e" @@ -9675,6 +9744,16 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +"memoize-one@>=3.1.1 <6": + version "5.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" + integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== + +memoize-one@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" + integrity sha512-YqVh744GsMlZu6xkhGslPSqSurOv6P+kLN2J3ysBZfagLcL5FdRK/0UpgLoL8hwjjEvvAVkjJZyFP+1T6p1vgA== + memory-fs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" @@ -10067,6 +10146,13 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" +native-url@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae" + integrity sha512-k4bDC87WtgrdD362gZz6zoiXQrl40kYlBmpfmSjwRO1VU0V5ccwJTlxuE72F6m3V0vc1xOf6n3UCP9QyerRqmA== + dependencies: + querystring "^0.2.0" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -11465,7 +11551,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -11603,7 +11689,7 @@ querystring-es3@^0.2.0: resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= -querystring@0.2.0: +querystring@0.2.0, querystring@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= @@ -11688,6 +11774,18 @@ react-base16-styling@^0.6.0: lodash.flow "^3.3.0" pure-color "^1.2.0" +react-color@^2.18.1: + version "2.18.1" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.18.1.tgz#2cda8cc8e06a9e2c52ad391a30ddad31972472f4" + integrity sha512-X5XpyJS6ncplZs74ak0JJoqPi+33Nzpv5RYWWxn17bslih+X7OlgmfpmGC1fNvdkK7/SGWYf1JJdn7D2n5gSuQ== + dependencies: + "@icons/material" "^0.2.4" + lodash "^4.17.11" + material-colors "^1.2.1" + prop-types "^15.5.10" + reactcss "^1.2.0" + tinycolor2 "^1.4.1" + react-dom@^16.12.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" @@ -11698,6 +11796,15 @@ react-dom@^16.12.0: prop-types "^15.6.2" scheduler "^0.19.1" +react-flame-graph@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/react-flame-graph/-/react-flame-graph-1.4.0.tgz#52d118cc94348f630a812fc0ec530a5b73c30cdb" + integrity sha512-DaCK9ZX+xK0mNca72kUE5cu6T8hGe/KLsefQWf+eT9sVt+0WP1dVxZCGD8Svfn2KrZB9Mv011Intg/yG2YWSxA== + dependencies: + flow-bin "^0.118.0" + memoize-one "^3.1.1" + react-window "^1" + react-hot-loader@^4.12.21: version "4.12.21" resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.12.21.tgz#332e830801fb33024b5a147d6b13417f491eb975" @@ -11739,6 +11846,11 @@ react-panelgroup@^1.0.12: dependencies: prop-types "^15.6.1" +react-refresh@^0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" + integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== + react-resize-detector@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-2.3.0.tgz#57bad1ae26a28a62a2ddb678ba6ffdf8fa2b599c" @@ -11825,6 +11937,26 @@ react-transition-group@^4.4.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtualized@^9.22.2: + version "9.22.2" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.2.tgz#217a870bad91e5438f46f01a009e1d8ce1060a5a" + integrity sha512-5j4h4FhxTdOpBKtePSs1yk6LDNT4oGtUwjT7Nkh61Z8vv3fTG/XeOf8J4li1AYaexOwTXnw0HFVxsV0GBUqwRw== + dependencies: + "@babel/runtime" "^7.7.2" + clsx "^1.0.4" + dom-helpers "^5.1.3" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-lifecycles-compat "^3.0.4" + +react-window@^1, react-window@^1.8.5: + version "1.8.5" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.5.tgz#a56b39307e79979721021f5d06a67742ecca52d1" + integrity sha512-HeTwlNa37AFa8MDZFZOKcNEkuF2YflA0hpGPiTT9vR7OawEt+GZbfM6wqkBahD3D3pUjIabQYzsnY/BSJbgq6Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" @@ -11834,6 +11966,13 @@ react@^16.13.1: object-assign "^4.1.1" prop-types "^15.6.2" +reactcss@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + read-config-file@6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/read-config-file/-/read-config-file-6.0.0.tgz#224b5dca6a5bdc1fb19e63f89f342680efdb9299" @@ -13067,6 +13206,11 @@ stackframe@^0.3.1: resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-0.3.1.tgz#33aa84f1177a5548c8935533cbfeb3420975f5a4" integrity sha1-M6qE8Rd6VUjIk1Uzy/6zQgl19aQ= +stackframe@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.2.0.tgz#52429492d63c62eb989804c11552e3d22e779303" + integrity sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA== + stat-mode@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465" @@ -13880,6 +14024,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinycolor2@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" + integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== + tmp-promise@^1.0.5: version "1.1.0" resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-1.1.0.tgz#bb924d239029157b9bc1d506a6aa341f8b13e64c" @@ -14121,6 +14270,11 @@ type-fest@^0.13.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== +type-fest@^0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.17.0.tgz#268bb55d38701ce3915f60a4367a1e9f28672deb" + integrity sha512-EFi9HE4hHj85XnVV80uAUMgICQmhxYgiEvtmfpcD6jqn6zYr36HxAU6k+i/DSY28TK7/lYL0s4v/kWmiKdqaoA== + type-fest@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" @@ -14613,7 +14767,7 @@ webidl-conversions@^6.1.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== -webpack-bundle-analyzer@^3.8.0: +webpack-bundle-analyzer@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.0.tgz#f6f94db108fb574e415ad313de41a2707d33ef3c" integrity sha512-Ob8amZfCm3rMB1ScjQVlbYYUEJyEjdEtQ92jqiFUYt5VkEeO2v5UMbv49P/gnmCZm3A6yaFQzCBvpZqN4MUsdA== @@ -14707,7 +14861,7 @@ webpack-log@^2.0.0: ansi-colors "^3.0.0" uuid "^3.3.2" -webpack-merge@^5.0.9: +webpack-merge@^5.1.4: version "5.1.4" resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.1.4.tgz#a2c3a0c38ac2c02055c47bb1d42de1f072f1aea4" integrity sha512-LSmRD59mxREGkCBm9PCW3AaV4doDqxykGlx1NvioEE0FgkT2GQI54Wyvg39ptkiq2T11eRVoV39udNPsQvK+QQ== @@ -14723,7 +14877,7 @@ webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.43.0: +webpack@^4.44.2: version "4.44.2" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.2.tgz#6bfe2b0af055c8b2d1e90ed2cd9363f841266b72" integrity sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q== @@ -15051,10 +15205,10 @@ yargs@^15.3.1, yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yarn@^1.22.5: - version "1.22.5" - resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.5.tgz#1933b7635429ca00847222dd9d38f05646e2df23" - integrity sha512-5uzKXwdMc++mYktXqkfpNYT9tY8ViWegU58Hgbo+KXzrzzhEyP1Ip+BTtXloLrXNcNlxFJbLiFKGaS9vK9ym6Q== +yarn@^1.22.10: + version "1.22.10" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.10.tgz#c99daa06257c80f8fa2c3f1490724e394c26b18c" + integrity sha512-IanQGI9RRPAN87VGTF7zs2uxkSyQSrSPsju0COgbsKQOOXr5LtcVPeyXWgwVa0ywG3d8dg6kSYKGBuYK021qeA== yauzl@^2.10.0: version "2.10.0"