Skip to content
Snippets Groups Projects
Commit 0fe732c6 authored by Erlend Ydse's avatar Erlend Ydse
Browse files

Merge branch 'dev' into 'master'

v0.2

See merge request erlenyd/mysql-query-profiler!55
parents bf1f4af3 072ab938
Branches
Tags v0.2
No related merge requests found
Showing
with 867 additions and 168 deletions
.env 0 → 100644
EXTEND_ESLINT=true
\ No newline at end of file
module.exports = {
extends: 'erb/typescript',
extends: [
'airbnb',
'plugin:react/recommended',
'plugin:import/recommended',
'prettier',
'plugin:prettier/recommended',
'plugin:jest/recommended',
'plugin:promise/recommended',
'plugin:compat/recommended',
'airbnb-typescript',
'plugin:import/typescript',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
],
rules: {
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'no-console': 'off',
'react/jsx-indent': 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-param-reassign': ['error', { props: false }],
'comma-dangle': 'off',
'operator-linebreak': 'off',
'object-curly-newline': 'off',
'max-len': 'off',
'implicit-arrow-linebreak': 'off',
'function-paren-newline': 'off',
},
parserOptions: {
ecmaVersion: 2020,
......
loginDetails.json
package-lock.json
out/
*.LICENSE
*.LICENSE.txt
app/main.prod.js.LICENSE.txt
# Logs
logs
......
image: node:lts-alpine
stages:
- pre
- test
cache:
key: ${CI_COMMIT_REF_SLUG}
key:
files:
- yarn.lock
paths:
- node_modules/
before_script:
- yarn install
- yarn build-main
- yarn build-renderer
stages:
- test
- deploy
lint:
stage: test
script:
- yarn install
- yarn run lint
#lint:types:
# stage: test
# script:
......@@ -26,12 +25,25 @@ lint:
tests:unit:
stage: test
script:
- yarn install
- yarn build
- yarn run test
#expo-deployments:
# stage: deploy
# only:
# - master
# script:
# - echo fs.inotify.max_user_watches=524288 | tee -a /etc/sysctl.conf && sysctl -p
# - npx expo login -u $EXPO_USERNAME -p $EXPO_PASSWORD
# - npx expo publish --non-interactive
publish:
stage: deploy
only:
- master
image: simpliston/mysql-query-profiler:latest
script:
- node -v
# Getting version number from app/package.json
- ticked_version=$(jq .version app/package.json)
- version=${ticked_version//[\"]/}
# Packaging the app
- yarn install --check-files
- yarn run package-all
# Uploading to snap store
- snapcraft login --with ${snapcraft_login}
- snapcraft upload --release=stable release/mysql-query-profiler_${version}_amd64.snap
# Creating new version on Bintray
- bash publish-bintray.sh
FROM snapcore/snapcraft:stable
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get dist-upgrade -y
RUN apt-get install -y snapcraft && apt-get clean
RUN apt-get install -y curl
RUN curl -sL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
RUN apt-get install -y nodejs
RUN node -v
RUN npm install -g yarn
RUN nodejs -v
RUN apt-get install -y rpm
RUN apt-get purge wine
RUN apt-get update && apt-get autoremove && apt-get clean && apt-get autoclean
RUN dpkg --add-architecture i386
RUN apt-get install -y software-properties-common
RUN add-apt-repository ppa:ubuntu-wine/ppa && apt-get update
RUN apt-get install -y wine1.8
RUN apt-get install jq -y
# TO BUILD:
# docker build -t simpliston/mysql-query-profiler .
# TO PUSH:
# docker login
# docker push simpliston/mysql-query-profiler:latest
# MySQL Query Profiler
## Requirements
[![Download](https://api.bintray.com/packages/kpro4/mysql-query-profiler/mysql-query-profiler/images/download.svg)](https://bintray.com/kpro4/mysql-query-profiler/mysql-query-profiler/_latestVersion)
## Installation and use
### Install
#### **Linux**
Either use the AppImage, deb, or rpm file in [Bintray](https://bintray.com/kpro4/mysql-query-profiler/mysql-query-profiler) or install the snap:
```bash
snap install mysql-query-profiler
```
#### **Windows**
Install the exe-file from [Bintray](https://bintray.com/kpro4/mysql-query-profiler/mysql-query-profiler).
#### **MacOS**
Use the zip-file from [Bintray](https://bintray.com/kpro4/mysql-query-profiler/mysql-query-profiler).
### Usage
Start the application, connect to a database (remote or local) and insert a query you want to debug. Performance will be shown along with an optimizer trace in addition to more options you can configure.
## Development
### Requirements
- Node 10 or later
- Npm
- Yarn
## Install
### Install for development
First, clone the repo via git and install dependencies:
......@@ -18,7 +46,7 @@ yarn
Test database: [test_db](https://github.com/datacharmer/test_db)
## Starting Development
### Starting Development
Start the app in the `dev` environment. This starts the renderer process in [**hot-module-replacement**](https://webpack.js.org/guides/hmr-react/) mode and starts a webpack dev server that sends hot updates to the renderer process:
......@@ -26,7 +54,7 @@ Start the app in the `dev` environment. This starts the renderer process in [**h
yarn dev
```
## Packaging for Production
### Packaging for Production
To package apps for the local platform:
......
import React from 'react';
import { createMuiTheme, ThemeProvider } from '@material-ui/core';
import { HashRouter as Router, Switch, Route } from 'react-router-dom';
import { SqlManagerProvider } from './context/SqlManagerContext';
import { LoadingProvider } from './context/LoadingContext';
import routes from './routes';
import LoginScreen from './containers/LoginScreen';
......@@ -11,6 +10,9 @@ import { LoginDetailsProvider } from './context/LoginDetailsContext';
const theme = createMuiTheme({
palette: {
type: 'dark',
error: {
main: '#FF9292',
},
primary: {
light: '#769CFF',
main: '#769CFF',
......@@ -45,12 +47,10 @@ const theme = createMuiTheme({
export default function App() {
return (
<div>
<div className="content">
<ThemeProvider theme={theme}>
<SqlManagerProvider>
<LoadingProvider>
<LoginDetailsProvider>
<div className="content">
<Router>
<Switch>
<Route exact path={routes.login}>
......@@ -61,10 +61,8 @@ export default function App() {
</Route>
</Switch>
</Router>
</div>
</LoginDetailsProvider>
</LoadingProvider>
</SqlManagerProvider>
</ThemeProvider>
</div>
);
......
import { Result } from '../backend/utils/mysql';
export interface QueryOutput {
results: Result | undefined;
error: undefined;
}
......@@ -4,15 +4,19 @@
* See https://github.com/webpack-contrib/sass-loader#imports
*/
body {
body,
body #root {
color: rgba(255, 255, 255, 0.9);
max-height: 100vh;
max-width: 100vw;
height: 100vh;
background-color: #404040;
width: 100vw;
background-color: #393939;
margin: 0;
overflow: hidden;
}
.content {
display: flex;
flex-flow: column nowrap;
justify-content: space-between;
min-height: 100%;
min-width: 100%;
}
......@@ -2,8 +2,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Hello Electron React!</title>
<meta
charset="utf-8"
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>MySQL Query Profiler</title>
<link
href="https://fonts.googleapis.com/css2?family=Raleway:wght@200;300;400;500;600;700&display=swap"
rel="stylesheet"
......
......@@ -5,11 +5,11 @@ import {
Paper,
Typography,
makeStyles,
LinearProgress,
} from '@material-ui/core';
import { useHistory, useLocation } from 'react-router';
import clsx from 'clsx';
import { SqlManagerContext } from '../context/SqlManagerContext';
import SqlManager from '../../backend/recorder/SqlManager';
import SqlManagerSingleton from '../../backend/recorder/SqlManagerSingleton';
import { LoadingContext } from '../context/LoadingContext';
import routes from '../routes';
import { LoginDetailsContext } from '../context/LoginDetailsContext';
......@@ -41,6 +41,13 @@ const useStyles = makeStyles((theme) => ({
textAlign: 'center',
marginBottom: theme.spacing(2),
},
errorMessage: {
textAlign: 'center',
whiteSpace: 'pre-line',
},
progressBar: {
borderRadius: '2px',
},
}));
interface LoginProps {
......@@ -52,7 +59,6 @@ const DefaultProps = {
};
export default function Login({ onConnect = undefined }: LoginProps) {
const sqlManagerContext = useContext(SqlManagerContext);
const loadingContext = useContext(LoadingContext);
const loginDetailsContext = useContext(LoginDetailsContext);
......@@ -74,19 +80,17 @@ export default function Login({ onConnect = undefined }: LoginProps) {
const [database, setDatabase] = useState(
loginDetailsContext?.loginDetails?.database || ''
);
const [connecting, setConnecting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
async function handleConnectByLogin() {
let manager;
if (!sqlManagerContext?.sqlManager) {
manager = new SqlManager();
sqlManagerContext?.setSqlManager(manager);
} else {
manager = sqlManagerContext.sqlManager;
}
setConnecting(true);
const manager = SqlManagerSingleton.getInstance();
try {
loadingContext?.setConnected(false);
loadingContext?.setConnecting(true);
await manager.connect({ host, user, password, port }, database);
setErrorMessage('');
loadingContext?.setConnecting(false);
loadingContext?.setConnected(true);
......@@ -98,12 +102,43 @@ export default function Login({ onConnect = undefined }: LoginProps) {
}
} catch (error) {
console.error(error);
if (error?.info?.msg?.includes('Unknown database')) {
const newErrorMessage = `${error.info.msg.replace(
'Unknown',
'The'
)} does not exist on the chosen server`;
setErrorMessage(newErrorMessage);
} else if (String(error).includes('database was not passed')) {
setErrorMessage('There was no database to connect to');
} else if (
String(error).includes('EAI_AGAIN') ||
String(error).includes('ENOTFOUND')
) {
setErrorMessage(`The host '${host}' could not be found`);
} else if (String(error).includes('Access denied')) {
setErrorMessage(`Wrong username or password, user: ${user}@${host}`);
} else if (String(error).includes('The server has gone away')) {
setErrorMessage(
`The server connection is not using the X Protocol.
Make sure you are connecting to the correct port and using a MySQL 5.7.12 (or higher) server instance.
Default port for X Protocol servers is 33060`
);
} else {
setErrorMessage('Failed to connect');
}
loadingContext?.setConnecting(false);
loadingContext?.setConnected(false);
setConnecting(false);
}
}
function handleKeyUp(e: { key: string }) {
if (e.key === 'Enter') {
handleConnectByLogin();
}
}
// TODO: Show connection indicator (spinning circle)
// TODO: Show result of connection (success/failure)
const classes = useStyles();
......@@ -116,11 +151,13 @@ export default function Login({ onConnect = undefined }: LoginProps) {
</Typography>
<div className={clsx(classes.content, classes.element)}>
<TextField
autoFocus
className={classes.element}
variant="filled"
label="Host"
value={host}
onChange={(event) => setHost(event.target.value)}
onKeyUp={handleKeyUp}
/>
<TextField
className={classes.element}
......@@ -128,6 +165,7 @@ export default function Login({ onConnect = undefined }: LoginProps) {
label="User"
value={user}
onChange={(event) => setUser(event.target.value)}
onKeyUp={handleKeyUp}
/>
<TextField
className={classes.element}
......@@ -136,6 +174,7 @@ export default function Login({ onConnect = undefined }: LoginProps) {
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyUp={handleKeyUp}
/>
<TextField
className={classes.element}
......@@ -143,6 +182,7 @@ export default function Login({ onConnect = undefined }: LoginProps) {
label="Port"
value={port}
onChange={(event) => setPort(Number(event.target.value))}
onKeyUp={handleKeyUp}
/>
<TextField
className={classes.element}
......@@ -150,9 +190,19 @@ export default function Login({ onConnect = undefined }: LoginProps) {
label="Database"
value={database}
onChange={(event) => setDatabase(event.target.value)}
onKeyUp={handleKeyUp}
/>
</div>
{!!errorMessage && (
<Typography
color="error"
className={clsx(classes.element, classes.errorMessage)}
>
{errorMessage}
</Typography>
)}
<Button
className={classes.element}
type="button"
variant="contained"
color="primary"
......@@ -160,6 +210,11 @@ export default function Login({ onConnect = undefined }: LoginProps) {
>
Connect
</Button>
<LinearProgress
hidden={!connecting}
className={clsx(classes.element, classes.progressBar)}
color="primary"
/>
</Paper>
</div>
);
......
import React, { useState, ReactElement, ReactNode } from 'react';
import _ from 'lodash';
import React, { useState, useContext } from 'react';
import {
Legend,
LineChart,
......@@ -10,11 +11,10 @@ import {
ResponsiveContainer,
Surface,
Symbols,
ContentRenderer,
LegendProps,
ReferenceLine,
Label,
} from 'recharts';
import _ from 'lodash';
import { MemoryPerformance } from '../../backend/data-processor/DataProcessor';
import { RecordingContext } from '../context/RecordingContext';
interface Payload {
value: string;
......@@ -23,38 +23,47 @@ interface Payload {
color: string;
}
const chartDataMock: MemoryPerformance = [
{ relative_time: 0, geom: 0, total: 235378 },
{ relative_time: 1, geom: 0, total: 263058 },
{ relative_time: 2, geom: 0, total: 263058 },
{ relative_time: 3, geom: 265, total: 263371 },
{ relative_time: 4, geom: 265, total: 263371 },
{ relative_time: 5, geom: 387, total: 263493 },
{ relative_time: 6, geom: 3267, total: 266373 },
{ relative_time: 7, geom: 3267, total: 266373 },
{ relative_time: 8, geom: 32067, total: 295173 },
{ relative_time: 9, geom: 32067, total: 295173 },
{ relative_time: 10, geom: 256503, total: 519609 },
{ relative_time: 11, geom: 320067, total: 726837 },
{ relative_time: 12, geom: 320067, total: 726837 },
{ relative_time: 13, geom: 1349729, total: 1756499 },
{ relative_time: 14, geom: 1442129, total: 1848899 },
{ relative_time: 15, geom: 1785089, total: 2191859 },
{ relative_time: 16, geom: 1980689, total: 2387459 },
{ relative_time: 17, geom: 1164423, total: 1571193 },
{ relative_time: 18, geom: 1418583, total: 1825353 },
{ relative_time: 19, geom: 1589703, total: 1996473 },
{ relative_time: 20, geom: 0, total: 216938 },
];
interface ChartColors {
[key: string]: string;
}
export default function MemoryChart() {
const [disabled, setDisabled] = useState<Array<string>>([]);
const [chartData, setChartData] = useState<MemoryPerformance>(chartDataMock);
const [chartColors, setChartColors] = useState({
geom: '#8884d8',
total: '#82ca9d',
});
const [chartColors, setChartColors] = useState({ total: '#82ca9d' });
const validStages = [
'stage/sql/preparing',
'stage/sql/optimizing',
'stage/sql/executing',
'stage/sql/end',
];
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)));
}
function getMaxTime() {
// eslint-disable-next-line prefer-spread
return Math.max.apply(Math, _.map(chartData, 'relative_time'));
}
// 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) {
if (_.includes(disabled, dataKey)) {
setDisabled(disabled.filter((obj) => obj !== dataKey));
......@@ -63,9 +72,7 @@ export default function MemoryChart() {
}
}
function renderCustomizedLegend(props: {
payload: Array<Payload>;
}): ReactNode {
function renderCustomizedLegend(props: { payload: Array<Payload> }) {
const { payload } = props;
return (
<div className="customized-legend">
......@@ -85,14 +92,13 @@ export default function MemoryChart() {
style={style}
key={value}
>
{/* viewBox="0 0 10 10" */}
<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>{value}</span>
<span style={{ color: '#FFF' }}>{value}</span>
</span>
);
})}
......@@ -100,6 +106,16 @@ export default function MemoryChart() {
);
}
function formatBytes(byte: number) {
if (Math.abs(byte) > 10000000) {
return `${Math.floor(byte / 1024 / 1024)} MB`;
}
if (Math.abs(byte) > 10000) {
return `${Math.floor(byte / 1024)} KB`;
}
return `${Math.floor(byte)} B`;
}
return (
<div className="highlight-bar-charts">
<ResponsiveContainer height={400}>
......@@ -110,13 +126,27 @@ export default function MemoryChart() {
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 10" />
<XAxis dataKey="relative_time" />
<YAxis />
<Tooltip />
{/* 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}
/>
<Legend
verticalAlign="bottom"
height={36}
align="left"
align="center"
payload={_.toPairs(chartColors).map((pair, i) => ({
value: pair[0],
id: i,
......@@ -125,6 +155,9 @@ export default function MemoryChart() {
}))}
content={renderCustomizedLegend}
/>
{/* 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) => (
......@@ -133,7 +166,26 @@ export default function MemoryChart() {
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_')}
>
<Label
value={item.stage}
position="top"
style={{ color: '#FFFFFF' }}
/>
</ReferenceLine>
))}
</LineChart>
</ResponsiveContainer>
......
import React, { useContext } from 'react';
import ReactJson from 'react-json-view';
import { RecordingContext } from '../context/RecordingContext';
export default function OptimizerTrace() {
const recordingContext = useContext(RecordingContext);
const optimizerTrace = recordingContext?.optimizerTrace;
return (
<div>
{optimizerTrace && (
<ReactJson
src={optimizerTrace}
theme="twilight"
name="OptimizerTrace"
collapsed={5}
indentWidth={2}
/>
)}
</div>
);
}
/* eslint-disable react/jsx-wrap-multilines */
import React, { useState, useContext } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import {
......@@ -7,11 +8,12 @@ import {
Typography,
FormControlLabel,
Checkbox,
CircularProgress,
} from '@material-ui/core';
import clsx from 'clsx';
import { SqlManagerContext } from '../context/SqlManagerContext';
import { DataProcessor } from '../../backend/data-processor/DataProcessor';
import { RecordingContext } from '../context/RecordingContext';
import ResultJson from './ResultJson';
import SqlManagerSingleton from '../../backend/recorder/SqlManagerSingleton';
const useStyles = makeStyles((theme) => ({
container: {
......@@ -32,7 +34,11 @@ const useStyles = makeStyles((theme) => ({
display: 'flex',
flexFlow: 'column nowrap',
flexGrow: 1,
width: '600px',
},
recordButtonWrapper: {
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
},
output: {
userSelect: 'none',
......@@ -44,13 +50,15 @@ const useStyles = makeStyles((theme) => ({
export default function QueryRecorder() {
const [changedQuery, setChangedQuery] = useState('');
const [output, setOutput] = useState<string>('');
const [output, setOutput] = useState('');
const [explainAnalyze, setExplainAnalyze] = useState(false);
const [waitRecording, setWaitRecording] = useState(false);
const manager = useContext(SqlManagerContext)?.sqlManager;
const manager = SqlManagerSingleton.getInstance();
const recordingContext = useContext(RecordingContext);
async function record() {
setWaitRecording(true);
if (!manager) {
console.log('not connected');
return;
......@@ -58,34 +66,34 @@ export default function QueryRecorder() {
try {
const result = await manager.record(changedQuery, explainAnalyze);
console.log(result);
if (result?.error) {
console.log(`QueryRecorder: Result: ${result}`);
setOutput(JSON.stringify(result.error.info.msg, undefined, 2));
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}`);
} else {
recordingContext?.setQueryOutput(result.result);
setOutput(JSON.stringify(result?.result, undefined, 2));
const memoryPerformance = DataProcessor.processMemoryPerformance(
result.memoryPerformance
);
recordingContext?.setMemoryPerformance(memoryPerformance);
recordingContext?.setOptimizerTrace(result.optimizerTrace);
recordingContext?.setChartData(result.memoryPerformance);
recordingContext?.setStageTimes(result.stageTimes);
}
} catch (error) {
console.log(`QueryRecorder: Error: ${error}`);
} finally {
setWaitRecording(false);
}
}
async function doOptimizerQuery() {
if (!manager) {
console.log('not connected');
return;
const classes = useStyles();
function handleKeyUp(e: { key: string; ctrlKey: boolean }) {
if (e.key === 'Enter' && e.ctrlKey) {
record();
}
const traceResult = await manager.executeWithTrace(changedQuery);
setOutput(JSON.stringify(traceResult.result, undefined, 2));
console.log(traceResult.trace);
}
const classes = useStyles();
return (
<div className={classes.container}>
<Paper className={classes.content} elevation={1}>
......@@ -94,6 +102,7 @@ export default function QueryRecorder() {
</div>
<div className={clsx(classes.element, classes.input)}>
<TextField
autoFocus
multiline
InputProps={{
classes: {
......@@ -106,12 +115,13 @@ export default function QueryRecorder() {
rowsMax={10}
value={changedQuery}
onChange={(event) => setChangedQuery(event.target.value)}
onKeyUp={handleKeyUp}
helperText="Ctrl + Enter: Run"
/>
</div>
<div className={classes.element}>
<FormControlLabel
control={
// eslint-disable-next-line react/jsx-wrap-multilines
<Checkbox
color="primary"
checked={explainAnalyze}
......@@ -121,29 +131,25 @@ export default function QueryRecorder() {
label="Explain analyze"
/>
</div>
<div className={classes.element}>
<Button variant="contained" color="primary" onClick={() => record()}>
<div className={clsx(classes.element, classes.recordButtonWrapper)}>
<Button
disabled={waitRecording}
variant="contained"
color="primary"
onClick={() => record()}
>
Record
</Button>
</div>
<div className={classes.element}>
<Button variant="contained" onClick={() => doOptimizerQuery()}>
Optimizer
</Button>
{waitRecording && (
<CircularProgress
style={{ marginLeft: '16px' }}
size="24px"
color="primary"
/>
)}
</div>
<div className={clsx(classes.element, classes.input)}>
<TextField
className={classes.output}
InputProps={{
classes: {
input: classes.code,
},
}}
multiline
rows={10}
variant="outlined"
value={output}
/>
<ResultJson result={output} />
</div>
</Paper>
</div>
......
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;
}
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 classes = useStyles();
const { result } = props;
return (
<div className={clsx(classes.element, classes.input)}>
<TextField
className={classes.result}
InputProps={{
classes: {
input: classes.code,
},
}}
multiline
rows={10}
variant="outlined"
value={result}
/>
</div>
);
}
/* 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 { RecordingContext } from '../context/RecordingContext';
// interface TResultProps {
// result: TResult | undefined;
// }
const useStyles = makeStyles({
table: {
minWidth: 650,
},
container: {
maxHeight: 211,
},
});
const StyledTableCell = withStyles((theme: Theme) =>
createStyles({
head: {
backgroundColor: '#404040',
color: theme.palette.common.white,
},
body: {
fontSize: 14,
},
})
)(TableCell);
export default function ResultTable() {
const recordingContext = useContext(RecordingContext);
const classes = useStyles();
const result = recordingContext?.queryOutput;
const tableHeader: string[] | undefined = result?.results?.labels;
const tableBody: ResultValue[][] | undefined = result?.results?.values;
const types: number[] | undefined = result?.results?.types;
function formatDate(d: Date) {
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;
return [year, month, day].join('-');
}
function format(index: number, entry: any): string {
let datatype = 0;
if (types) {
datatype = types[index];
}
if (datatype === 12) {
if (typeof entry === 'number') {
const date = new Date(entry);
return formatDate(date);
}
const date = new Date(entry);
return formatDate(date);
}
return entry;
}
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>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
import React, { useContext, useState } 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: [],
};
const useStyles = makeStyles({
root: {
flexGrow: 1,
},
});
/*
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 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}>
{Array.isArray(nodes.children)
? nodes.children.map((node) => RenderTree(node))
: null}
</TreeItem>
);
const RenderTreeWithoutRoot = (root: Node) => {
// Renders only the children of root
const elements: JSX.Element[] = [];
root.children.forEach((child) => {
elements.push(RenderTree(child));
});
return elements;
};
// TODO: Find dynamic solution to defaultExpanded
return (
<TreeView
className={classes.root}
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpanded={['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10']}
defaultExpandIcon={<ChevronRightIcon />}
>
{[RenderTreeWithoutRoot(rootNode)]}
</TreeView>
);
}
import React, { useContext, useState } from 'react';
import { Button, Dialog } from '@material-ui/core';
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 MemoryChart from '../components/MemoryChart';
import ResultView from './ResultView';
const useStyles = makeStyles((theme) => ({
wrapper: {
minHeight: '100%',
height: '100%',
maxHeight: '100vh',
},
column: {
display: 'flex',
flexFlow: 'column nowrap',
maxHeight: '98vh',
height: '100%',
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
width: '100%',
overflowY: 'auto',
},
}));
export default function Dashboard() {
const [changeConnectionOpen, setChangeConnectionOpen] = useState(false);
......@@ -32,8 +50,20 @@ export default function Dashboard() {
setChangeConnectionOpen(true);
}
const classes = useStyles();
return (
<div>
<div className={classes.wrapper}>
<RecordingProvider>
<PanelGroup
borderColor="#4B4B4B"
spacing={16}
panelWidths={[
{ minSize: 200, size: 700, resize: 'dynamic' },
{ minSize: 200, resize: 'dynamic' },
]}
>
<div className={classes.column}>
<div>
<Button
variant="outlined"
......@@ -49,9 +79,12 @@ export default function Dashboard() {
<Login onConnect={() => setChangeConnectionOpen(false)} />
</Dialog>
</div>
<RecordingProvider>
<QueryRecorder />
<MemoryChart />
</div>
<div className={classes.column}>
<ResultView />
</div>
</PanelGroup>
</RecordingProvider>
</div>
);
......
import React, { useEffect, useState, useContext } from 'react';
import { Typography, makeStyles } from '@material-ui/core';
import Login from '../components/Login';
import { loadLoginDetails } from '../../backend/utils/fileutils';
import FileIO from '../../backend/utils/FileIO';
import { LoginDetailsContext } from '../context/LoginDetailsContext';
const useStyles = makeStyles((theme) => ({
......@@ -14,10 +14,10 @@ const useStyles = makeStyles((theme) => ({
flexFlow: 'column nowrap',
alignItems: 'center',
justifyContent: 'space-between',
padding: `${theme.spacing(8)}px ${theme.spacing(16)}px`,
padding: `${theme.spacing(6)}px ${theme.spacing(16)}px`,
},
title: {
marginBottom: theme.spacing(8),
marginBottom: theme.spacing(6),
textAlign: 'center',
},
}));
......@@ -28,7 +28,7 @@ export default function LoginScreen() {
const loginDetailsContext = useContext(LoginDetailsContext);
useEffect(() => {
loadLoginDetails()
FileIO.loadLoginDetails()
.then((response) => {
if (response) {
loginDetailsContext?.setLoginDetails(response);
......@@ -37,7 +37,6 @@ export default function LoginScreen() {
return true;
})
.catch((error) => console.error(error));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const classes = useStyles();
......@@ -46,7 +45,7 @@ export default function LoginScreen() {
<div className={classes.wrapper}>
{!loading && (
<div className={classes.content}>
<Typography className={classes.title} variant="h2">
<Typography className={classes.title} variant="h3">
Welcome to MySQL query profiler!
</Typography>
<Login />
......
/* eslint-disable react/jsx-wrap-multilines */
import {
makeStyles,
Checkbox,
FormControlLabel,
Paper,
} from '@material-ui/core';
import React, { useContext, useState } from 'react';
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';
const useStyles = makeStyles((theme) => ({
wrapper: {
display: 'flex',
flexFlow: 'column nowrap',
alignContent: 'center',
},
item: {
margin: `${theme.spacing(1)}px ${theme.spacing(0)}`,
},
}));
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 recordingContext = useContext(RecordingContext);
const classes = useStyles();
return (
<div className={classes.wrapper} style={{ minWidth: '200px' }}>
<div>
<FormControlLabel
label="Show result table"
control={
<Checkbox
color="primary"
checked={showResultTable}
onChange={(event) => setShowResultTable(event.target.checked)}
/>
}
/>
<FormControlLabel
label="Show memory chart"
control={
<Checkbox
color="primary"
checked={showMemoryChart}
onChange={(event) => setShowMemoryChart(event.target.checked)}
/>
}
/>
<FormControlLabel
label="Show optimizer trace"
control={
<Checkbox
color="primary"
checked={showOptimizerTrace}
onChange={(event) => setShowOptimizerTrace(event.target.checked)}
/>
}
/>
<FormControlLabel
label="Show explain analyze"
control={
<Checkbox
color="primary"
checked={showExplainAnalyze}
onChange={(event) => setShowExplainAnalyze(event.target.checked)}
/>
}
/>
</div>
{showResultTable && (
<Paper className={classes.item}>
<ResultTable />
</Paper>
)}
{showMemoryChart && (
<Paper
className={classes.item}
style={{ paddingBottom: 80, paddingTop: 8 }}
>
<MemoryChart />
</Paper>
)}
{showOptimizerTrace && recordingContext?.optimizerTrace && (
<Paper className={classes.item} style={{ padding: 8 }}>
<OptimizerTrace />
</Paper>
)}
{showExplainAnalyze && (
<Paper className={classes.item}>
<TreeViewExplainAnalyze />
</Paper>
)}
</div>
);
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment