Copy lattice repo @8c29787 into subdirectory lattice/

This commit is contained in:
Alan Bernstein 2021-05-19 20:38:40 -05:00
parent 4a00bd9d6e
commit 4aaf3e2fc2
158 changed files with 29368 additions and 0 deletions

3
lattice/Caddyfile Normal file
View file

@ -0,0 +1,3 @@
:80
root * /lattice
file_server

14
lattice/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM moleculacorp/nodejs:latest as build
WORKDIR /lattice
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . ./
RUN yarn build
FROM caddy
COPY --from=build /lattice/build /lattice
COPY Caddyfile /etc/caddy/Caddyfile

16
lattice/Makefile Normal file
View file

@ -0,0 +1,16 @@
.PHONY: proto run-pilosa run-app
proto: pilosa
docker run --rm -v $(PWD)/pilosa:/pilosa -w /pilosa/proto/ jfbrandhorst/grpc-web-generators protoc -I/usr/include -I. --plugin=protoc-gen-ts=/usr/local/bin/protoc-gen-ts --js_out=import_style=commonjs,binary:. --ts_out=service=grpc-web:. pilosa.proto
(echo "/* eslint-disable */"; cat pilosa/proto/pilosa_pb.js; echo "/* eslint-enable */") > src/proto/pilosa_pb.js
(echo "/* eslint-disable */"; cat pilosa/proto/pilosa_pb_service.js; echo "/* eslint-enable */") > src/proto/pilosa_pb_service.js
cp pilosa/proto/pilosa_pb.d.ts pilosa/proto/pilosa_pb_service.d.ts src/proto
pilosa:
git clone git@github.com:molecula/pilosa.git
run-pilosa:
pilosa server --handler.allowed-origins http://localhost:3000
run-app:
yarn run start

22
lattice/README.md Normal file
View file

@ -0,0 +1,22 @@
## Connecting to a standalone Pilosa instance
Lattice can run independently (with e.g. `yarn start`; see below), connecting to any Pilosa instance. Pilosa must be started with the `allowed-origins` configuration parameter set to communicate with the UI.
CLI: `pilosa server --handler.allowed-origins http://localhost:3000`
You will need to update the `lattice.config.js` file and set the hostname and port of your pilosa server, otherwise it will use the browser url. The default pilosa server is `localhost:10101`
If you will be consistently working in the repo, it may be useful to locally ignore changes to this file to prevent accidental commits by using:
`git update-index --assume-unchanged src/lattice.config.js`
### `yarn start`
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Embedding Lattice within Pilosa
Lattice can be embedded within the Pilosa binary, so the UI is fully accessible directly from the server, reducing operational complexity.
If additional build dependencies `yarn` (`brew install yarn` and `brew upgrade yarn` perhaps) and `statik` (`make install-statik`) are available on your system, running `make generate-statik` before `make install` should produce a Pilosa binary with Lattice embedded. For up to date instructions, check the Pilosa [README](https://github.com/molecula/pilosa#getting-started).

71
lattice/package.json Normal file
View file

@ -0,0 +1,71 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@date-io/moment": "^1.3.13",
"@improbable-eng/grpc-web": "^0.13.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.57",
"@material-ui/pickers": "^3.3.10",
"@nivo/bar": "^0.69.1",
"@nivo/core": "^0.69.0",
"@nivo/line": "^0.69.1",
"axios": "^0.19.2",
"classnames": "^2.2.6",
"copy-to-clipboard": "^3.3.1",
"d3-array": "^2.4.0",
"d3-scale-chromatic": "^2.0.0",
"date-fns": "^2.16.1",
"framer-motion": "^2.0.0",
"fuse.js": "^6.4.1",
"get-ip-range": "^4.0.1",
"google-protobuf": "^3.13.0",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"moment-timezone": "^0.5.31",
"react": "^17.0.1",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.1",
"react-highlight-words": "^0.16.0",
"react-moment": "^0.9.7",
"react-pluralize": "^1.6.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-split": "^2.0.9",
"react-use": "^15.3.3"
},
"devDependencies": {
"@types/d3-array": "^2.0.0",
"@types/jest": "^26.0.4",
"@types/node": "^14.0.20",
"@types/node-sass": "^4.11.0",
"@types/react": "^16.9.41",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^16.9.8",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.5",
"node-sass": "^4.12.0",
"react-scripts": "^4.0.0",
"tslint": "^6.1.2",
"typescript": "^4.2.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"lint": "yarn tslint -c tslint.json 'src/**/*.{ts,tsx}'",
"lint:fix": "yarn tslint --fix -c tslint.json 'src/**/*.{ts,tsx}'",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

BIN
lattice/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
lattice/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50">
<style>.m-blue { fill: #004379; } @media (prefers-color-scheme: dark) { .m-blue { fill: #ffffff; } }
</style>
<path d="M25.46 1.32a2.38 2.38 0 10-2.38 4.13l17.62 10.2V36a2.38 2.38 0 004.76 0V12.9z" fill="#c3d3dc"/>
<path d="M12.78 7.62a2.37 2.37 0 00.85 3.24l17.64 10.22v20.37a2.39 2.39 0 004.77 0V18.33L16 6.75a2.4 2.4 0 00-3.22.87z" fill="#48b5cd"/>
<path d="M3.32 13.09A2.35 2.35 0 003 14.22V36a2.4 2.4 0 004.8 0V18.43l4.62 2.68v20.36a2.38 2.38 0 004.76 0v-17.6l4.66 2.7v20.32a2.39 2.39 0 004.77 0V23.82l-20-11.6a2.39 2.39 0 00-3.27.84z" class="m-blue"/>
</svg>

After

Width:  |  Height:  |  Size: 636 B

44
lattice/public/index.html Normal file
View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" type="image/png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,Open+Sans:300,Roboto+Mono:400,700,Ubuntu:400,500" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Molecula</title>
<div data-pp-cfiw-widget="222f88d2-66e8-11e9-80b9-0288f735e5b9" data-env="prod" data-static="false"></div><script async type="text/javascript">(function(d,id){var js,h = d.getElementsByTagName("head")[0];if (d.getElementById(id)) return;js=d.createElement("script"); js.id = id;js.src="https://widget.prodpad.com/customer_feedback_portal_app/inapp_widget/sdk.js";h.appendChild(js);}(document,"prodpad-cfiwjs"));</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,15 @@
{
"short_name": "Molecula",
"name": "Molecula Demo App",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,15 @@
.container {
height: calc(100vh - 64px);
}
.layout {
display: grid;
height: 100%;
grid-template-areas: 'left-nav main-content';
grid-template-columns: 250px calc(100vw - 250px);
grid-template-rows: 1fr;
}
.mainContent {
grid-area: main-content;
}

9
lattice/src/App.test.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
xit('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

58
lattice/src/App.tsx Normal file
View file

@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
import CssBaseline from '@material-ui/core/CssBaseline';
import { Route, Switch } from 'react-router-dom';
import { darkTheme, lightTheme } from 'theme/';
import { Home } from 'App/Home';
import { Header } from 'shared/Header';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { Nav } from 'shared/Nav';
import { NotFound } from 'App/NotFound';
import { MoleculaTablesContainer } from 'App/MoleculaTables';
import { QueryContainer } from 'App/Query';
import { QueryBuilderContainer } from 'App/QueryBuilder';
import css from './App.module.scss';
const App = () => {
const [theme, setTheme] = useState<string>(
localStorage.getItem('theme') || 'light'
);
useEffect(() => {
if(theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark')
} else {
document.documentElement.removeAttribute('data-theme');
}
}, [theme]);
const onToggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<MuiThemeProvider theme={theme === 'light' ? lightTheme : darkTheme}>
<CssBaseline />
<Header onToggleTheme={onToggleTheme} />
<div className={css.container}>
<div className={css.layout}>
<Nav />
<div className={css.mainContent}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/tables/:id?" component={MoleculaTablesContainer} />
<Route exact path="/query" component={QueryContainer} />
<Route exact path="/querybuilder" component={QueryBuilderContainer} />
<Route component={NotFound} />
</Switch>
</div>
</div>
</div>
</MuiThemeProvider>
);
}
export default App;

View file

@ -0,0 +1,24 @@
.header {
display: flex;
align-items: center;
.expandAllIcon {
flex-grow: 1;
text-align: right;
padding-right: 12px;
}
}
.nodes {
padding: 20px 0;
}
.activity {
margin-top: 16px;
}
.pilosaError {
padding: 8px 16px;
margin-top: 20px;
}

View file

@ -0,0 +1,173 @@
import React, { FC, Fragment, useCallback, useEffect, useState } from 'react';
import CollapseAllIcon from '@material-ui/icons/UnfoldLess';
import ExpandAllIcon from '@material-ui/icons/UnfoldMore';
import IconButton from '@material-ui/core/IconButton';
import Paper from '@material-ui/core/Paper';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { CLUSTER_STATUS } from './clusterStatus';
import { ImportStatus } from './ImportStatus';
import { Metrics } from './Metrics';
import { Node } from './Node';
import { pilosa } from 'services/eventServices';
import { StatusIndicator } from 'shared/StatusIndicator';
import { useEffectOnce } from 'react-use';
import css from './ClusterHealth.module.scss';
export const ClusterHealth: FC = () => {
const [cluster, setCluster] = useState<any>();
const [metrics, setMetrics] = useState<any>();
const [info, setInfo] = useState<any>();
const [clusterData, setClusterData] = useState<any>();
const [expanded, setExpanded] = useState<string[]>([]);
const [showMetrics, setShowMetrics] = useState<any>();
const allExpanded = cluster && expanded.length === cluster.nodes.length;
useEffectOnce(() => {
getClusterHealth();
getClusterData();
});
const refreshMetrics = useCallback(() => {
pilosa.get
.metrics()
.then((res) => setMetrics(res.data))
.catch(() => setMetrics(undefined));
}, []);
useEffect(() => {
const interval = setInterval(() => {
if (!clusterData) {
getClusterData();
}
getClusterHealth();
refreshMetrics();
}, 15000);
return () => clearInterval(interval);
}, [refreshMetrics, cluster, clusterData]);
const getClusterHealth = () => {
pilosa.get
.status()
.then((res) => {
setCluster(res.data);
})
.catch(() => setCluster(undefined));
let clusterInfo = {};
pilosa.get
.info()
.then((res) => {
clusterInfo = { ...res.data };
})
.then(() => {
pilosa.get.version().then((res) => {
clusterInfo = { ...clusterInfo, ...res.data };
setInfo(clusterInfo);
});
})
.catch(() => setInfo(undefined));
pilosa.get
.metrics()
.then((res) => setMetrics(res.data))
.catch(() => setMetrics(undefined));
};
const getClusterData = () => {
pilosa.get
.usage()
.then((res) => setClusterData(res.data))
.catch(() => setClusterData(undefined));
};
const toggleAccordion = (nodeId: string) => {
const isExpanded = expanded.includes(nodeId);
if (isExpanded) {
const newExpanded = expanded.filter((n) => n !== nodeId);
setExpanded(newExpanded);
} else {
setExpanded([...expanded, nodeId]);
}
};
const expandAllNodes = () => {
const allNodeIds = cluster.nodes.map((n) => n.id);
setExpanded(allNodeIds);
};
return (
<Fragment>
<Block>
<div className={css.header}>
{cluster ? (
<StatusIndicator
state={cluster.state}
stateDetails={CLUSTER_STATUS[cluster.state]}
margin="both"
/>
) : null}
<Typography variant="h5" color="textSecondary">
Cluster Health
</Typography>
{cluster && cluster.nodes.length > 1 && (
<div className={css.expandAllIcon}>
<Tooltip
title={allExpanded ? 'Collapse all nodes' : 'Expand all nodes'}
placement="left"
arrow
>
{allExpanded ? (
<IconButton size="small" onClick={() => setExpanded([])}>
<CollapseAllIcon aria-label="Collapse all nodes" />
</IconButton>
) : (
<IconButton size="small" onClick={expandAllNodes}>
<ExpandAllIcon aria-label="Expand all nodes" />
</IconButton>
)}
</Tooltip>
</div>
)}
</div>
{cluster && info ? (
<div className={css.nodes}>
{cluster.nodes.map((node) => (
<Node
key={node.id}
node={node}
info={info}
usage={clusterData ? clusterData[node.id] : undefined}
expanded={expanded.includes(node.id)}
onToggle={() => toggleAccordion(node.id)}
onMetricClick={() => setShowMetrics(node)}
/>
))}
</div>
) : (
<Paper className={css.pilosaError}>
<Typography variant="caption" color="textSecondary">
There is a problem connecting to Pilosa.
</Typography>
</Paper>
)}
{metrics ? (
<div className={css.activity}>
<ImportStatus metrics={metrics} />
</div>
) : null}
</Block>
{showMetrics && (
<Metrics
open={!!showMetrics}
onClose={() => setShowMetrics(undefined)}
node={showMetrics}
/>
)}
</Fragment>
);
};

View file

@ -0,0 +1,17 @@
.keyValues {
display: flex;
flex-direction: column;
}
.cell {
display: flex;
flex-direction: column;
padding: 4px;
label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
font-weight: 400;
}
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import css from './ClusterInfo.module.scss';
const info = {
shardWidth: 1048576
};
export const ClusterInfo = () => {
const keys = Object.keys(info);
return (
<div className={css.keyValues}>
{keys.map((key) => (
<div key={key} className={css.cell}>
<label>{key}</label>
<div>{info[key]}</div>
</div>
))}
</div>
);
};

View file

@ -0,0 +1 @@
export * from './ClusterInfo';

View file

@ -0,0 +1,29 @@
.activityLayout {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 250px;
grid-gap: 32px;
}
.tooltip {
padding: 4px 8px;
background: #ffffff;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.05);
.metric {
display: flex;
justify-content: space-between;
margin: 4px 0;
}
.metricName {
margin-right: 20px;
}
}
[data-theme='dark'] {
.tooltip {
background: #1c2022;
}
}

View file

@ -0,0 +1,284 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import sub from 'date-fns/sub';
import format from 'date-fns/format';
import isUndefined from 'lodash/isUndefined';
import Typography from '@material-ui/core/Typography';
import { ResponsiveLine as Line } from '@nivo/line';
import { metricsList } from './helpers';
import { useTheme } from '@material-ui/core/styles';
import css from './ImportStatus.module.scss';
import add from 'date-fns/add';
type ImportStatusType = {
metrics: any;
};
export const ImportStatus: FC<ImportStatusType> = ({ metrics }) => {
const theme = useTheme();
const isDark = theme.palette.type === 'dark';
const [time, setTime] = useState<Date>(new Date());
const [data, setData] = useState<any>();
useEffect(() => {
const nodes = Object.keys(metrics);
let newData = data ? { ...data } : {};
let importMetrics = {};
nodes.forEach((node) => {
metrics[node].forEach((metric) => {
if (metricsList.includes(metric.name)) {
let aggregateValue = importMetrics[metric.name]
? importMetrics[metric.name].value
: 0;
metric.metrics.forEach((m) => {
aggregateValue = aggregateValue + Number(m.value);
});
importMetrics = {
...importMetrics,
[metric.name]: {
x: time,
value: aggregateValue
}
};
}
});
});
metricsList.forEach((metric) => {
if (!newData[metric]) {
newData[metric] = [];
}
if (importMetrics[metric]) {
newData[metric] = [...newData[metric], importMetrics[metric]];
} else {
newData[metric] = [
...newData[metric],
{
x: time,
y: 0,
value: 0
}
];
}
newData[metric].forEach((node, i) => {
const prevValue = i > 0 ? newData[metric][i - 1].value : undefined;
if (isUndefined(prevValue)) {
newData[metric][i] = { ...newData[metric][i], y: 0 };
} else {
newData[metric][i] = {
...newData[metric][i],
y: ((node.value - prevValue) / 15).toFixed(1)
};
}
});
if (newData[metric].length > 60) {
newData[metric].splice(0, 1);
}
});
setTime((t) => add(t, { seconds: 15 }));
setData(newData);
}, [metrics]);
return (
<Fragment>
<Typography variant="h5" color="textSecondary">
Activity
</Typography>
{data ? (
<div className={css.activityLayout}>
<div>
<Line
colors={{ scheme: 'dark2' }}
theme={
isDark
? {
textColor: 'var(--text-secondary)',
axis: {
domain: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } }
},
grid: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } },
tooltip: { container: { background: '#1c2022' } }
}
: {
axis: {
domain: { line: { stroke: '#dddddd' } }
}
}
}
margin={{ top: 20, right: 20, bottom: 45, left: 80 }}
data={[
{
id: 'pilosa_set_bit_total (set via PQL)',
data: data['pilosa_set_bit_total']
},
{
id: 'pilosa_importing_total (set requests via import API)',
data: data['pilosa_importing_total']
},
{
id:
'pilosa_imported_total (actual changed bits via import API)',
data: data['pilosa_imported_total']
}
]}
axisLeft={{
// TODO: potentially format ticks values differently
// format: (value) => formatBytes(Number(value), 0)
legend: 'bits per sec',
legendPosition: 'middle',
legendOffset: -55,
format: (value) => value.toLocaleString()
}}
yFormat={(value) => `${value.toLocaleString()} bits/sec`}
enableGridX={false}
xScale={{ type: 'time', min: sub(time, { minutes: 15 }) }}
axisBottom={{
legend: 'Import (set) stats',
legendPosition: 'middle',
legendOffset: 35,
tickValues: 4,
format: (value) => format(value as Date, 'h:mm aaaa')
}}
enableSlices="x"
sliceTooltip={({ slice }) => (
<div className={css.tooltip}>
<Typography variant="body2" color="textSecondary" paragraph>
<strong>
{format(
slice.points[0].data.x as Date,
'M/d/yyyy h:mm:ss aaaa'
)}
</strong>
</Typography>
<div>
{slice.points.map((p) => {
const label = p.serieId.toString();
const splitIdx = label.indexOf(' ');
return (
<div key={p.serieId} className={css.metric}>
<div
className={css.metricName}
style={{ color: p.serieColor }}
>
<strong>{label.substring(0, splitIdx)}</strong>
<Typography variant="caption" component="div">
{label.substring(splitIdx + 1)}
</Typography>
</div>
<span style={{ color: p.serieColor }}>
{p.data.yFormatted}
</span>
</div>
);
})}
</div>
</div>
)}
curve="monotoneX"
animate={false}
/>
</div>
<div>
<Line
colors={{ scheme: 'dark2' }}
theme={
isDark
? {
textColor: 'var(--text-secondary)',
axis: {
domain: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } }
},
grid: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } },
tooltip: { container: { background: '#1c2022' } }
}
: {
axis: {
domain: { line: { stroke: '#dddddd' } }
}
}
}
margin={{ top: 20, right: 20, bottom: 45, left: 80 }}
data={[
{
id: 'pilosa_clear_bit_total ',
data: data['pilosa_clear_bit_total']
},
{
id: 'pilosa_clearing_total ',
data: data['pilosa_clearing_total']
},
{
id: 'pilosa_cleared_total ',
data: data['pilosa_cleared_total']
}
]}
axisLeft={{
// TODO: potentially format ticks values differently
// format: (value) => formatBytes(Number(value), 0)
legend: 'bits per sec',
legendPosition: 'middle',
legendOffset: -55,
format: (value) => value.toLocaleString()
}}
yFormat={(value) => `${value.toLocaleString()} bits/sec`}
enableGridX={false}
xScale={{ type: 'time', min: sub(time, { minutes: 15 }) }}
axisBottom={{
legend: 'Import (clear) stats',
legendPosition: 'middle',
legendOffset: 35,
tickValues: 4,
format: (value) => format(value as Date, 'h:mm aaaa')
}}
enableSlices="x"
sliceTooltip={({ slice }) => (
<div className={css.tooltip}>
<Typography variant="body2" color="textSecondary" paragraph>
<strong>
{format(
slice.points[0].data.x as Date,
'M/d/yyyy h:mm:ss aaaa'
)}
</strong>
</Typography>
<div>
{slice.points.map((p) => {
const label = p.serieId.toString();
const splitIdx = label.indexOf(' ');
return (
<div key={p.serieId} className={css.metric}>
<div
className={css.metricName}
style={{ color: p.serieColor }}
>
<strong>{label.substring(0, splitIdx)}</strong>
<Typography variant="caption" component="div">
{label.substring(splitIdx + 1)}
</Typography>
</div>
<span style={{ color: p.serieColor }}>
{p.data.yFormatted}
</span>
</div>
);
})}
</div>
</div>
)}
curve="monotoneX"
animate={false}
/>
</div>
</div>
) : null}
</Fragment>
);
};

View file

@ -0,0 +1,8 @@
export const metricsList = [
'pilosa_set_bit_total',
'pilosa_clear_bit_total',
'pilosa_importing_total',
'pilosa_imported_total',
'pilosa_clearing_total',
'pilosa_cleared_total'
];

View file

@ -0,0 +1 @@
export * from './ImportStatus';

View file

@ -0,0 +1,119 @@
.infoMessage {
padding: 16px;
border: 1px solid rgba(var(--primary-rgb), 0.5);
background: rgba(var(--primary-rgb), 0.1);
border-radius: 4px;
margin-bottom: 16px;
}
.metricsLabel {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
font-weight: 400;
margin-right: 8px;
}
.clearFilter {
padding-left: 8px;
border-left: 1px solid rgba(var(--contrast-rgb), 0.27);
}
.filters {
display: flex;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--divider);
.typeFilter {
margin-left: 16px;
background: transparent;
}
}
.key {
font-weight: 600;
}
.highlight {
background: rgba(var(--primary-rgb), 0.5);
color: var(--contrast);
padding: 0 1px;
}
.metricsList {
padding: 8px;
.metricsHeader {
display: flex;
align-items: center;
line-height: 1;
}
.loading {
display: flex;
align-items: center;
}
.loadingIcon {
margin: 0 2px 0 8px;
}
}
.reset {
color: var(--primary);
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
.metricType {
float: right;
}
.metricItem {
margin-bottom: 48px;
}
.metricName {
margin-right: 8px;
font-size: 1rem;
}
.dataTable {
padding: 8px;
background: rgba(var(--default-rgb), 0.15);
.tableValue {
padding: 8px;
vertical-align: top;
pre,
code {
margin: 0;
font-size: 11px;
}
}
.tableRow:last-child {
.tableValue {
border-bottom: 0;
}
}
}
.dialogPaper {
height: calc(100% - 64px);
}
.refreshMetrics {
float: right;
}
[data-theme='dark'] {
.dataTable {
background: rgba(var(--base-rgb), 0.1);
}
}

View file

@ -0,0 +1,334 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import Checkbox from '@material-ui/core/Checkbox';
import Chip from '@material-ui/core/Chip';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import FormControl from '@material-ui/core/FormControl';
import FormControlLabel from '@material-ui/core/FormControlLabel';
import FormGroup from '@material-ui/core/FormGroup';
import Fuse from 'fuse.js';
import Highlighter from 'react-highlight-words';
import IconButton from '@material-ui/core/IconButton';
import Link from '@material-ui/core/Link';
import Pluralize from 'react-pluralize';
import RefreshIcon from '@material-ui/icons/Refresh';
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 TextField from '@material-ui/core/TextField';
import Tooltip from '@material-ui/core/Tooltip';
import { pilosa } from 'services/eventServices';
import { priorityMetrics } from './priorityMetrics';
import { Typography } from '@material-ui/core';
import { useEffectOnce } from 'react-use';
import css from './Metrics.module.scss';
type MetricsProps = {
open: boolean;
node: any;
onClose: () => void;
};
export const Metrics: FC<MetricsProps> = ({ open, node, onClose }) => {
const [loading, setLoading] = useState<boolean>(false);
const [searchText, setSearchText] = useState<string>('');
const [showTypes, setShowTypes] = useState<string[]>([
'gauge',
'counter',
'summary'
]);
const [metrics, setMetrics] = useState<any>();
const [filteredMetrics, setFilteredMetrics] = useState<any>([]);
useEffectOnce(() => getMetrics());
useEffect(() => {
if (metrics) {
let filteredResults = metrics;
if (searchText.length > 1) {
const fuse = new Fuse(metrics, {
keys: ['name', 'help'],
minMatchCharLength: 2,
ignoreLocation: true,
threshold: 0
});
const result = fuse.search(searchText);
filteredResults = [];
result.forEach((r: any) => {
filteredResults.push(r?.item);
});
}
const typeFiltered = filteredResults.filter((node) =>
showTypes.includes(node.type.toLowerCase())
);
setFilteredMetrics(typeFiltered);
}
}, [showTypes, searchText, metrics]);
const getMetrics = () => {
pilosa.get
.metrics()
.then((res) => setMetrics(res.data[node.id]))
.catch(() => setMetrics(undefined));
setTimeout(() => {
setLoading(false);
}, 500);
};
const handleTypeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const name = event.target.name;
if (showTypes.includes(name)) {
const newShowTypes = showTypes.filter((type) => type !== name);
setShowTypes(newShowTypes);
} else {
setShowTypes([...showTypes, name]);
}
};
const renderValue = (value: any) => {
if (typeof value === 'object') {
return Object.keys(value).length > 1 ? (
<pre>{JSON.stringify(value, null, 2)}</pre>
) : (
<code>{JSON.stringify(value, null, 2)}</code>
);
} else if (!isNaN(Number(value))) {
return <code>{Number(value).toLocaleString()}</code>;
}
return <code>value</code>;
};
const renderFilter = () => (
<FormControl component="div" className={css.typeFilter}>
<FormGroup row>
<FormControlLabel
control={
<Checkbox
checked={showTypes.includes('gauge')}
onChange={handleTypeChange}
name="gauge"
size="small"
/>
}
label={<Typography variant="caption">Gauge</Typography>}
/>
<FormControlLabel
control={
<Checkbox
checked={showTypes.includes('counter')}
onChange={handleTypeChange}
name="counter"
size="small"
/>
}
label={<Typography variant="caption">Counter</Typography>}
/>
<FormControlLabel
control={
<Checkbox
checked={showTypes.includes('summary')}
onChange={handleTypeChange}
name="summary"
size="small"
/>
}
label={<Typography variant="caption">Summary</Typography>}
/>
</FormGroup>
</FormControl>
);
const renderMetric = (item: any) => {
const metricsKeys = Object.keys(item.metrics[0]);
return (
<div key={item.name} className={css.metricItem}>
<Chip
size="small"
label={item.type}
variant="outlined"
className={css.metricType}
/>
<div className={css.metricName}>
<Highlighter
highlightClassName={css.highlight}
searchWords={searchText.length > 1 ? [searchText] : []}
textToHighlight={item.name}
autoEscape={true}
/>
</div>
<Typography variant="caption" color="textSecondary" paragraph>
<Highlighter
highlightClassName={css.highlight}
searchWords={searchText.length > 1 ? [searchText] : []}
textToHighlight={item.help}
autoEscape={true}
/>
</Typography>
<TableContainer className={css.dataTable}>
<Table size="small">
<TableHead>
<TableRow>
{metricsKeys.map((key) => (
<TableCell key={`header=${key}`}>
<Typography variant="overline" color="textSecondary">
{key}
</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{item.metrics.map((metric, idx) => (
<TableRow key={`metric-${idx}`} className={css.tableRow}>
{metricsKeys.map((key) => (
<TableCell
key={`metric-${idx}-${key}`}
className={css.tableValue}
>
{renderValue(metric[key])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
PaperProps={{ className: css.dialogPaper }}
fullWidth
>
<DialogTitle>
Metrics:{' '}
<code>
{node.uri.host}:{node.uri.port}
</code>
<Tooltip
title={loading ? 'Refreshing...' : 'Refresh Metrics'}
placement="left"
arrow
>
<IconButton
size="small"
onClick={() => {
setLoading(true);
getMetrics();
}}
className={css.refreshMetrics}
>
<RefreshIcon aria-label="Refresh Metrics" />
</IconButton>
</Tooltip>
</DialogTitle>
<DialogContent>
<div className={css.infoMessage}>
Molecula provides metrics for use with Prometheus. Instantaneous
metrics are shown here for convenience; to take full advantage of this
feature, consider using a Prometheus instance, perhaps with a{' '}
<Link
href="https://prometheus.io/docs/visualization/grafana/"
target="_blank"
>
Grafana dashboard
</Link>
.
</div>
<div>
<label className={css.metricsLabel}>Filter Metrics</label>
{searchText.length > 1 && (
<Typography className={css.clearFilter} variant="caption">
<span className={css.reset} onClick={() => setSearchText('')}>
Clear filter
</span>
</Typography>
)}
</div>
<div className={css.filters}>
<TextField
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search for Metric"
variant="outlined"
size="small"
/>
{renderFilter()}
</div>
<div className={css.metricsList}>
{filteredMetrics.length > 0 && (
<Fragment>
<Typography
variant="caption"
color="textSecondary"
component="div"
className={css.metricsHeader}
paragraph
>
<div>
Showing{' '}
<Pluralize singular="metric" count={filteredMetrics.length} />
</div>
{loading ? (
<div className={css.loading}>
<RefreshIcon
fontSize="inherit"
className={css.loadingIcon}
/>
<span>Refreshing...</span>
</div>
) : null}
</Typography>
{filteredMetrics.map((metric) => {
const isPriority = priorityMetrics.includes(metric.name);
if (isPriority) {
return renderMetric(metric);
} else {
return null;
}
})}
{filteredMetrics.map((metric) => {
const isPriority = priorityMetrics.includes(metric.name);
if (!isPriority) {
return renderMetric(metric);
} else {
return null;
}
})}
</Fragment>
)}
{filteredMetrics.length === 0 && (
<Typography
className={css.noResults}
variant="caption"
color="textSecondary"
component="div"
>
No metrics to show.{` `}
{searchText && (
<span className={css.reset} onClick={() => setSearchText('')}>
Clear filter
</span>
)}
</Typography>
)}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1 @@
export * from './Metrics';

View file

@ -0,0 +1,10 @@
export const priorityMetrics = [
'go_goroutines',
'go_threads',
'pilosa_http_request_duration_seconds',
'pilosa_maximum_shard',
'pilosa_query_count_total',
'process_open_fds',
'process_resident_memory_bytes',
'process_virtual_memory_bytes'
];

View file

@ -0,0 +1,160 @@
.header {
display: flex;
flex-grow: 1;
align-items: center;
.copyIcon,
.metricsIcon {
margin-left: 4px;
font-size: 0.75rem;
opacity: 0.3;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
}
.details {
padding: 0px 40px 16px 35px;
}
.node {
width: 100%;
.nodeId {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.label {
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
margin-right: 4px;
}
.value {
padding: 2px 6px;
border-radius: 4px;
transition: background-color 0.3s ease;
font-size: 0.75rem;
&:hover {
background: rgba(var(--base-rgb), 0.2);
cursor: pointer;
}
}
.tableHeader {
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
border-bottom: 1px solid rgba(var(--contrast-rgb), 0.1);
}
}
.nodeUsage {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 32px;
}
.tableHeader,
.tableRow {
display: flex;
padding: 4px 0;
.scheme,
.host,
.port {
flex: 2;
}
&:last-child {
border-bottom: 0;
}
> div {
flex: 1;
display: flex;
justify-items: center;
flex-direction: column;
}
}
.tableRow {
.key {
span {
padding-left: 4px;
}
}
&:nth-child(even) {
background: rgba(var(--default-rgb), 0.15);
}
}
.nodeSettings {
display: flex;
flex-wrap: wrap;
}
.cell {
display: flex;
flex-direction: column;
white-space: nowrap;
margin-bottom: 32px;
&:not(:last-child) {
padding-right: 4%;
}
label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
display: flex;
align-items: center;
line-height: 1;
}
.nodeInfoLabel {
margin-right: 4px;
}
}
.totalCapacity {
display: flex;
align-items: center;
width: 100%;
height: 13px;
border-radius: 4px;
background: rgba(var(--contrast-rgb), 0.1);
margin: 4px 0 32px;
.totalInUse {
height: 13px;
border-radius: 4px 0 0 4px;
background: rgba(var(--primary-rgb), 0.7);
}
.unknownCapacity {
font-size: 10px;
line-height: 13px;
margin-left: 8px;
}
}
.metricsLink {
margin-top: 32px;
}
[data-theme='dark'] {
.tableRow {
&:nth-child(even) {
background: rgba(var(--base-rgb), 0.1);
}
}
}

View file

@ -0,0 +1,320 @@
import React, { FC, Fragment, useState } from 'react';
import Button from '@material-ui/core/Button';
import copy from 'copy-to-clipboard';
import EqualizerIcon from '@material-ui/icons/EqualizerSharp';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Accordion from '@material-ui/core/Accordion';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import FileCopySharpIcon from '@material-ui/icons/FileCopySharp';
import Find from 'lodash/find';
import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/Info';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { formatBytes } from 'shared/utils/formatBytes';
import { nodeInfo } from './nodeInfo';
import { NODE_STATE } from './nodeStatus';
import { StatusIndicator } from 'shared/StatusIndicator';
import css from './Node.module.scss';
type NodeType = {
node: any;
info: any;
usage: any;
expanded: boolean;
onToggle: () => void;
onMetricClick: () => void;
};
export const Node: FC<NodeType> = ({
node,
info,
usage,
expanded,
onToggle,
onMetricClick
}) => {
const [copyHost, setCopyHost] = useState<string>('Copy Host');
const [copyID, setCopyID] = useState<string>('Click to Copy');
const { id, isPrimary, state } = node;
const diskTotalInUse = usage?.diskUsage?.totalInUse;
const diskCapacity = usage?.diskUsage?.capacity;
const diskUsagePercentage = diskCapacity
? (diskTotalInUse / diskCapacity) * 100
: undefined;
const memoryTotalInUse = usage?.memoryUsage?.totalInUse;
const memoryCapacity = usage?.memoryUsage?.capacity;
const memoryUsagePercentage = memoryCapacity
? (memoryTotalInUse / memoryCapacity) * 100
: undefined;
const keys = Object.keys(info);
const onCopyHostClick = () => {
copy(`${node.uri.host}:${node.uri.port}`);
setCopyHost('Copied!');
setTimeout(() => {
setCopyHost('Copy Host');
}, 1500);
};
const onCopyIdClick = () => {
copy(id);
setCopyID('Copied!');
setTimeout(() => {
setCopyID('Click to Copy');
}, 1500);
};
return (
<Accordion expanded={expanded} onChange={onToggle}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<div className={css.header}>
<StatusIndicator
state={state}
stateDetails={NODE_STATE[state]}
margin="right"
size="small"
/>
<code>{`${node.uri.host}:${node.uri.port}`}</code>
<Tooltip title={copyHost} placement="top" arrow>
<IconButton
className={css.copyIcon}
size="small"
color="inherit"
onClick={(e) => {
e.stopPropagation();
onCopyHostClick();
}}
>
<FileCopySharpIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</div>
{isPrimary ? <span>(Primary)</span> : null}
</AccordionSummary>
<AccordionDetails className={css.details}>
<div className={css.node}>
<div className={css.nodeId}>
<span className={css.label}>Node Id:</span>
<Tooltip title={copyID} placement="top" arrow>
<span className={css.value} onClick={onCopyIdClick}>
{node.id}
</span>
</Tooltip>
</div>
<div className={css.nodeUsage}>
<div>
<div className={css.label}>Disk Usage:</div>
<div>
{usage ? (
<Fragment>
<Typography variant="caption">
{formatBytes(diskTotalInUse)}
{diskCapacity
? ` used out of ${formatBytes(diskCapacity)}`
: null}
</Typography>
<div className={css.totalCapacity}>
{diskUsagePercentage ? (
<Tooltip
title={
<Typography variant="caption">
{diskUsagePercentage < 1
? '< 1'
: diskUsagePercentage.toLocaleString(
undefined,
{ maximumFractionDigits: 1 }
)}
% used
</Typography>
}
placement="top"
arrow
>
<div
className={css.totalInUse}
style={{
width: `${
diskUsagePercentage < 1
? 1
: diskUsagePercentage
}%`
}}
/>
</Tooltip>
) : (
<Fragment>
<Tooltip
title={
<Typography variant="caption">
{formatBytes(diskTotalInUse)} used
</Typography>
}
placement="top"
arrow
>
<div
className={css.totalInUse}
style={{ width: '2%' }}
/>
</Tooltip>
<Typography
className={css.unknownCapacity}
variant="caption"
color="textSecondary"
>
Node disk capacity unknown
</Typography>
</Fragment>
)}
</div>
</Fragment>
) : (
<Typography variant="caption" paragraph>
Calculating...
</Typography>
)}
</div>
</div>
<div>
<div className={css.label}>Memory Usage:</div>
<div>
{usage ? (
<Fragment>
<Typography variant="caption">
{formatBytes(memoryTotalInUse)}
{memoryCapacity
? ` used out of ${formatBytes(memoryCapacity)}`
: null}
</Typography>
<div className={css.totalCapacity}>
{memoryUsagePercentage ? (
<Tooltip
title={
<Typography variant="caption">
{memoryUsagePercentage < 1
? '< 1'
: memoryUsagePercentage.toLocaleString(
undefined,
{ maximumFractionDigits: 1 }
)}
% used
</Typography>
}
placement="top"
arrow
>
<div
className={css.totalInUse}
style={{
width: `${
memoryUsagePercentage < 1
? 1
: memoryUsagePercentage
}%`
}}
/>
</Tooltip>
) : (
<Fragment>
<Tooltip
title={
<Typography variant="caption">
{formatBytes(memoryTotalInUse)} used
</Typography>
}
placement="top"
arrow
>
<div
className={css.totalInUse}
style={{ width: '2%' }}
/>
</Tooltip>
<Typography
className={css.unknownCapacity}
variant="caption"
color="textSecondary"
>
Node memory capacity unknown
</Typography>
</Fragment>
)}
</div>
</Fragment>
) : (
<Typography variant="caption" paragraph>
Calculating...
</Typography>
)}
</div>
</div>
</div>
<div className={css.nodeSettings}>
{keys.map((key) => {
const showNode = Find(nodeInfo, (node) => node.name === key);
if (showNode) {
return (
<div key={key} className={css.cell}>
<label>
<span className={css.nodeInfoLabel}>{key}</span>
{showNode.tooltip ? (
<Tooltip title={showNode.tooltip} placement="top" arrow>
<InfoIcon fontSize="inherit" />
</Tooltip>
) : null}
</label>
<div>
{key === 'memory'
? formatBytes(info[key])
: info[key].toLocaleString()}
</div>
</div>
);
}
return null;
})}
</div>
<div className={css.tableHeader}>
<div className={css.key} />
<div className={css.scheme}>Scheme</div>
<div className={css.host}>Host</div>
<div className={css.port}>Port</div>
</div>
{node.uri && (
<div className={css.tableRow}>
<div className={css.key}>
<span>uri</span>
</div>
<div className={css.scheme}>{node.uri.scheme}</div>
<div className={css.host}>{node.uri.host}</div>
<div className={css.port}>{node.uri.port}</div>
</div>
)}
{node['grpc-uri'] && (
<div className={css.tableRow}>
<div className={css.key}>
<span>grpc-uri</span>
</div>
<div className={css.scheme}>{node['grpc-uri'].scheme}</div>
<div className={css.host}>{node['grpc-uri'].host}</div>
<div className={css.port}>{node['grpc-uri'].port}</div>
</div>
)}
<div className={css.metricsLink}>
<Button
size="small"
variant="contained"
onClick={onMetricClick}
startIcon={<EqualizerIcon />}
>
View Metrics
</Button>
</div>
</div>
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1 @@
export * from './Node';

View file

@ -0,0 +1,10 @@
export const nodeInfo = [
{ name: 'shardWidth', tooltip: 'Number of records in a shard, which affects query performance (concurrency): maxConcurrency = ShardCount = RecordCount/ShardWidth.' },
{ name: 'replicaN', tooltip: 'Total number of data copies that are distributed around the cluster. Higher values allow reads to continue with some node failures, lower values decrease data footprint.' },
{ name: 'cpuType', tooltip: '' },
{ name: 'cpuPhysicalCores', tooltip: '' },
{ name: 'cpuLogicalCores', tooltip: '' },
{ name: 'cpuMHz', tooltip: '' },
{ name: 'txSrc', tooltip: 'Storage engine for bitmap data.' },
{ name: 'version', tooltip: '' }
];

View file

@ -0,0 +1,31 @@
type nodeStates = 'STARTING' | 'STARTED' | 'RESIZING' | 'UNKNOWN' | 'READY' | 'DOWN';
export const NODE_STATE: {
[key in nodeStates]: { label: string; status: string };
} = {
STARTING: {
label: '',
status: 'info'
},
STARTED: {
label: '',
status: 'success'
},
RESIZING: {
label: '',
status: 'warning'
},
UNKNOWN: {
label: 'Unable to get node status',
status: 'disabled'
},
// deprecated
READY: {
label: '',
status: 'success'
},
DOWN: {
label: '',
status: 'disabled'
}
}

View file

@ -0,0 +1,32 @@
type clusterStatuses = 'NORMAL' | 'DEGRADED' | 'STARTING' | 'RESIZING' | 'DOWN' | 'UNKNOWN';
export const CLUSTER_STATUS: {
[key in clusterStatuses]: { label: string; status: string };
} = {
NORMAL: {
label: 'All nodes are up, cluster is healthy.',
status: 'success'
},
DEGRADED: {
label:
'Some nodes are down but all data is available and queries can still be answered, but performance may be worse.',
status: 'warning'
},
STARTING: {
label: 'Some nodes are up, but not enough to answer queries.',
status: 'error'
},
RESIZING: {
label:
'Cluster is resizing. Most endpoints are unavailable until the resize completes.',
status: 'info'
},
DOWN: {
label: 'Cluster is unable to serve queries.',
status: 'disabled'
},
UNKNOWN: {
label: 'Unable to get cluster status.',
status: 'disabled'
}
};

View file

@ -0,0 +1 @@
export * from './ClusterHealth';

View file

@ -0,0 +1,3 @@
.prodPadSpacer {
padding-bottom: 38px;
}

View file

@ -0,0 +1,14 @@
import React, { FC, Fragment } from 'react';
import { ClusterHealth } from 'App/Home/ClusterHealth';
import { QueryHistory } from 'App/Home/QueryHistory';
import { Transactions } from 'App/Home/Transactions';
import css from './Home.module.scss';
export const Home: FC = () => (
<Fragment>
<ClusterHealth />
<Transactions />
<QueryHistory />
<div className={css.prodPadSpacer} />
</Fragment>
);

View file

@ -0,0 +1,28 @@
.wrapper {
display: inline-block;
margin: 0 8px;
}
.indicator {
width: 15px;
height: 15px;
margin: -2px 0 0 -2px;
border-radius: 50%;
position: relative;
transition: opacity 0.3s ease;
&.clickable:hover {
cursor: pointer;
}
}
.indicator::after {
content: ' ';
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
position: absolute;
}

View file

@ -0,0 +1,62 @@
import React, { FC } from 'react';
import classNames from 'classnames';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { nodeRGBColors } from '../nodeColors';
import css from './NodeIndicator.module.scss';
type NodeIndicatorProps = {
node: string;
nodeIdx: number;
tooltip?: boolean;
off?: boolean;
onClick?: () => void;
};
export const NodeIndicator: FC<NodeIndicatorProps> = ({
node,
nodeIdx,
tooltip = false,
off = false,
onClick = undefined
}) => {
if (tooltip) {
return (
<Tooltip
title={
<Typography variant="caption" component="div">
{node}
</Typography>
}
placement="top"
arrow
>
<div className={css.wrapper}>
<div
className={classNames(css.indicator, { [css.clickable]: onClick })}
style={{
backgroundColor: `rgb(${nodeRGBColors[nodeIdx]})`,
boxShadow: `inset -2px -2px 3px rgba(var(--contrast-rgb), 0.1), 0 0 4px 4px rgba(${nodeRGBColors[nodeIdx]}, 0.1)`,
opacity: off ? 0.3 : 1
}}
onClick={onClick}
></div>
</div>
</Tooltip>
);
} else {
return (
<div className={css.wrapper}>
<div
className={classNames(css.indicator, { [css.clickable]: onClick })}
style={{
backgroundColor: `rgb(${nodeRGBColors[nodeIdx]})`,
boxShadow: `inset -2px -2px 3px rgba(var(--contrast-rgb), 0.1), 0 0 4px 4px rgba(${nodeRGBColors[nodeIdx]}, 0.1)`,
opacity: off ? 0.3 : 1
}}
onClick={onClick}
></div>
</div>
);
}
};

View file

@ -0,0 +1 @@
export * from './NodeIndicator';

View file

@ -0,0 +1,28 @@
.actions {
display: flex;
padding: 20px 0;
}
.sortByList {
padding: 0;
}
.nodeFilter {
margin-left: 32px;
}
.filterLabel {
font-size: 12px;
color: var(--text-secondary);
position: relative;
top: -11px;
}
.indicators {
position: relative;
top: -4px;
}
.noQueries {
padding: 12px 16px;
}

View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import Paper from '@material-ui/core/Paper';
import reverse from 'lodash/reverse';
import sortBy from 'lodash/sortBy';
import Typography from '@material-ui/core/Typography';
import uniqBy from 'lodash/uniqBy';
import { Block } from 'shared/Block';
import { NodeIndicator } from './NodeIndicator';
import { pilosa } from 'services/eventServices';
import { QueryItem } from './QueryItem';
import { SortBy } from 'shared/SortBy';
import { useEffectOnce } from 'react-use';
import css from './QueryHistory.module.scss';
export const QueryHistory = () => {
const [queries, setQueries] = useState<any[]>([]);
const [nodes, setNodes] = useState<string[]>([]);
const [hideNodes, setHideNodes] = useState<string[]>([]);
useEffectOnce(() => getQueries());
const getQueries = () => {
pilosa.get.queryHistory().then((res) => {
setQueries(res.data);
const uniqNodes = uniqBy(Array.from(res.data, (i: any) => i.nodeID));
setNodes(uniqNodes);
});
};
const handleSortChange = (value: any) => {
if (value === 'runtime-desc') {
setQueries(sortBy(queries, 'runtime'));
} else if (value === 'runtime-asc') {
setQueries(reverse(sortBy(queries, 'runtime')));
} else {
setQueries(sortBy(queries, 'start'));
}
};
const handleNodeFilterClick = (node: string) => {
if (hideNodes.includes(node)) {
const newNodes = hideNodes.filter((n) => n !== node);
setHideNodes(newNodes);
} else {
setHideNodes([...hideNodes, node]);
}
};
return (
<Block>
<div className={css.header}>
<Typography variant="h5" color="textSecondary">
Recent Query History
</Typography>
</div>
<div className={css.actions}>
<SortBy
options={[
{ label: 'Most Recent', value: 'starttime' },
{ label: 'Slowest Runtime', value: 'runtime-asc' },
{ label: 'Fastest Runtime', value: 'runtime-desc' }
]}
defaultValue="starttime"
onChange={handleSortChange}
sortId="sort-by"
/>
{nodes.length > 1 ? (
<div className={css.nodeFilter}>
<label className={css.filterLabel}>Filter by Node</label>
<div className={css.indicators}>
{nodes.map((node, idx) => (
<NodeIndicator
key={`node-ind-${node}`}
node={node}
nodeIdx={idx}
tooltip={true}
off={hideNodes.includes(node)}
onClick={() => handleNodeFilterClick(node)}
/>
))}
</div>
</div>
) : null}
</div>
<div>
{queries
.filter((q) => !hideNodes.includes(q.nodeID))
.map((query) => (
<QueryItem
key={query.start}
item={query}
nodeIdx={nodes.indexOf(query.nodeID)}
/>
))}
{queries.filter((q) => !hideNodes.includes(q.nodeID)).length === 0 ? (
<Paper className={css.noQueries}>
<Typography variant="caption" color="textSecondary">
No recent queries.
</Typography>
</Paper>
) : null}
</div>
</Block>
);
};

View file

@ -0,0 +1,87 @@
.item {
.details {
display: block;
}
}
.summary {
width: calc(100% - 84px);
}
.durationWrapper {
min-width: 75px;
}
.duration {
margin-right: 8px;
border-radius: 4px;
background: rgba(var(--primary-rgb), 0.3);
font-size: 11px;
padding: 3px 4px 2px;
line-height: 20px;
}
.queryHeader {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
white-space: nowrap;
display: flex;
align-items: center;
}
.queryString {
text-overflow: ellipsis;
}
.fullQuery {
display: block;
word-wrap: break-word;
font-size: 11px;
}
.pqlTranslation {
margin-top: 8px;
label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
}
.showHideLink {
color: var(--primary);
font-size: 11px;
&:hover {
cursor: pointer;
text-decoration: underline;
}
}
.metadata {
display: flex;
.cell {
display: flex;
flex-direction: column;
white-space: nowrap;
margin-bottom: 16px;
&:not(:last-child) {
padding-right: 4%;
}
label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
}
}
.nodeId {
display: flex;
align-items: center;
}
}

View file

@ -0,0 +1,87 @@
import React, { FC, Fragment, useState } from 'react';
import Accordion from '@material-ui/core/Accordion';
import AccordionSummary from '@material-ui/core/AccordionSummary';
import AccordionDetails from '@material-ui/core/AccordionDetails';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Moment from 'react-moment';
import { formatDuration } from 'shared/utils/formatDuration';
import { NodeIndicator } from '../NodeIndicator';
import css from './QueryItem.module.scss';
type QueryItemProps = {
item: any;
nodeIdx: number;
};
export const QueryItem: FC<QueryItemProps> = ({ item, nodeIdx }) => {
const [expanded, setExpanded] = useState<boolean>(false);
const [showFullQuery, setShowFullQuery] = useState<boolean>(false);
const query = item.SQL ? item.SQL : `[${item.index}]${item.PQL}`;
return (
<Accordion
expanded={expanded}
onChange={() => setExpanded(!expanded)}
className={css.item}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
classes={{ content: css.summary }}
>
<code className={css.queryHeader}>
<span className={css.durationWrapper}>
<span className={css.duration}>
{formatDuration(item.runtimeNanoseconds, true)}
</span>
</span>
<NodeIndicator node={item.nodeID} nodeIdx={nodeIdx} />
<span className={css.queryString}>{query}</span>
</code>
</AccordionSummary>
<AccordionDetails className={css.details}>
<div className={css.metadata}>
<div className={css.cell}>
<label>Start Time</label>
<div>
<Moment date={item.start} format="MMM D, YYYY hh:mm:ss A" />
</div>
</div>
<div className={css.cell}>
<label>Index</label>
<div>{item.index}</div>
</div>
<div className={css.cell}>
<label>Node ID</label>
{item.nodeID}
</div>
</div>
<div>
{showFullQuery ? (
<Fragment>
<code className={css.fullQuery}>{query}</code>
{item.SQL ? (
<div className={css.pqlTranslation}>
<label>PQL translation</label>
<code className={css.fullQuery}>{item.PQL}</code>
</div>
) : null}
<span
className={css.showHideLink}
onClick={() => setShowFullQuery(false)}
>
Hide Query
</span>
</Fragment>
) : (
<span
className={css.showHideLink}
onClick={() => setShowFullQuery(true)}
>
Show Full Query
</span>
)}
</div>
</AccordionDetails>
</Accordion>
);
};

View file

@ -0,0 +1 @@
export * from './QueryItem';

View file

@ -0,0 +1 @@
export * from './QueryHistory';

View file

@ -0,0 +1,10 @@
export const nodeRGBColors = [
'47,75,124',
'102,81,145',
'160,81,149',
'212,80,135',
'249,93,106',
'255,124,67',
'255,166,0',
'0,63,92',
];

View file

@ -0,0 +1,76 @@
.header {
display: flex;
align-items: center;
.label {
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
margin-right: 4px;
}
.value {
transition: background-color 0.3s ease;
font-size: 0.75rem;
}
.exclusiveIcon {
margin-left: 4px;
opacity: 0.3;
}
}
.details {
display: flex;
padding-top: 16px;
}
.cell {
display: flex;
flex-direction: column;
padding: 4px;
&:not(:last-child) {
margin-right: 3%;
}
label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
font-weight: 400;
}
.blockedBy {
display: flex;
align-items: center;
}
.nestedIcon {
transform: rotate(-45deg);
opacity: 0.3;
margin-right: 4px;
}
.blockedById {
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
.error {
color: var(--error)
}
.errorMessage {
padding: 4px;
}
.finishLink {
&:hover {
text-decoration: underline;
cursor: pointer;
}
}

View file

@ -0,0 +1,113 @@
import React, { FC } from 'react';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
// import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import LooksOneSharpIcon from '@material-ui/icons/LooksOneSharp';
import Moment from 'react-moment';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { formatTimeoutString } from './utils';
import css from './Transaction.module.scss';
type TransactionProps = {
transaction: any;
className?: any;
forceFinish: (id: string) => void;
};
export const Transaction: FC<TransactionProps> = ({
transaction,
className,
forceFinish
}) => {
const { id, active, exclusive, timeout, deadline, error } = transaction;
return (
<Card className={className}>
<CardContent>
<div className={css.header}>
<span className={css.label}>Transaction Id:</span>
<span className={css.value}>
<code>{id}</code>
</span>
{exclusive && (
<Tooltip title="Exclusive" placement="top" arrow>
<LooksOneSharpIcon
className={css.exclusiveIcon}
fontSize="inherit"
/>
</Tooltip>
)}
</div>
<div className={css.details}>
<div className={css.cell}>
<label>Status</label>
<div className={error ? css.error : ''}>
{error ? 'Error' : active ? 'Active' : 'Waiting'}
</div>
</div>
<div className={css.cell}>
<label>Timeout</label>
<div>
{typeof timeout === 'string'
? formatTimeoutString(timeout)
: `${timeout} secs`}
</div>
</div>
<div className={css.cell}>
<label>Deadline</label>
<Tooltip
title={<Moment date={deadline} format="MMM D, YYYY hh:mm:ss A" />}
placement="right"
arrow
>
<Moment date={deadline} fromNow />
</Tooltip>
</div>
</div>
{error && (
<Typography
variant="caption"
color="error"
className={css.errorMessage}
>
{error}
</Typography>
)}
{/* TODO: Once we have blocked-by */}
{/* {transaction['blocked-by'] && (
<div className={css.details}>
<div className={css.cell}>
<label>Blocked By</label>
<div>
{transaction['blocked-by'].map((txId) => (
<div className={css.blockedBy}>
<ChevronLeftIcon
className={css.nestedIcon}
fontSize="small"
/>
<code className={css.blockedById}>{txId}</code>
</div>
))}
</div>
</div>
</div>
)} */}
</CardContent>
<CardActions>
<Typography
className={css.finishLink}
variant="overline"
color="primary"
onClick={() => forceFinish(id)}
>
Force Finish
</Typography>
</CardActions>
</Card>
);
};

View file

@ -0,0 +1 @@
export * from './Transaction';

View file

@ -0,0 +1,12 @@
import { formatTimeoutString } from './utils';
it('formats a timeout string', () => {
expect(formatTimeoutString('1d1h1m1s')).toEqual('1 day 1 hr 1 min 1 sec');
expect(formatTimeoutString('11d11h11m11s')).toEqual('11 days 11 hrs 11 mins 11 secs');
expect(formatTimeoutString('0d0h0m0s')).toEqual('0d0h0m0s');
expect(formatTimeoutString('1d0h11m0s')).toEqual('1 day 11 mins');
});
it('returns original value if invalid unit', () => {
expect(formatTimeoutString('1w')).toEqual('1w');
});

View file

@ -0,0 +1,27 @@
export const formatTimeoutString = (timeout: string) => {
let timeoutArr = timeout.match(/[0-9]+[dhms]/g) || [];
const timeoutExpanded = timeoutArr.map((t) => {
const value = Number(t.substring(0, t.length - 1));
if (value === 0) {
return '';
}
const isPlural = value > 1;
const unit = t.substring(t.length - 1);
switch (unit) {
case 'd':
return `${value} ${isPlural ? 'days' : 'day'}`;
case 'h':
return `${value} ${isPlural ? 'hrs' : 'hr'}`;
case 'm':
return `${value} ${isPlural ? 'mins' : 'min'}`;
case 's':
return `${value} ${isPlural ? 'secs' : 'sec'}`;
default: return value;
}
});
return timeoutExpanded.join(' ').trim() ?
timeoutExpanded.join(' ').replace(/\s{2,}/g, ' ').trim() :
timeout;
};

View file

@ -0,0 +1,85 @@
.transactionsHeader {
display: flex;
align-items: center;
margin-bottom: 20px;
.refresh {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 1rem;
transition: opacity 0.3s ease;
opacity: 0.3;
&:hover {
opacity: 1;
cursor: pointer;
}
}
}
.layout {
display: grid;
grid-template-columns: 2fr 3fr;
align-items: flex-start;
}
.list {
min-height: 200px;
}
.transactionItem {
display: flex;
align-items: center;
&:last-child {
border-bottom: 0;
}
&.inactive {
opacity: 0.5;
}
}
.errorIcon,
.waitingIcon,
.activeIcon {
margin-right: 8px;
}
.exclusiveIcon {
margin-left: 8px;
opacity: 0.3;
}
.itemWrapper {
display: flex;
.dividerLeft {
height: 1px;
width: 10px;
margin-left: 6px;
background: var(--divider);
align-self: center;
}
.dividerRight {
border: 1px solid var(--divider);
border-right: 0;
width: 10px;
margin: 3px 6px 3px 0;
}
.item {
flex-grow: 1;
}
}
@keyframes rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-359deg);
}
}

View file

@ -0,0 +1,178 @@
import React, { FC, useCallback, useState, useEffect } from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import classNames from 'classnames';
import ErrorSharpIcon from '@material-ui/icons/ErrorSharp';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import LooksOneSharpIcon from '@material-ui/icons/LooksOneSharp';
import Paper from '@material-ui/core/Paper';
import PauseCircleFilledIcon from '@material-ui/icons/PauseCircleFilled';
import Pluralize from 'react-pluralize';
import RefreshIcon from '@material-ui/icons/RefreshSharp';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { Pager } from 'shared/Pager';
import { pilosa } from 'services/eventServices';
import { Transaction } from './Transaction/Transaction';
import { useEffectOnce } from 'react-use';
import css from './Transactions.module.scss';
export const Transactions: FC<{}> = () => {
const [lastRefresh, setLastRefresh] = useState<number>(0);
const [transactions, setTransactions] = useState<any>([]);
const [page, setPage] = useState<number>(1);
const [selected, setSelected] = useState<any>();
const sliceStart = (page - 1) * 5;
const hasTransactions = transactions.length > 0;
useEffectOnce(() => {
getTransactions();
});
const tick = useCallback(() => {
if (lastRefresh >= 29) {
getTransactions();
} else {
setLastRefresh(lastRefresh + 1);
}
}, [lastRefresh]);
useEffect(() => {
let timer = setTimeout(() => tick(), 1000);
return () => {
clearTimeout(timer);
};
}, [tick]);
const getTransactions = () => {
pilosa.get.transactions().then((res) => {
const transactionList = res.data;
setTransactions(transactionList);
if (transactionList.length > 0) {
setSelected(transactionList[0]);
} else {
setSelected(undefined);
}
setLastRefresh(0);
});
};
const forceFinish = (id: string) => {
pilosa.post.finishTransaction(id).then(() => getTransactions());
};
const renderListItem = (transaction: any) => {
const { id, active, exclusive, error } = transaction;
return (
<ListItem
key={id}
className={classNames(css.transactionItem, {
[css.inactive]: !active && !error
})}
onClick={() => setSelected(transaction)}
selected={selected?.id === id}
divider
button
>
{error && (
<Tooltip title={error} placement="top" arrow>
<ErrorSharpIcon
className={css.errorIcon}
fontSize="inherit"
color="error"
/>
</Tooltip>
)}
{active && !error && (
<Tooltip title="Active" placement="top" arrow>
<CircularProgress className={css.activeIcon} size="0.75rem" />
</Tooltip>
)}
{!active && !error && (
<Tooltip title="Waiting" placement="top" arrow>
<PauseCircleFilledIcon
className={css.waitingIcon}
fontSize="inherit"
/>
</Tooltip>
)}
<code>{id}</code>
{exclusive && (
<Tooltip title="Exclusive" placement="top" arrow>
<LooksOneSharpIcon
className={css.exclusiveIcon}
fontSize="inherit"
/>
</Tooltip>
)}
</ListItem>
);
};
return (
<Block>
<div className={css.transactionsHeader}>
<Typography variant="h5" color="textSecondary">
Transactions
</Typography>
<div className={css.refresh}>
<Tooltip
title={
<div>
Last refreshed{' '}
<Pluralize singular="second" count={lastRefresh} /> ago
</div>
}
placement="top"
arrow
>
<IconButton size="small" color="inherit" onClick={getTransactions}>
<RefreshIcon color="inherit" fontSize="inherit" />
</IconButton>
</Tooltip>
</div>
</div>
<div className={css.layout}>
<div>
<Paper>
<List className={css.list}>
{transactions
.slice(sliceStart, sliceStart + 5)
.map((transaction) => renderListItem(transaction))}
{!hasTransactions && (
<ListItem>
<Typography variant="caption" color="textSecondary">
No active transactions.
</Typography>
</ListItem>
)}
</List>
</Paper>
{hasTransactions && (
<Pager
page={page}
rowsPerPage={5}
totalItems={transactions.length}
onChangePage={setPage}
/>
)}
</div>
{selected && (
<div className={css.itemWrapper}>
<div className={css.dividerLeft} />
<div className={css.dividerRight} />
<Transaction
className={css.item}
transaction={selected}
forceFinish={forceFinish}
/>
</div>
)}
</div>
</Block>
);
};

View file

@ -0,0 +1 @@
export * from './Transactions';

View file

@ -0,0 +1 @@
export * from './Home';

View file

@ -0,0 +1,128 @@
.layout {
display: flex;
padding-bottom: 16px;
.label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
font-weight: 400;
}
.breakdown {
margin-left: 16px;
flex-grow: 1;
}
.optionsItem {
display: flex;
align-items: center;
margin-bottom: 16px;
> div {
margin: 0 32px 0 8px;
}
}
.clearFilter {
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid rgba(var(--contrast-rgb), 0.27);
}
}
.table {
.tableHeader {
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 400;
line-height: 33px;
border-bottom: 1px solid rgba(var(--contrast-rgb), 0.1);
}
.sortable {
display: flex;
align-items: center;
.sortArrow {
font-size: 1rem;
}
&:hover {
cursor: pointer;
}
}
.currentSort {
color: var(--primary);
.asc {
transform: rotate(180deg);
}
}
.row {
pre {
font-size: 11px;
}
&:nth-child(even) {
background: rgba(var(--default-rgb), 0.15);
}
}
.tableCell {
border-bottom: 0;
}
}
.code {
font-size: 11px;
}
.optionsTable {
display: grid;
grid-template-columns: 100px auto 1fr;
gap: 2px 8px;
padding: 8px 0;
font-size: 11px;
}
.filter {
margin: 8px 0;
}
.highlight {
background: rgba(var(--primary-rgb), 0.5);
color: var(--contrast);
padding: 0 1px;
}
.noResults {
padding: 8px;
}
.reset {
color: var(--primary);
margin-left: 4px;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
.pagination {
padding: 12px 0;
margin-top: 8px;
border-top: 1px solid rgba(var(--contrast-rgb), 0.1);
}
[data-theme='dark'] {
.row {
&:nth-child(even) {
background: rgba(var(--base-rgb), 0.1);
}
}
}

View file

@ -0,0 +1,295 @@
import React, { FC, Fragment, useState, useEffect } from 'react';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import Breadcrumbs from '@material-ui/core/Breadcrumbs';
import classNames from 'classnames';
import Fuse from 'fuse.js';
import Highlighter from 'react-highlight-words';
import Link from '@material-ui/core/Link';
import map from 'lodash/map';
import OrderBy from 'lodash/orderBy';
import Reduce from 'lodash/reduce';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { Pager } from 'shared/Pager';
import { UsageBreakdown } from '../UsageBreakdown';
import css from './MoleculaTable.module.scss';
type MoleculaTableProps = {
table: any;
dataDistribution: any;
};
export const MoleculaTable: FC<MoleculaTableProps> = ({
table,
dataDistribution
}) => {
const [page, setPage] = useState<number>(1);
const [resultsPerPage, setResultsPerPage] = useState<number>(10);
const sliceStart = (page - 1) * resultsPerPage;
const [searchText, setSearchText] = useState<string>('');
const [filteredFields, setFiltereedFields] = useState(table.fields);
const [fieldsData, setFieldsData] = useState<{}>({});
const [maxFieldSize, setMaxFieldSize] = useState<number>(0);
const [sort, setSort] = useState<string>('total');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
if (dataDistribution) {
const aggregatedFieldsData = Reduce(
dataDistribution.fields,
(result, value) => {
let newResult = {};
const keys = Object.keys(value);
keys.forEach(
(key) =>
(newResult[key] = {
total: result[key].total + value[key].total,
fragments: result[key].fragments + value[key].fragments,
keys: result[key].keys + value[key].keys,
metadata: result[key].metadata + value[key].metadata
})
);
return newResult;
}
);
const sorted = OrderBy(aggregatedFieldsData, ['total'], ['desc']);
if (sorted.length > 0) {
setMaxFieldSize(sorted[0].total);
}
setFieldsData(aggregatedFieldsData);
}
}, [dataDistribution]);
useEffect(() => {
if (searchText.length > 1) {
const fuse = new Fuse(table.fields, {
keys: ['name'],
minMatchCharLength: 2,
ignoreLocation: true,
threshold: 0
});
const result = fuse.search(searchText);
let resultsArray: any[] = [];
result.forEach((r: any) => {
resultsArray.push({ ...r?.item, ...fieldsData[r?.item.name] });
});
setFiltereedFields(OrderBy(resultsArray, [sort], [sortDir]));
setPage(1);
} else {
const aggregatedData = table.fields.map((field) => {
return { ...field, ...fieldsData[field.name] };
});
setFiltereedFields(OrderBy(aggregatedData, [sort], [sortDir]));
}
}, [searchText, table.fields, sort, sortDir, fieldsData]);
const renderValue = (value: string) => {
let valueString = value.toString();
const isNumber = !isNaN(Number(value));
if (isNumber) {
const numValue = Number(value);
return numValue.toLocaleString();
}
return valueString;
};
const onSortClick = (name: string) => {
if (sort === name) {
setSortDir(sortDir === 'desc' ? 'asc' : 'desc');
} else {
setSort(name);
setSortDir('asc');
}
};
return (
<Block>
<Breadcrumbs aria-label="breadcrumb">
<Link color="inherit" variant="caption" href="/tables">
Tables
</Link>
<Typography color="textPrimary" variant="caption">
{table.name}
</Typography>
</Breadcrumbs>
<Typography variant="h5" color="textSecondary">
{table.name}
</Typography>
<div className={css.layout}>
<div>
<label className={css.label}>keys</label>
<div>
<code className={css.code}>
{table.options.keys ? 'TRUE' : 'FALSE'}
</code>
</div>
</div>
<div className={css.breakdown}>
<UsageBreakdown data={dataDistribution} />
</div>
</div>
<div>
<div>
<label className={css.label}>Fields</label>
{searchText.length > 1 && (
<Typography className={css.clearFilter} variant="caption">
<span className={css.reset} onClick={() => setSearchText('')}>
Clear filter
</span>
</Typography>
)}
</div>
<div className={css.filter}>
<TextField
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Search for Field"
variant="outlined"
size="small"
fullWidth
/>
</div>
<Table size="small" className={css.table}>
<TableHead>
<TableRow>
<TableCell className={css.tableHeader}>
<span
className={classNames(css.sortable, {
[css.currentSort]: sort === 'name'
})}
onClick={() => onSortClick('name')}
>
Name{' '}
<ArrowDropDownIcon
className={classNames(css.sortArrow, {
[css.asc]: sortDir === 'asc'
})}
/>
</span>
</TableCell>
<TableCell className={css.tableHeader}>Type</TableCell>
<TableCell className={css.tableHeader}>Cardinality</TableCell>
<TableCell className={css.tableHeader}>Options</TableCell>
<TableCell className={css.tableHeader}>
<span
className={classNames(css.sortable, {
[css.currentSort]: sort === 'total'
})}
onClick={() => onSortClick('total')}
>
Disk Usage{' '}
<ArrowDropDownIcon
className={classNames(css.sortArrow, {
[css.asc]: sortDir === 'asc'
})}
/>
</span>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredFields
.slice(sliceStart, sliceStart + resultsPerPage)
.map((field) => {
const { name, options, cardinality } = field;
const { type, keys, bitDepth, ...rest } = options;
const showKeys = ['set', 'time'].includes(type);
return (
<TableRow key={`field-${name}`} className={css.row}>
<TableCell className={css.tableCell}>
<Highlighter
highlightClassName={css.highlight}
searchWords={searchText.length > 1 ? [searchText] : []}
textToHighlight={name}
autoEscape={true}
/>
</TableCell>
<TableCell className={css.tableCell}>
<code className={css.code}>
{type} {showKeys ? (keys ? '(keys)' : '(ID)') : null}
</code>
</TableCell>
<TableCell className={css.tableCell}>
{cardinality ? cardinality.toLocaleString() : '-'}
</TableCell>
<TableCell className={css.tableCell}>
<div className={css.optionsTable}>
{map(rest, (value, key) => {
if (value !== '') {
const isMinMax = key === 'min' || key === 'max';
const scale = rest.scale
? Math.pow(10, rest.scale)
: 1;
const max = 9223372036854776000;
const isMaxed = max / scale === Math.abs(value);
if (isMinMax && isMaxed) {
return null;
} else {
return (
<Fragment key={`field-${name}-${key}`}>
<code>{key}</code>
<code>|</code>
<code>{renderValue(value)}</code>
</Fragment>
);
}
}
return null;
})}
</div>
</TableCell>
<TableCell className={css.tableCell}>
<UsageBreakdown
data={field}
width={`${(field.total / maxFieldSize) * 150}px`}
showLabel={false}
usageValueSize="small"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{filteredFields.length > 0 && (
<div className={css.pagination}>
<Pager
page={page}
rowsPerPage={resultsPerPage}
totalItems={filteredFields.length}
showTotal={true}
onChangePage={setPage}
onChangePerPage={setResultsPerPage}
/>
</div>
)}
{filteredFields.length === 0 && (
<Typography
className={css.noResults}
variant="caption"
color="textSecondary"
component="div"
>
No fields to show.
{searchText && (
<span className={css.reset} onClick={() => setSearchText('')}>
Clear filter
</span>
)}
</Typography>
)}
</div>
</Block>
);
};

View file

@ -0,0 +1 @@
export * from './MoleculaTable';

View file

@ -0,0 +1,71 @@
.actions {
padding: 20px 0;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
justify-items: stretch;
grid-gap: 20px;
}
.header {
font-size: 18px;
margin-bottom: 8px;
}
.cell {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
.code {
font-size: 11px;
}
}
.tableTile {
.label {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 400;
}
.section {
margin: 8px 0;
}
.showDetails {
margin-top: 16px;
padding-top: 8px;
text-align: center;
border-top: 1px solid var(--divider);
.link {
color: var(--text-secondary);
font-size: 12px;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
}
}
.breakdown {
display: flex;
align-items: center;
.distributionTotal {
display: flex;
margin-right: 8px;
white-space: nowrap;
}
}
.pilosaError {
padding: 8px 16px;
}

View file

@ -0,0 +1,126 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import OrderBy from 'lodash/orderBy';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { SortBy } from 'shared/SortBy';
import { UsageBreakdown } from './UsageBreakdown';
import { useHistory } from 'react-router-dom';
import css from './MoleculaTables.module.scss';
type MoleculaTablesProps = {
tables: any;
dataDistribution: any;
maxSize: number;
};
export const MoleculaTables: FC<MoleculaTablesProps> = ({
tables,
dataDistribution,
maxSize
}) => {
const history = useHistory();
const [sortedTables, setSortedTables] = useState<any>([]);
useEffect(() => {
if (tables && dataDistribution) {
let aggregatedData: any[] = [];
tables.forEach((i) =>
aggregatedData.push({
...dataDistribution[i.name],
...i
})
);
setSortedTables(aggregatedData);
} else if (tables) {
setSortedTables(tables);
}
}, [tables, dataDistribution]);
const handleSortChange = (value: any) => {
const sortDirection = value === 'name' ? 'asc' : 'desc';
setSortedTables(OrderBy(sortedTables, [value], [sortDirection]));
};
return (
<Fragment>
<Block>
<Typography variant="h5" color="textSecondary">
Tables
</Typography>
<div className={css.actions}>
<SortBy
options={[
{ label: 'Name', value: 'name' },
{ label: 'Index Size', value: 'total' },
{ label: 'Index Keys Size', value: 'indexKeys' },
{ label: 'Fragment Size', value: 'fragments' },
{ label: 'Field Keys Size', value: 'fieldKeysTotal' },
{ label: 'Metadata Size', value: 'metadata' }
]}
defaultValue="name"
onChange={handleSortChange}
sortId="sort-by"
/>
</div>
<div className={css.tiles}>
{sortedTables.map((table) => {
const { name, options, fields } = table;
return (
<Card key={name} className={css.tableTile}>
<CardContent>
<div className={css.header}>{name}</div>
<div className={css.section}>
<UsageBreakdown
data={
dataDistribution ? dataDistribution[name] : undefined
}
width={
dataDistribution
? `${(dataDistribution[name].total / maxSize) * 100}%`
: '0px'
}
/>
</div>
<label className={css.label}>Options</label>
<div className={css.cell}>
<span className={css.label}>keys</span>
<code className={css.code}>
{options.keys ? 'TRUE' : 'FALSE'}
</code>
</div>
<div className={css.showDetails}>
<span
className={css.link}
onClick={() => history.push(`/tables/${name}`)}
>
Show Fields ({fields.length})
</span>
</div>
</CardContent>
</Card>
);
})}
</div>
{tables && tables.length === 0 && (
<Paper className={css.pilosaError}>
<Typography variant="caption" color="textSecondary">
There are no tables to show.
</Typography>
</Paper>
)}
{!tables && (
<Paper className={css.pilosaError}>
<Typography variant="caption" color="textSecondary">
There is a problem connecting to Pilosa.
</Typography>
</Paper>
)}
</Block>
</Fragment>
);
};

View file

@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import OrderBy from 'lodash/orderBy';
import { MoleculaTable } from './MoleculaTable';
import { MoleculaTables } from './MoleculaTables';
import { pilosa } from 'services/eventServices';
import { useEffectOnce } from 'react-use';
import { useRouteMatch } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
export const MoleculaTablesContainer = () => {
const match = useRouteMatch('/tables/:table');
const history = useHistory();
const [tables, setTables] = useState<any>();
const [selectedTable, setSelectedTable] = useState<any>();
const [dataDistribution, setDataDistribution] = useState<any>();
const [maxSize, setMaxSize] = useState<number>(0);
useEffectOnce(() => {
pilosa.get
.schema()
.then((res) => setTables(res.data.indexes))
.finally(() =>
pilosa.get
.schemaDetails()
.then((res) => setTables(res.data.indexes))
.catch((err) => console.log(err))
);
pilosa.get.usage().then((res) => {
const nodes = Object.keys(res.data);
let data = {};
nodes.forEach((node) => {
const nodeIndexes = res.data[node].diskUsage.indexes;
const indexList = Object.keys(nodeIndexes);
indexList.forEach((i) => {
const nodeData = nodeIndexes[i];
if (data[i]) {
data[i] = {
total: data[i].total + nodeData.total,
fieldKeysTotal: data[i].fieldKeysTotal + nodeData.fieldKeysTotal,
indexKeys: data[i].indexKeys + nodeData.indexKeys,
fragments: data[i].fragments + nodeData.fragments,
metadata: data[i].metadata + nodeData.metadata,
fields: [...data[i].fields, nodeData.fields]
};
} else {
data[i] = {
total: nodeData.total,
fieldKeysTotal: nodeData.fieldKeysTotal,
indexKeys: nodeData.indexKeys,
fragments: nodeData.fragments,
metadata: nodeData.metadata,
fields: [nodeData.fields]
};
}
});
});
const sorted = OrderBy(data, ['total'], ['desc']);
if (sorted.length > 0) {
setMaxSize(sorted[0].total);
}
setDataDistribution(data);
});
});
useEffect(() => {
if (match && tables) {
const tableName = match?.params['table'];
const matchTable = tables.find((t) => t.name === tableName);
if (matchTable) {
setSelectedTable(matchTable);
} else {
history.push('/tables');
}
} else {
setSelectedTable(undefined);
}
}, [match, tables, history]);
return selectedTable ? (
<MoleculaTable
table={selectedTable}
dataDistribution={
dataDistribution ? dataDistribution[selectedTable.name] : undefined
}
/>
) : (
<MoleculaTables
tables={tables}
dataDistribution={dataDistribution}
maxSize={maxSize}
/>
);
};

View file

@ -0,0 +1,62 @@
.label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 4px;
font-weight: 400;
}
.usageBreakdown {
display: flex;
align-items: center;
.usageBreakdownLabel {
white-space: nowrap;
margin-right: 8px;
&.smallLabel {
font-size: 12px;
}
}
}
.breakdown {
display: flex;
align-items: center;
height: 13px;
border-radius: 4px;
background: rgba(var(--contrast-rgb), 0.1);
.fieldKeysTotal {
height: 13px;
background: rgba(88, 80, 141, 0.7);
}
.indexKeys {
height: 13px;
background: rgba(255, 99, 97, 0.7);
}
.keys {
height: 13px;
background: rgba(88, 80, 141, 0.7);
}
.fragments {
height: 13px;
background: rgba(255, 166, 0, 0.7);
}
.metadata {
height: 13px;
background: rgba(188, 80, 144, 0.7);
}
.bar:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.bar:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View file

@ -0,0 +1,171 @@
import React, { FC, Fragment } from 'react';
import classNames from 'classnames';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { formatBytes } from 'shared/utils/formatBytes';
import css from './UsageBreakdown.module.scss';
type UsageBreakdownProps = {
data: any;
width?: string;
showLabel?: boolean;
usageValueSize?: 'small' | 'medium';
};
export const UsageBreakdown: FC<UsageBreakdownProps> = ({
data = {},
width,
showLabel = true,
usageValueSize = 'medium'
}) => {
const { total, fieldKeysTotal, indexKeys, fragments, metadata, keys } = data;
const fieldKeysPercentage =
fieldKeysTotal && total ? (fieldKeysTotal / total) * 100 : 0;
const indexKeysPercentage = indexKeys ? (indexKeys / total) * 100 : 0;
const fragmentsPercentage = fragments ? (fragments / total) * 100 : 0;
const metadataPercentage = metadata ? (metadata / total) * 100 : 0;
const keysPercentage = keys && total ? (keys / total) * 100 : 0;
return (
<Fragment>
{showLabel ? <label className={css.label}>Data</label> : null}
<div className={css.usageBreakdown}>
{total ? (
<Fragment>
<span
className={classNames(css.usageBreakdownLabel, {
[css.smallLabel]: usageValueSize === 'small'
})}
>
{formatBytes(total)}
</span>
<div
className={css.breakdown}
style={{ width: width ? width : '100%' }}
>
{fieldKeysTotal ? (
<Tooltip
title={
<Fragment>
<label>Field Keys:</label>
<Typography variant="caption" component="div">
{formatBytes(fieldKeysTotal)} (
{fieldKeysPercentage.toLocaleString(undefined, {
maximumFractionDigits: 1
})}
%)
</Typography>
</Fragment>
}
placement="top"
arrow
>
<div
className={classNames(css.bar, css.fieldKeysTotal)}
style={{ width: `${fieldKeysPercentage}%` }}
/>
</Tooltip>
) : null}
{indexKeys ? (
<Tooltip
title={
<Fragment>
<label>Index Keys:</label>
<Typography variant="caption" component="div">
{formatBytes(indexKeys)} (
{indexKeysPercentage.toLocaleString(undefined, {
maximumFractionDigits: 1
})}
%)
</Typography>
</Fragment>
}
placement="top"
arrow
>
<div
className={classNames(css.bar, css.indexKeys)}
style={{ width: `${indexKeysPercentage}%` }}
/>
</Tooltip>
) : null}
{keys ? (
<Tooltip
title={
<Fragment>
<label>Keys:</label>
<Typography variant="caption" component="div">
{formatBytes(keys)} (
{keysPercentage.toLocaleString(undefined, {
maximumFractionDigits: 1
})}
%)
</Typography>
</Fragment>
}
placement="top"
arrow
>
<div
className={classNames(css.bar, css.keys)}
style={{ width: `${keysPercentage}%` }}
/>
</Tooltip>
) : null}
{fragments ? (
<Tooltip
title={
<Fragment>
<label>Fragments:</label>
<Typography variant="caption" component="div">
{formatBytes(fragments)} (
{fragmentsPercentage.toLocaleString(undefined, {
maximumFractionDigits: 1
})}
%)
</Typography>
</Fragment>
}
placement="top"
arrow
>
<div
className={classNames(css.bar, css.fragments)}
style={{ width: `${fragmentsPercentage}%` }}
/>
</Tooltip>
) : null}
{metadata ? (
<Tooltip
title={
<Fragment>
<label>Metadata:</label>
<Typography variant="caption" component="div">
{formatBytes(metadata)} (
{metadataPercentage.toLocaleString(undefined, {
maximumFractionDigits: 1
})}
%)
</Typography>
</Fragment>
}
placement="top"
arrow
>
<div
className={classNames(css.bar, css.metadata)}
style={{ width: `${metadataPercentage}%` }}
/>
</Tooltip>
) : null}
</div>
</Fragment>
) : (
<Typography variant="caption" component="div">
Calculating...
</Typography>
)}
</div>
</Fragment>
);
};

View file

@ -0,0 +1 @@
export * from './UsageBreakdown';

View file

@ -0,0 +1,2 @@
export * from './MoleculaTables';
export * from './MoleculaTablesContainer';

View file

@ -0,0 +1,10 @@
import React from 'react';
import Typography from '@material-ui/core/Typography';
export const NotFound = () => (
<div>
<Typography variant="h4" color="textSecondary" paragraph>
Page Not Found
</Typography>
</div>
);

View file

@ -0,0 +1 @@
export * from './NotFound';

View file

@ -0,0 +1,92 @@
.inputLayout {
display: flex;
align-items: center;
min-height: 66px;
padding: 8px;
border: 1px solid rgba(var(--contrast-rgb), 0.23);
border-radius: 4px;
background-color: rgba(var(--base-rgb), 0.1);
transition: border-color 0.15s ease;
&:hover {
border-color: rgba(var(--contrast-rgb), 0.5);
}
&.active {
border: 1px solid rgba(var(--primary-rgb), 1);
}
.console {
flex-grow: 1;
align-self: center;
}
}
.consoleError {
padding: 16px;
border: 1px solid rgba(var(--error-rgb), 0.5);
background: rgba(var(--error-rgb), 0.1);
border-radius: 4px;
margin: 16px 0;
}
.queryConsole {
padding: 0 4px;
display: flex;
align-items: center;
.cursor {
margin: 12px 0 12px 16px;
}
.input {
flex-grow: 1;
font-family: 'Roboto Mono', monospace;
margin: 8px 0;
background-color: transparent;
position: relative;
.textarea {
width: 100%;
background-color: transparent;
textarea {
width: 100%;
font-family: 'Roboto Mono', monospace;
background-color: transparent;
}
}
}
.autocompleteText {
position: absolute !important;
display: block !important;
top: 0;
opacity: 0.5;
width: 100%;
background-color: transparent;
textarea {
width: 100%;
font-family: 'Roboto Mono', monospace;
background-color: transparent;
}
}
.iconButton {
padding-right: 16px;
transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
}
}
.queryTypeTag {
padding: 2px 8px;
background: rgba(var(--default-rgb), 0.35);
color: var(--text-secondary);
border-radius: 4px;
}
[data-theme='dark'] {
.queryTypeTag {
background: rgba(var(--base-rgb), 0.2);
}
}

View file

@ -0,0 +1,762 @@
import React, {
ChangeEvent,
forwardRef,
Fragment,
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useRef,
useState
} from 'react';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import classNames from 'classnames';
import ClearIcon from '@material-ui/icons/Clear';
import Fuse from 'fuse.js';
import IconButton from '@material-ui/core/IconButton';
import InputBase from '@material-ui/core/InputBase';
import SubdirectoryIcon from '@material-ui/icons/SubdirectoryArrowLeft';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import {
keywordHelpers,
pqlKeywords,
rowCallOptions,
sqlKeywords
} from './helpers';
import { ResultType } from 'App/Query/QueryContainer';
import css from './Console.module.scss';
type ConsoleProps = {
loading: boolean;
indexList: any[];
previousQueries: string[];
error?: ResultType;
onQuery: (query: string, type: 'PQL' | 'SQL', index?: string) => void;
onHelperTextChange: (helperText: string[]) => void;
onSetIndex: (index: string | undefined) => void;
};
export const Console = forwardRef((props: ConsoleProps, ref: any) => {
const {
loading,
indexList,
previousQueries,
error,
onQuery,
onHelperTextChange,
onSetIndex
} = props;
const [inputVal, setInputVal] = useState<string>('');
const [showError, setShowError] = useState<boolean>(false);
const [autocomplete, setAutocomplete] = useState<string>('');
const [showAutocomplete, setShowAutocomplete] = useState<boolean>(false);
const [inputIsDirty, setInputIsDirty] = useState<boolean>(false);
const [clearedAfterQuery, setClearedAfterQuery] = useState<boolean>(true);
const [queryType, setQueryType] = useState<'PQL' | 'SQL'>('SQL');
const [queryHistory, setQueryHistory] = useState<number>(-1);
const [focused, setFocused] = useState<boolean>(false);
const keys = Object.keys(keywordHelpers);
const inputEl = useRef<HTMLInputElement>(null);
const clearInput = useCallback(() => {
if (ref.current) {
ref.current.value = '';
setInputIsDirty(false);
setQueryHistory(-1);
setShowError(false);
setAutocomplete('');
ref.current.focus();
}
}, [ref]);
useEffect(() => {
if (!loading && !error && !clearedAfterQuery) {
clearInput();
setClearedAfterQuery(true);
setShowError(false);
} else if (error) {
ref.current.focus();
}
}, [ref, loading, clearInput, clearedAfterQuery, error, setShowError]);
useEffect(() => {
if (error) {
setShowError(true);
}
}, [error]);
const onQueryEnter = (event: SyntheticEvent) => {
event.preventDefault();
if (ref && ref.current && inputIsDirty) {
if (queryType === 'PQL') {
const query = ref.current.value.trim();
const indexStart = query.indexOf('[') + 1;
const indexEnd = query.indexOf(']');
const index = query.substring(indexStart, indexEnd);
const pqlQuery = query.substring(indexEnd + 1);
onQuery(pqlQuery.replace(/;+$/, ''), queryType, index);
} else {
onQuery(ref.current.value, queryType);
}
}
};
// [syang] tbh, this function does too much at the moment since it
// handles autocomplete for both PQL and SQL as well as showing the
// helper text and detecting when a table has been selected.
// TODO: if we're keeping autocomplete functionality a front end
// thing, we should consider using a parser strategy since it will
// be easier to manage with addition of future functionality
// ie: https://pegjs.org/
const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
const trimmedValue = event.target.value.trim();
setShowError(false);
setInputVal(value);
setQueryHistory(-1);
let pqlPattern;
if (trimmedValue !== '') {
pqlPattern = trimmedValue.match(/\[(.*?)\]/);
setInputIsDirty(true);
} else {
setInputIsDirty(false);
}
if (trimmedValue[0] === '[' || (pqlPattern && pqlPattern.index === 0)) {
setQueryType('PQL');
} else {
setQueryType('SQL');
}
updateHelper(trimmedValue);
if (pqlPattern && pqlPattern.index === 0) {
onSetIndex(pqlPattern[1]);
const queryString = value.substring(value.indexOf(']') + 1).trim();
const operationIndex = queryString.indexOf('(');
if (operationIndex < 0) {
setAutocompleteText(pqlKeywords, false, queryString);
} else {
const index = indexList.find((i) => i.name === pqlPattern[1]);
const { op, postOpString } = getOperation(queryString);
switch (op) {
case 'Row':
if (postOpString.match(/(.*?),/)) {
const indexOfComma = postOpString.indexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
const indexOfFrom = postCommaString.indexOf('from=');
const indexOfTo = postCommaString.indexOf('to=');
if (indexOfFrom >= 0 && indexOfTo >= 0) {
setAutocomplete('');
} else if (
postCommaString.indexOf(',') >= 0 &&
indexOfFrom >= 0
) {
const argString = postCommaString
.substring(postCommaString.indexOf(',') + 1)
.trimStart();
if (argString.length === 0) {
setAutocomplete('to=');
} else {
setAutocompleteText(['to='], false, argString);
}
} else if (postCommaString.indexOf(',') >= 0 && indexOfTo >= 0) {
const argString = postCommaString
.substring(postCommaString.indexOf(',') + 1)
.trimStart();
if (argString.length === 0) {
setAutocomplete('from=');
} else {
setAutocompleteText(['from='], false, argString);
}
} else {
setAutocompleteText(['from=', 'to='], false, postCommaString);
}
} else {
setAutocompleteText(index.fields, true, postOpString);
}
break;
case 'Rows':
if (postOpString.match(/(.*?),/)) {
const lastIndexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(lastIndexOfComma + 1)
.trimStart();
setAutocompleteText(
['previous=', 'limit=', 'column=', 'from=', 'to='],
false,
postCommaString
);
} else {
setAutocompleteText(index.fields, true, postOpString);
}
break;
case 'Clear':
case 'Set':
if (postOpString.match(/(.*?),/)) {
const indexOfComma = postOpString.indexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
setAutocompleteText(index.fields, true, postCommaString);
} else {
setAutocomplete('');
}
break;
case 'SetRowAttrs':
if (postOpString.match(/(.*?),/)) {
const indexOfComma = postOpString.indexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
setAutocompleteText(rowCallOptions, false, postCommaString);
} else {
setAutocompleteText(index.fields, true, postOpString);
}
break;
case 'Store':
if (postOpString.match(/(.*?),/)) {
const indexOfComma = postOpString.indexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
setAutocompleteText(index.fields, true, postCommaString);
} else {
setAutocompleteText(rowCallOptions, false, postOpString);
}
break;
case 'ClearRow':
setAutocompleteText(index.fields, true, postOpString);
break;
case 'TopK':
if (postOpString.includes(',')) {
const indexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
setAutocompleteText(
[...rowCallOptions, 'k=', 'filter=', 'from=', 'to='],
false,
postCommaString
);
} else {
setAutocompleteText(index.fields, true, postOpString);
}
break;
case 'TopN':
if (postOpString.includes(',')) {
const indexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(indexOfComma + 1)
.trimStart();
setAutocompleteText(
[...rowCallOptions, 'n=', 'attrName=', 'attrValues='],
false,
postCommaString
);
} else {
setAutocompleteText(index.fields, true, postOpString);
}
break;
case 'Min':
case 'Max':
case 'Sum':
if (postOpString.match(/(.*?)field=/)) {
const lastIndexOfEquals = postOpString.lastIndexOf('=');
const postEquals = postOpString
.substring(lastIndexOfEquals + 1)
.trimStart();
setAutocompleteText(index.fields, true, postEquals, true);
} else if (postOpString.match(/(.*?),(.*?)/g)) {
const openParen = postOpString.match(/\(/g) || [];
const closeParen = postOpString.match(/\)/g) || [];
const lastIndexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(lastIndexOfComma + 1)
.trimStart();
if (closeParen.length - openParen.length > 0) {
setAutocompleteText(rowCallOptions, false, postCommaString);
} else if (closeParen.length - openParen.length === 0) {
if (postCommaString.trim().length === 0) {
setAutocomplete('field=');
} else {
setAutocompleteText(['field='], false, postCommaString);
}
} else {
setAutocomplete('');
}
} else {
setAutocompleteText(
[...rowCallOptions, 'field='],
false,
postOpString
);
}
break;
case 'Difference':
case 'Intersect':
case 'Union':
case 'Xor':
if (postOpString.includes(',')) {
const lastIndexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(lastIndexOfComma + 1)
.trimStart();
setAutocompleteText(rowCallOptions, false, postCommaString);
} else {
setAutocompleteText(rowCallOptions, false, postOpString);
}
break;
case 'Limit':
case 'Not':
case 'Count':
case 'Extract':
case 'IncludesColumn':
if (postOpString.match(/(.*?)/)) {
const indexOfClose = postOpString.indexOf(')');
const postCloseString = postOpString
.substring(indexOfClose + 1)
.trimStart();
setAutocompleteText([', column='], false, postCloseString);
} else {
setAutocompleteText(rowCallOptions, false, postOpString);
}
break;
case 'UnionRows':
if (postOpString.includes(',')) {
const lastIndexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(lastIndexOfComma + 1)
.trimStart();
setAutocompleteText(['Rows('], false, postCommaString);
} else {
setAutocompleteText(['Rows('], false, postOpString);
}
break;
case 'Options':
if (postOpString.includes(',')) {
const lastIndexOfComma = postOpString.lastIndexOf(',');
const postCommaString = postOpString
.substring(lastIndexOfComma + 1)
.trimStart();
setAutocompleteText(
[
'columnAttrs=',
'excludeColumns=',
'excludeRowAttrs=',
'shards='
],
false,
postCommaString
);
} else {
setAutocompleteText(pqlKeywords, false, postOpString);
}
break;
default:
setAutocomplete('');
break;
}
}
} else {
if (queryType === 'PQL') {
onSetIndex(undefined);
if (!pqlPattern) {
setAutocompleteText(
indexList,
true,
trimmedValue.substring(1),
false,
true
);
} else {
setAutocomplete('');
}
} else {
const split = value.split(' ');
switch (split[0]) {
case 'drop':
if (value.match(/drop(\s+)table(\s+)/)) {
const postOpString = value.substring(11);
setAutocompleteText(
indexList,
true,
postOpString.trim().replace('`', '')
);
} else {
setAutocompleteText(sqlKeywords, false, value);
}
break;
case 'show':
if (value.match(/show(\s+)fields(\s+)from(\s+)/)) {
const postOpString = value.substring(17);
setAutocompleteText(
indexList,
true,
postOpString.trim().replace('`', '')
);
} else {
setAutocompleteText(sqlKeywords, false, value);
}
break;
case 'select':
if (value.match(/select(\s+)distinct(\s+)(.*)(\s+)from(\s+)/)) {
const fromIdx = value.indexOf('from ');
const postFromString = value.substring(fromIdx + 5);
setAutocompleteText(indexList, true, postFromString.trim());
} else if (
value.match(/select(\s+)(.*)(\s+)from(\s+)(.*)(\s+)where(\s+)/)
) {
const whereIdx = value.indexOf('where ');
const postWhereString = value.substring(whereIdx + 6);
const indexMatch = value.match(/from (.*) where(\s+)/);
if (indexMatch) {
const index = indexList.find(
(i) => i.name === indexMatch[1].trim().replaceAll('`', '')
);
if (index) {
onSetIndex(index.name);
if (postWhereString.match(/(.*)=(.*)(\s+)and(\s+)/)) {
const lastSpaceIdx = postWhereString.lastIndexOf(' ');
const postAndString = postWhereString
.substring(lastSpaceIdx + 1)
.trim();
setAutocompleteText(
[...index.fields, { name: 'and ' }],
true,
postAndString.replaceAll('`', '')
);
} else if (postWhereString.match(/(.*)=(.*)/)) {
const lastSpaceIdx = postWhereString.lastIndexOf(' ');
const postFieldString = postWhereString
.substring(lastSpaceIdx + 1)
.trim();
setAutocompleteText(['and '], false, postFieldString);
} else {
setAutocompleteText(
index.fields,
true,
postWhereString.trim().replaceAll('`', '')
);
}
}
} else {
setAutocomplete('');
}
} else if (value.match(/select(\s+)(.*)(\s+)from(\s+)/)) {
const fromIdx = value.indexOf('from ');
const postFromString = value.substring(fromIdx + 5).trimStart();
const spaceIdx = postFromString.trimStart().indexOf(' ');
if (spaceIdx > 0) {
const indexString = postFromString
.substring(0, spaceIdx)
.replaceAll('`', '');
const index = indexList.find((i) => i.name === indexString);
if (index) {
onSetIndex(indexString);
}
const postSpaceString = postFromString.substring(spaceIdx);
setAutocompleteText(
['where ', 'limit '],
false,
postSpaceString.trim()
);
} else {
setAutocompleteText(
indexList,
true,
postFromString.trim().replace('`', '')
);
}
} else {
onSetIndex(undefined);
setAutocompleteText(sqlKeywords, false, value);
}
break;
default:
onSetIndex(undefined);
setAutocompleteText(sqlKeywords, false, value);
break;
}
}
}
};
const getOperation = (str: string) => {
let matchesOpen: any[] = [];
const rx = /\(/g;
let match;
while ((match = rx.exec(str)) !== null) {
matchesOpen.push(match.index);
}
let matchStack: any[] = [];
let openCount = 0;
const brackets = str.match(/[()]/g) || [];
const opsList = str.split('(');
for (let i = 0; i < brackets.length; i++) {
if (brackets[i] === '(') {
matchStack.push(openCount);
openCount++;
} else {
matchStack.pop();
}
}
const matchIndex = matchStack.length
? matchStack[matchStack.length - 1]
: 0;
let op = opsList[matchIndex];
const opStart = op.match(/[,\s]/);
if (opStart) {
op = op.substring((opStart.index || 0) + 1).trimStart();
}
const postOpString =
matchesOpen.length > 0 ? str.substring(matchesOpen[matchIndex] + 1) : str;
return { op, postOpString };
};
const setAutocompleteText = (
searchArray: any[],
hasNameKey: boolean,
searchString: string,
closeOperation: boolean = false,
isIndexAutocomplete: boolean = false
) => {
const fuse = new Fuse(searchArray, {
keys: hasNameKey ? ['name'] : [],
minMatchCharLength: 1,
location: 0,
distance: 0,
threshold: 0,
isCaseSensitive: true
});
const result = fuse.search(searchString);
if (result.length > 0) {
const r = result[0].item as any;
const restOfText = hasNameKey
? r.name.substring(searchString.length)
: r.substring(searchString.length);
setAutocomplete(
isIndexAutocomplete
? `${restOfText}]`
: closeOperation
? `${restOfText})`
: restOfText
);
} else {
setAutocomplete('');
}
};
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
const isPQL = queryType === 'PQL';
if (event.key === 'Enter' && !event.shiftKey) {
onQueryEnter(event);
setClearedAfterQuery(false);
} else if (
(event.key === 'Tab' || event.key === 'ArrowRight') &&
showAutocomplete &&
autocomplete
) {
event.preventDefault();
ref.current.value = ref.current.value + autocomplete;
setShowError(false);
updateHelper(ref.current.value);
if (isPQL) {
const pqlPattern = ref.current.value.match(/\[(.*?)\]/);
if (pqlPattern) {
onSetIndex(pqlPattern[1]);
}
setAutocomplete('');
} else {
setAutocomplete('');
}
} else if (event.key === 'ArrowUp' && previousQueries.length > 0) {
if (ref.current.value === '' || queryHistory >= 0) {
const prevQueryIndex = Math.min(
queryHistory + 1,
previousQueries.length - 1
);
setQueryHistory(prevQueryIndex);
if (previousQueries[prevQueryIndex][0] === '!') {
const qString = previousQueries[prevQueryIndex].substring(1);
getQueryType(qString);
ref.current.value = qString;
} else {
getQueryType(previousQueries[prevQueryIndex]);
ref.current.value = previousQueries[prevQueryIndex];
}
setTimeout(() => {
ref.current.selectionStart = ref.current.selectionEnd =
ref.current.value.length;
}, 0);
setInputIsDirty(true);
}
} else if (
event.key === 'ArrowDown' &&
queryHistory >= 0 &&
previousQueries.length > 0
) {
const prevQueryIndex = Math.max(queryHistory - 1, -1);
setQueryHistory(prevQueryIndex);
if (prevQueryIndex >= 0) {
if (previousQueries[prevQueryIndex][0] === '!') {
const qString = previousQueries[prevQueryIndex].substring(1);
getQueryType(qString);
ref.current.value = qString;
} else {
getQueryType(previousQueries[prevQueryIndex]);
ref.current.value = previousQueries[prevQueryIndex];
}
setInputIsDirty(true);
} else {
ref.current.value = '';
setQueryType('SQL');
setInputIsDirty(false);
}
}
};
const onKeyUp = () => {
const isSelectionRange =
ref.current?.selectionStart !== ref.current?.selectionEnd;
const cursorIsAtEnd =
ref.current.value.length === ref.current?.selectionStart &&
!isSelectionRange;
if (cursorIsAtEnd) {
setShowAutocomplete(true);
} else {
setShowAutocomplete(false);
}
};
const updateHelper = (value: string) => {
if (queryType === 'PQL') {
const queryString = value.substring(value.indexOf(']') + 1);
const { op } = getOperation(queryString);
if (keys.includes(op)) {
onHelperTextChange(keywordHelpers[op]);
} else {
onHelperTextChange([]);
}
} else {
let matchedKeywords: string[] = [];
for (const key of sqlKeywords) {
if (value.toLowerCase().trim().includes(key.trim())) {
matchedKeywords.push(key.trim());
}
}
const currentKeyword =
matchedKeywords.length > 0
? matchedKeywords[matchedKeywords.length - 1]
: undefined;
if (currentKeyword && keys.includes(currentKeyword)) {
onHelperTextChange(keywordHelpers[currentKeyword]);
} else {
onHelperTextChange([]);
}
}
};
const getQueryType = (queryString: string) => {
if (queryString[0] === '[') {
setQueryType('PQL');
} else {
setQueryType('SQL');
}
};
return (
<Fragment>
<Block
className={classNames(css.inputLayout, {
[css.active]: focused
})}
>
<div className={css.console}>
<form className={css.queryConsole} onSubmit={onQueryEnter}>
<ChevronRightIcon className={css.cursor} color="primary" />
<div className={css.input}>
<InputBase
className={css.textarea}
placeholder="Enter a query"
ref={inputEl}
inputRef={ref}
onChange={onInputChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onBlur={() => {
setFocused(false);
setShowAutocomplete(false);
}}
onFocus={() => {
setFocused(true);
setShowAutocomplete(true);
}}
disabled={loading}
autoFocus
multiline
/>
{autocomplete && showAutocomplete ? (
<InputBase
className={css.autocompleteText}
value={`${inputVal}${autocomplete}`}
disabled={true}
multiline
/>
) : null}
</div>
{inputIsDirty && (
<Fragment>
<Tooltip
title={
<Typography variant="caption" component="div">
It looks like you're running a {queryType} query.
</Typography>
}
placement="top"
arrow
>
<div className={css.queryTypeTag}>{queryType}</div>
</Tooltip>
<IconButton
className={css.iconButton}
onClick={clearInput}
aria-label="Clear Input"
>
<ClearIcon />
</IconButton>
</Fragment>
)}
<IconButton
className={css.iconButton}
disabled={!inputIsDirty}
color={inputIsDirty ? 'primary' : 'default'}
type="submit"
aria-label="Query"
>
<SubdirectoryIcon />
</IconButton>
</form>
</div>
</Block>
{showError ? (
<div className={css.consoleError}>{error?.error}</div>
) : null}
</Fragment>
);
});

View file

@ -0,0 +1,151 @@
export const keywordHelpers = {
Distinct: ['Distinct([ROW_CALL], field=<INT_FIELD>, [index=<INDEX_NAME>])'],
Row: [
'Row(<FIELD>=<ROW>)',
'Row(<FIELD>=<ROW>, from=<TIMESTAMP>, to=<TIMESTAMP>)',
'Row([<COMPARISON_VALUE> <COMPARISON_OPERATOR>] <FIELD> <COMPARISON_OPERATOR> <COMPARISON_VALUE>)'
],
Union: ['Union([ROW_CALL ...])'],
Intersect: ['Intersect(<ROW_CALL>, [ROW_CALL ...])'],
Difference: ['Difference(<ROW_CALL>, [ROW_CALL ...])'],
Xor: ['Xor(<ROW_CALL>, [ROW_CALL ...])'],
Not: ['Not(<ROW_CALL>'],
Count: ['Count(<ROW_CALL>)'],
Shift: ['Shift(<ROW_CALL>, [n=UINT])'],
TopK: ['TopK(<FIELD>, [k=UINT], [filter=ROW_CALL], [from=TIMESTAMP], [to=TIMESTAMP])'],
TopN: [
'TopN(<FIELD>, [ROW_CALL], [n=UINT], [attrName=<ATTR_NAME>, attrValues=<[]ATTR_VALUE>])'
],
Min: ['Min([ROW_CALL], field=<FIELD>)'],
Max: ['Max([ROW_CALL], field=<FIELD>)'],
Sum: ['Sum([ROW_CALL], field=<FIELD>)'],
Rows:
['Rows(<FIELD>, previous=<UINT|STRING>, limit=<UINT>, column=<UINT|STRING>, from=<TIMESTAMP>, to=<TIMESTAMP>)'],
GroupBy: ['GroupBy(<ROWS_CALL>, [<ROWS_CALL>...], limit=<UINT>, filter=<ROW_CALL>, having=Condition([<COMPARISON_VALUE> <COMPARISON_OPERATOR>] <count|sum> <COMPARISON_OPERATOR> <COMPARISON_VALUE>), aggregate=<CALL>, sort=<SORT_DIRECTIVE_LIST>)'],
Extract: ['Extract(<ROW_CALL>, [<ROWS_CALL>...])'],
Limit: ['Limit(<ROW_CALL>, [limit=<UINT>], [offset=<UINT>])'],
Set: ['Set(<COLUMN>, <FIELD>=<ROW>, [TIMESTAMP])'],
SetColumnAttrs: ['SetColumnAttrs(<COLUMN>, <ATTR_NAME=ATTR_VALUE>, [ATTR_NAME=ATTR_VALUE ...])'],
SetRowAttrs: ['SetRowAttrs(<FIELD>, <ROW>, <ATTR_NAME=ATTR_VALUE>, [ATTR_NAME=ATTR_VALUE ...])'],
Store: ['Store(<ROW_CALL>, <FIELD>=<ROW>)'],
ClearRow: ['ClearRow(<FIELD>=<ROW>)'],
Clear: ['Clear(<COLUMN>, <FIELD>=<ROW>)'],
Options: ['Options(<CALL>, columnAttrs=<BOOL>, excludeColumns=<BOOL>, excludeRowAttrs=<BOOL>, shards=[UINT ...])'],
IncludesColumn: ['IncludesColumn(<ROW_CALL>, column=<UINT|STRING>)'],
select: [
'select * from <TABLE> where _id = 1',
'select <FIELD>, <FIELD> from <TABLE>',
'select _id, <FIELD> from <TABLE> where _id = <_ID>',
'select _id from <TABLE>',
'select distinct <FIELD> from <TABLE>',
'select count(',
'select min(<FIELD>) from <TABLE>',
'select max(<FIELD>) from <TABLE>',
'select sum(<FIELD>) from <TABLE>',
'select avg(<FIELD>) from <TABLE>',
],
'select * from': [
'select * from <TABLE> where _id = 1',
'select <FIELD>, <FIELD> from <TABLE>',
'select _id, <FIELD> from <TABLE> where _id = <_ID>',
'select _id from <TABLE>',
'select distinct <FIELD> from <TABLE>',
'select count(',
'select min(<FIELD>) from <TABLE>',
'select max(<FIELD>) from <TABLE>',
'select sum(<FIELD>) from <TABLE>',
'select avg(<FIELD>) from <TABLE>',
],
'select _id from': [
'select _id from <TABLE> where <FIELD> = 1',
'select _id from <TABLE> where <FIELD> in (1, 2)',
'select _id from <TABLE> where <FIELD> = 1 limit 1',
'select _id from <TABLE> where <FIELD> = 1 and <FIELD> = 2',
],
'select count(': [
'select count(*) from <TABLE>',
'select count(*) from <TABLE> where <FIELD> = 1',
'select count(*) from <TABLE> where <FIELD> = 1 and <FIELD> = 2',
'select count(distinct <FIELD>) from <TABLE>',
],
'select distinct': ['select distinct <FIELD> from <TABLE>'],
'select avg(': [
'select avg(<FIELD>) from <TABLE>',
'select avg(<FIELD>) from <TABLE> where <FIELD> = 1',
],
'select min(': [
'select min(<FIELD>) from <TABLE>',
'select min(<FIELD>) from <TABLE> where <FIELD> = 1',
],
'select max(': [
'select max(<FIELD>) from <TABLE>',
'select max(<FIELD>) from <TABLE> where <FIELD> = 1',
],
'select sum(': [
'select sum(<FIELD>) from <TABLE>',
'select sum(<FIELD>) from <TABLE> where <FIELD> = 1',
],
show: ['show tables', 'show fields from <TABLE>'],
'show tables': ['show tables', 'show fields from <TABLE>'],
'show fields from': ['show tables', 'show fields from <TABLE>'],
};
export const rowCallOptions = [
'All()',
'ConstRow(',
'Difference(',
'Intersect(',
'Limit(',
'Not(',
'Row(',
'Union(',
'UnionRows(',
'Xor('
];
export const pqlKeywords = [
'All()',
'Clear(',
'ClearRow(',
'ConstRow(columns=[',
'Count(',
'Difference(',
'Distinct(',
'Extract(',
'GroupBy(',
'IncludesColumn(',
'Intersect(',
'Limit(',
'Max(',
'Min(',
'Not(',
'Options(',
'Row(',
'Rows(',
'Set(',
'SetColumnAttrs(',
'SetRowAttrs(',
'Sum(',
'Store(',
'TopK(',
'TopN(',
'Union(',
'UnionRows(',
'Xor('
];
export const sqlKeywords = [
'drop table ',
'show ',
'show tables ',
'show fields from ',
'select ',
'select * from ',
'select _id from ',
'select count(',
'select distinct ',
'select avg(',
'select max(',
'select min(',
'select sum('
];

View file

@ -0,0 +1 @@
export * from './Console';

View file

@ -0,0 +1,108 @@
.consoleCol {
padding-right: 32px;
}
.consoleLayout {
display: flex;
width: 100%;
margin-top: 20px;
}
.consoleContent {
flex-grow: 1;
.syntaxHelper {
margin: 16px 0;
font-family: 'Roboto Mono', monospace;
}
}
.fieldsCol {
padding: 0 32px;
margin: 16px 0;
.fieldList {
font-family: 'Roboto Mono', monospace;
> div {
white-space: nowrap;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
.toggleFields {
margin-left: 16px;
font-size: 12px;
color: var(--text-secondary);
transition: color 0.15s ease;
&:hover {
cursor: pointer;
color: var(--primary);
text-decoration: underline;
}
}
.filterDropdown {
max-height: 300px;
overflow: auto;
margin: 3px 0 0 8px;
.filterDropdownItem {
padding: 8px 16px;
transition: background-color 0.15s ease;
&.hover {
background: rgba(var(--base-rgb), 0.3);
cursor: pointer;
}
&.active {
background: rgba(var(--primary-rgb), 0.3);
}
}
.noResults {
padding: 8px 16px;
font-style: italic;
}
}
.results {
padding: 2px 16px;
margin-bottom: 32px;
}
.loadingContainer {
padding: 16px;
text-align: center;
margin-bottom: 32px;
}
.clearResults {
text-align: right;
margin-bottom: 16px;
.divider {
margin: 0 8px;
}
.recentLink,
.clearLink {
color: var(--text-secondary);
font-size: 12px;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
}
.list {
padding-bottom: 24px;
}

View file

@ -0,0 +1,253 @@
import React, {
createRef,
FC,
Fragment,
useCallback,
useEffect,
useState
} from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Paper from '@material-ui/core/Paper';
import Split from 'react-split';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { Console } from './Console';
import { motion } from 'framer-motion';
import { QueryResults } from './QueryResults';
import { RecentQueries } from './RecentQueries';
import { ResultType } from './QueryContainer';
import './splitjs.scss';
import css from './Query.module.scss';
type QueryProps = {
indexList: any[];
results: ResultType[];
error?: ResultType;
loading: boolean;
onRemoveResult: (resultIdx: number) => void;
onClear: () => void;
onQuery: (query: string, type: 'PQL' | 'SQL', index?: string) => void;
};
export const Query: FC<QueryProps> = ({
indexList = [],
results,
error,
loading,
onRemoveResult,
onClear,
onQuery
}) => {
const [index, setIndex] = useState<any>();
const [helperText, setHelperText] = useState<string[]>([]);
const [showRecent, setShowRecent] = useState<boolean>(false);
const colSizes = JSON.parse(localStorage.getItem('colSizes') || '[80, 20]');
const inputRef = createRef<HTMLInputElement>();
const queries = JSON.parse(localStorage.getItem('recent-queries') || '[]');
const hasRecentQueries = queries.length > 0;
useEffect(() => {
if (!error) {
setIndex(undefined);
setHelperText([]);
}
}, [results, error]);
const onSetIndex = useCallback(
(queryIndex: string | undefined) => {
const matchIndex = indexList.find((i) => i.name === queryIndex);
if (matchIndex) {
setIndex(matchIndex);
} else {
setIndex(undefined);
}
},
[setIndex, indexList]
);
const onReRunQuery = (query: string) => {
const queryPattern = query.match(/\[(.*?)\]/);
if (query[0] === '[' || (queryPattern && queryPattern.index === 0)) {
const indexStart = query.indexOf('[') + 1;
const indexEnd = query.indexOf(']');
const index = query.substring(indexStart, indexEnd);
const pqlQuery = query.substring(indexEnd + 1);
onQuery(pqlQuery, 'PQL', index);
} else {
onQuery(query, 'SQL');
}
setIndex(undefined);
};
return (
<Fragment>
<Block>
<Typography variant="h5" color="textSecondary">
Query
</Typography>
<Split
sizes={colSizes}
cursor="col-resize"
minSize={215}
onDragEnd={(sizes) =>
localStorage.setItem('colSizes', JSON.stringify(sizes))
}
gutter={(_index, direction) => {
const gutter = document.createElement('div');
gutter.className = `gutter gutter-margin-top gutter-${direction}`;
const dragbars = document.createElement('div');
dragbars.className = 'dragBars';
gutter.appendChild(dragbars);
return gutter;
}}
style={{ display: 'flex' }}
>
<div className={css.consoleCol}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.7, delay: 0.3 }}
>
<div className={css.consoleLayout}>
<div className={css.consoleContent}>
<Console
ref={inputRef}
onQuery={(
query: string,
type: 'PQL' | 'SQL',
index?: string
) => onQuery(query, type, index)}
error={error}
onHelperTextChange={setHelperText}
onSetIndex={onSetIndex}
loading={loading}
indexList={indexList}
previousQueries={queries}
/>
<Typography
className={css.syntaxHelper}
color="textSecondary"
variant="caption"
component="div"
>
{helperText
? helperText.map((txt) => <div key={txt}>{txt}</div>)
: null}
</Typography>
</div>
</div>
</motion.div>
{results.length > 0 ? (
<div className={css.clearResults}>
<span
className={css.recentLink}
onClick={() => setShowRecent(true)}
>
Show Recent Queries
</span>
<span className={css.divider}>|</span>
<span className={css.clearLink} onClick={onClear}>
Clear Results
</span>
</div>
) : null}
{!results.length && hasRecentQueries ? (
<Fragment>
<Typography color="textPrimary" variant="overline">
Recent Queries
</Typography>
<RecentQueries onQueryClick={(query) => onReRunQuery(query)} />
</Fragment>
) : null}
{loading && (
<Paper className={css.loadingContainer}>
<CircularProgress size={20} />
</Paper>
)}
{results.map((result, idx) => (
<Paper key={`query-result-${idx}`} className={css.results}>
<QueryResults
collapsibleQuery={false}
results={result}
onRemoveResult={() => onRemoveResult(idx)}
/>
</Paper>
))}
</div>
{index ? (
<div className={css.fieldsCol}>
<Typography color="textPrimary" variant="overline">
Available Fields
</Typography>
<Typography
className={css.fieldList}
color="textSecondary"
variant="caption"
component="div"
>
{index.fields.map((field) => (
<div
key={field.name}
title={`${field.name} (${field.options.type})`}
>
{field.name} ({field.options.type})
</div>
))}
</Typography>
</div>
) : (
<div className={css.fieldsCol}>
<Typography color="textPrimary" variant="overline">
Available Indexes
</Typography>
{indexList ? (
<Typography
className={css.fieldList}
color="textSecondary"
variant="caption"
component="div"
>
{indexList.map((i) => (
<div key={i.name} title={i.name}>
{i.name}
</div>
))}
</Typography>
) : (
<Typography
color="textSecondary"
variant="caption"
component="div"
>
No indexes available
</Typography>
)}
</div>
)}
</Split>
</Block>
<Dialog
open={showRecent}
onClose={() => setShowRecent(false)}
maxWidth="md"
>
<DialogTitle>Recent Queries</DialogTitle>
<DialogContent>
<div className={css.list}>
<RecentQueries
onQueryClick={(query) => {
setShowRecent(false);
onReRunQuery(query);
}}
/>
</div>
</DialogContent>
</Dialog>
</Fragment>
);
};

View file

@ -0,0 +1,135 @@
import React, { FC, useState } from 'react';
import moment, { Moment } from 'moment';
import uniqBy from 'lodash/uniqBy';
import { Query } from './Query';
import { pilosa } from 'services/eventServices';
import { useEffectOnce } from 'react-use';
import { grpc } from '@improbable-eng/grpc-web';
import { queryPQL, querySQL } from 'services/grpcServices';
import { ColumnInfo, ColumnResponse, RowResponse } from 'proto/pilosa_pb';
export type ResultType = {
query: string;
operation: string;
type: 'PQL' | 'SQL';
headers: ColumnInfo.AsObject[];
rows: ColumnResponse.AsObject[][];
duration?: number;
roundtrip: number;
index?: string;
error: string;
};
let streamingResults: ResultType = {
query: '',
operation: '',
type: 'SQL',
headers: [],
rows: [],
roundtrip: 0,
error: ''
};
export const QueryContainer: FC<{}> = () => {
let startTime: Moment;
const [indexes, setIndexes] = useState<any>();
const [results, setResults] = useState<ResultType[]>([]);
const [errorResult, setErrorResult] = useState<ResultType>();
const [loading, setLoading] = useState<boolean>(false);
useEffectOnce(() => {
pilosa.get.schema().then((res) => {
setIndexes(res.data.indexes);
});
});
const handleQueryMessages = (message: RowResponse) => {
const response = message.toObject();
if (response.headersList.length > 0) {
streamingResults.headers = response.headersList;
streamingResults.duration = response.duration;
}
streamingResults.rows.push(response.columnsList);
};
const handleQueryEnd = (status: grpc.Code, statusMessage: string) => {
if (status !== grpc.Code.OK) {
streamingResults.error = statusMessage;
setErrorResult(streamingResults);
} else {
let recentQueries = JSON.parse(
localStorage.getItem('recent-queries') || '[]'
);
const lastQuery = localStorage.getItem('last-query');
recentQueries.unshift(lastQuery);
recentQueries = uniqBy(recentQueries);
if (recentQueries.length > 10) {
localStorage.setItem(
'recent-queries',
JSON.stringify(recentQueries.slice(0, 9))
);
} else {
localStorage.setItem('recent-queries', JSON.stringify(recentQueries));
}
streamingResults.roundtrip = moment
.duration(moment().diff(startTime))
.as('milliseconds');
setErrorResult(undefined);
setResults([streamingResults, ...results]);
}
setLoading(false);
};
const onQuery = (query: string, type: 'PQL' | 'SQL', index?: string) => {
streamingResults = {
query,
operation: '',
type,
headers: [],
rows: [],
index,
roundtrip: 0,
error: ''
};
startTime = moment();
if (query) {
setLoading(true);
localStorage.setItem(
'last-query',
type === 'PQL' ? `[${index}]${query}` : query
);
if (type === 'PQL') {
if (index) {
queryPQL(index, query, handleQueryMessages, handleQueryEnd);
} else {
streamingResults.error = 'missing index';
setErrorResult(streamingResults);
setLoading(false);
}
} else {
querySQL(query, handleQueryMessages, handleQueryEnd);
}
}
};
const removeResultItem = (resultIdx: number) => {
const resultsClone = [...results];
resultsClone.splice(resultIdx, 1);
setResults(resultsClone);
};
return (
<Query
indexList={indexes}
onQuery={onQuery}
results={results}
error={errorResult}
loading={loading}
onClear={() => setResults([])}
onRemoveResult={removeResultItem}
/>
);
};

View file

@ -0,0 +1,58 @@
.queryMetadata {
margin-bottom: 16px;
}
.queryItemHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.queryHeaderRight {
display: flex;
align-items: center;
.removeItemButton {
margin-left: 16px;
}
}
.durationTooltip {
display: grid;
grid-template-columns: auto auto;
grid-gap: 4px;
.duration {
text-align: right;
}
}
.queryString {
white-space: pre-wrap;
display: block;
font-size: 12px;
}
.link {
color: var(--primary);
font-size: 12px;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
.queryHeader {
display: flex;
align-items: center;
line-height: 1;
color: var(--text-secondary);
.icon {
margin-left: 4px;
font-size: 0.75rem;
}
}

View file

@ -0,0 +1,149 @@
import React, { FC, Fragment, useState } from 'react';
import CloseIcon from '@material-ui/icons/Close';
import copy from 'copy-to-clipboard';
import FileCopySharpIcon from '@material-ui/icons/FileCopySharp';
import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { DataTable } from 'shared/DataTable';
import { formatDuration } from 'shared/utils/formatDuration';
import { GroupByChart } from 'App/QueryBuilder/GroupByChart';
import { ResultType } from '../QueryContainer';
import css from './QueryResults.module.scss';
type QueryResultsProps = {
collapsibleQuery?: boolean;
results: ResultType;
onRemoveResult?: () => void;
};
export const QueryResults: FC<QueryResultsProps> = ({
collapsibleQuery = true,
results,
onRemoveResult
}) => {
const [showQuery, setShowQuery] = useState<boolean>(false);
const [copyTooltip, setCopyTooltip] = useState<string>('Copy Query');
const queryString =
results.type === 'PQL'
? `[${results.index}]${results.query}`
: results.query;
const headers = results.headers;
const data = results.rows.map((row) => {
let rowData = {};
row.forEach((col, colIdx) => {
const header = headers[colIdx];
if (header.datatype.includes('[]')) {
const dataTypeVal = `${header.datatype.slice(2)}arrayval`;
rowData[header.name] = col[dataTypeVal].valsList.join(', ');
} else if (header.datatype === 'decimal') {
const decimalVal = col[`${header.datatype}val`];
if (decimalVal) {
const { value, scale } = decimalVal;
rowData[header.name] = value / Math.pow(10, scale);
} else {
rowData[header.name] = decimalVal;
}
} else {
rowData[header.name] = col[`${header.datatype}val`];
}
});
return rowData;
});
const onCopyQuery = () => {
copy(queryString);
setCopyTooltip('Copied!');
setTimeout(() => {
setCopyTooltip('Copy Query');
}, 1500);
};
return (
<Fragment>
<div className={css.queryMetadata}>
<div className={css.queryItemHeader}>
<div className={css.queryHeader}>
{!collapsibleQuery ? (
<Typography color="textSecondary" variant="overline">
Query
</Typography>
) : (
<span
onClick={() => setShowQuery(!showQuery)}
className={css.link}
>
{showQuery ? 'Hide' : 'Show'} Query
</span>
)}
<Tooltip title={copyTooltip} placement="top" arrow>
<IconButton
className={css.icon}
size="small"
color="inherit"
onClick={onCopyQuery}
>
<FileCopySharpIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</div>
<div className={css.queryHeaderRight}>
{results.duration ? (
<Tooltip
title={
<Typography
className={css.durationTooltip}
color="textSecondary"
variant="caption"
component="div"
>
<div className={css.duration}>
{formatDuration(results.duration, true)}
</div>
<div>| query time</div>
<div className={css.duration}>
{formatDuration(results.roundtrip)}
</div>
<div>| total roundtrip time</div>
</Typography>
}
placement="top"
arrow
>
<Typography
color="textSecondary"
variant="caption"
component="div"
>
{formatDuration(results.duration, true)}
</Typography>
</Tooltip>
) : null}
{onRemoveResult ? (
<IconButton
className={css.removeItemButton}
edge="end"
size="small"
aria-label="Remove result item"
onClick={onRemoveResult}
>
<CloseIcon fontSize="small" />
</IconButton>
) : null}
</div>
</div>
{!collapsibleQuery || showQuery ? (
<code className={css.queryString}>{queryString}</code>
) : null}
</div>
{results.operation === 'GroupBy' && results.rows.length <= 50 ? (
<GroupByChart results={results} />
) : (
<DataTable headers={headers} data={data} autoWidth={true} />
)}
</Fragment>
);
};

View file

@ -0,0 +1 @@
export * from './QueryResults';

View file

@ -0,0 +1,28 @@
.queryItem {
display: flex;
align-items: center;
&:hover {
cursor: pointer;
}
.copyIcon {
margin-right: 4px;
font-size: 0.75rem;
opacity: 0.3;
transition: opacity 0.3s ease;
&:hover {
opacity: 1;
}
}
.queryText {
font-family: 'Roboto Mono', monospace;
transition: color 0.3s ease;
&:hover {
color: var(--text-primary);
}
}
}

View file

@ -0,0 +1,72 @@
import React, { FC, Fragment, useState } from 'react';
import copy from 'copy-to-clipboard';
import FileCopySharpIcon from '@material-ui/icons/FileCopySharp';
import IconButton from '@material-ui/core/IconButton';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { MotionGroup, MotionSlideItem } from 'shared/Animations';
import css from './RecentQueries.module.scss';
type RecentQueriesType = {
onQueryClick?: (query: string) => void;
};
export const RecentQueries: FC<RecentQueriesType> = ({ onQueryClick }) => {
const [copyText, setCopyText] = useState<string>('Click to copy query');
const queries = JSON.parse(localStorage.getItem('recent-queries') || '[]');
const onCopyQuery = (query: string) => {
copy(query);
setCopyText('Copied!');
setTimeout(() => {
setCopyText('Click to copy query');
}, 1500);
};
const renderItem = (query: string) => (
<div
className={css.queryItem}
onClick={() => (onQueryClick ? onQueryClick(query) : null)}
>
<Tooltip title={copyText} placement="top" arrow>
<IconButton
className={css.copyIcon}
size="small"
color="inherit"
onClick={(e) => {
e.stopPropagation();
onCopyQuery(query);
}}
>
<FileCopySharpIcon fontSize="inherit" />
</IconButton>
</Tooltip>
<Tooltip title="Re-run query" placement="right-end" arrow>
<Typography
className={css.queryText}
color="textSecondary"
variant="caption"
component="div"
>
{query}
</Typography>
</Tooltip>
</div>
);
return (
<Fragment>
{queries && queries.length > 0 ? (
<MotionGroup>
{queries.map((query) => (
<MotionSlideItem key={query}>{renderItem(query)}</MotionSlideItem>
))}
</MotionGroup>
) : (
<Typography variant="caption" color="textSecondary" component="div">
No recent queries.
</Typography>
)}
</Fragment>
);
};

View file

@ -0,0 +1 @@
export * from './RecentQueries';

View file

@ -0,0 +1,2 @@
export * from './Query';
export * from './QueryContainer';

View file

@ -0,0 +1,32 @@
.gutter {
&:hover {
cursor: col-resize;
}
}
// kinda hacky, but unable to disable adjustments if one pane gets
// collapsed
.hide-gutter {
.gutter {
display: none;
}
}
.gutter-margin-top {
margin-top: 16px;
}
.dragBars {
width: 4px;
height: 100%;
margin: 0 3px;
border-left: 1px solid rgba(var(--contrast-rgb), 0.1);
border-right: 1px solid rgba(var(--contrast-rgb), 0.1);
}
.dragBar {
width: 1px;
height: 100%;
margin: 0 4px;
border-right: 1px solid rgba(var(--contrast-rgb), 0.1);
}

View file

@ -0,0 +1,20 @@
.layout {
display: grid;
grid-template-columns: 300px 300px;
grid-gap: 8px 32px;
}
.droppable {
max-height: 400;
overflow-y: scroll;
transition: background-color 0.3s ease;
&.isDraggingOver {
background-color: rgba(var(--primary-rgb), 0.1);
}
}
.dragHandle {
font-size: 1rem;
margin-right: 4px;
}

View file

@ -0,0 +1,212 @@
import React, { FC, useEffect, useState } from 'react';
import Button from '@material-ui/core/Button';
import classNames from 'classnames';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import DragIndicatorIcon from '@material-ui/icons/DragIndicator';
import isEqual from 'lodash/isEqual';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import {
DragDropContext,
Droppable,
Draggable,
DroppableProvided,
DraggableProvided,
DropResult,
DraggableLocation,
DroppableStateSnapshot
} from 'react-beautiful-dnd';
import css from './ColumnSelector.module.scss';
type ColumnSelectorProps = {
open: boolean;
fieldsList: { name: string; show: boolean }[];
onChange: (updatedList: { name: string; show: boolean }[]) => void;
onClose: () => void;
};
export const ColumnSelector: FC<ColumnSelectorProps> = ({
open,
fieldsList,
onChange,
onClose
}) => {
const [updatedList, setUpdatedList] = useState<{
show: string[];
hide: string[];
}>({ show: [], hide: [] });
useEffect(() => {
setUpdatedList({
show: fieldsList.filter((f) => f.show).map((f) => f.name),
hide: fieldsList.filter((f) => !f.show).map((f) => f.name)
});
}, [fieldsList]);
const onUpdateColumns = () => {
let list: { name: string; show: boolean }[] = [];
updatedList.show.forEach((item) => list.push({ name: item, show: true }));
updatedList.hide.forEach((item) => list.push({ name: item, show: false }));
if (isEqual(fieldsList, list)) {
onClose();
} else {
onChange(list);
onClose();
}
};
const onCloseSelector = () => {
setUpdatedList({
show: fieldsList.filter((f) => f.show).map((f) => f.name),
hide: fieldsList.filter((f) => !f.show).map((f) => f.name)
});
onClose();
}
const reorder = (list: string[], startIdx: number, endIdx: number) => {
const result = Array.from(list);
const [removed] = result.splice(startIdx, 1);
result.splice(endIdx, 0, removed);
return result;
};
const move = (source: DraggableLocation, dest: DraggableLocation) => {
const sourceType = source.droppableId === 'show' ? 'show' : 'hide';
const destType = dest.droppableId === 'show' ? 'show' : 'hide';
const sourceClone = [...updatedList[sourceType]];
const destClone = [...updatedList[destType]];
const [removed] = sourceClone.splice(source.index, 1);
destClone.splice(dest.index, 0, removed);
const result = { ...updatedList };
result[sourceType] = sourceClone;
result[destType] = destClone;
return result;
};
const onDragEnd = (result: DropResult) => {
const { source, destination } = result;
if (!destination) {
return;
}
if (source.droppableId === destination.droppableId) {
const listType = source.droppableId === 'show' ? 'show' : 'hide';
const newList = reorder(
[...updatedList[listType]],
source.index,
destination.index
);
setUpdatedList({ ...updatedList, [listType]: newList });
} else {
const newList = move(source, destination);
setUpdatedList(newList);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" disableBackdropClick>
<DialogTitle>Configure Result Fields</DialogTitle>
<DialogContent>
<Typography variant="caption" color="textSecondary" paragraph>
Drag and drop fields to reorder, show or hide fields.
</Typography>
<div className={css.layout}>
<Typography variant="overline">Show Fields</Typography>
<Typography variant="overline">Hide Fields</Typography>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="show">
{(
provided: DroppableProvided,
snapshot: DroppableStateSnapshot
) => (
<Paper
ref={provided.innerRef}
{...provided.droppableProps}
className={classNames(css.droppable, {
[css.isDraggingOver]: snapshot.isDraggingOver
})}
>
<List dense>
{updatedList.show.map((field, idx) => (
<Draggable key={field} draggableId={field} index={idx}>
{(provided: DraggableProvided) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<DragIndicatorIcon
className={css.dragHandle}
fontSize="small"
color="disabled"
/>
{field}
</ListItem>
)}
</Draggable>
))}
{provided.placeholder}
</List>
</Paper>
)}
</Droppable>
<Droppable droppableId="hide">
{(
provided: DroppableProvided,
snapshot: DroppableStateSnapshot
) => (
<Paper
ref={provided.innerRef}
{...provided.droppableProps}
className={classNames(css.droppable, {
[css.isDraggingOver]: snapshot.isDraggingOver
})}
>
<List dense>
{updatedList.hide.map((field, idx) => (
<Draggable key={field} draggableId={field} index={idx}>
{(provided: DraggableProvided) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<DragIndicatorIcon
className={css.dragHandle}
fontSize="inherit"
color="disabled"
/>
{field}
</ListItem>
)}
</Draggable>
))}
{provided.placeholder}
</List>
</Paper>
)}
</Droppable>
</DragDropContext>
</div>
</DialogContent>
<DialogActions>
<Button onClick={onCloseSelector}>Cancel</Button>
<Button variant="contained" color="primary" onClick={onUpdateColumns}>
Apply
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1 @@
export * from './ColumnSelector';

View file

@ -0,0 +1,128 @@
import React, { FC } from 'react';
import groupBy from 'lodash/groupBy';
import { ResponsiveBar } from '@nivo/bar';
import { ResultType } from '../../Query';
import { schemeTableau10 } from 'd3-scale-chromatic';
import { useTheme } from '@material-ui/core/styles';
type GroupByChartType = {
results: ResultType;
};
export const GroupByChart: FC<GroupByChartType> = ({ results }) => {
const theme = useTheme();
const isDark = theme.palette.type === 'dark';
const { headers, rows } = results;
let uniqueKeys: string[] = [];
const grouped = groupBy(rows, (row) => row[0].stringval);
const data = Object.keys(grouped).map((key) => {
let groupData = {};
grouped[key].forEach((row) => {
const secondaryValue = row[1][`${headers[1].datatype}val`];
if (headers.length > 2) {
groupData = {
...groupData,
[headers[0].name]: row[0][`${headers[0].datatype}val`],
[secondaryValue]: row[2][`${headers[2].datatype}val`]
};
if (!uniqueKeys.includes(secondaryValue.toString())) {
uniqueKeys.push(secondaryValue.toString());
}
} else {
groupData = {
...groupData,
[headers[0].name]: row[0][`${headers[0].datatype}val`],
value: secondaryValue
};
}
});
return groupData;
});
return (
<div style={{ height: '80%' }}>
<ResponsiveBar
data={data}
keys={uniqueKeys.length > 0 ? uniqueKeys : undefined}
indexBy={headers[0].name}
margin={{ top: 50, right: 130, bottom: 100, left: 60 }}
padding={0.3}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={schemeTableau10}
enableLabel={false}
theme={
isDark
? {
textColor: 'var(--text-secondary)',
axis: {
domain: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } }
},
grid: { line: { stroke: 'rgba(255, 255, 255, 0.1)' } },
tooltip: { container: { background: '#1c2022' } }
}
: {
axis: {
domain: { line: { stroke: '#dddddd' } }
}
}
}
groupMode="grouped"
axisBottom={{
tickSize: 5,
tickPadding: 5,
tickRotation: -40,
legend: headers[0].name,
legendPosition: 'middle',
legendOffset: 80
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'count',
legendPosition: 'middle',
legendOffset: -40
}}
tooltip={({ id, value, color }) => (
<strong style={{ color }}>
{id}: {value}
</strong>
)}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={{ from: 'color', modifiers: [['darker', 1.6]] }}
legends={[
{
dataFrom: 'keys',
anchor: 'bottom-right',
direction: 'column',
justify: false,
translateX: 120,
translateY: 0,
itemsSpacing: 2,
itemWidth: 120,
itemHeight: 20,
itemDirection: 'left-to-right',
itemOpacity: 0.85,
symbolShape: 'circle',
symbolSize: 20,
effects: [
{
on: 'hover',
style: {
itemOpacity: 1
}
}
]
}
]}
animate={true}
motionStiffness={90}
motionDamping={15}
/>
</div>
);
};

View file

@ -0,0 +1 @@
export * from './GroupByChart';

View file

@ -0,0 +1,169 @@
.builderColumn,
.results {
height: calc(100vh - 64px);
overflow: scroll;
position: relative;
.openClose {
position: absolute;
right: 10px;
top: 10px;
}
}
.resultsBlock {
height: 100%;
}
.builderColumn {
display: grid;
grid-template-rows: auto 1fr auto;
}
.results {
padding-bottom: 20px;
}
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
.download {
display: flex;
align-items: center;
}
.downloadInfo {
margin-right: 8px;
}
}
.collapsedBuilder {
border-right: 1px solid rgba(var(--contrast-rgb), 0.1);
text-align: center;
padding-top: 10px;
}
.builderHeader {
padding: 32px 32px 0;
}
.builder {
overflow-y: scroll;
padding: 20px 32px;
.queryMetadata,
.tableSelector,
.operationSelector {
margin-bottom: 16px;
}
.columnsSelector {
margin: 4px 14px;
display: flex;
text-align: center;
.info {
margin-left: 4px;
fill: var(--text-secondary);
}
}
.queryMetadata {
border-bottom: 1px solid var(--divider);
padding-bottom: 16px;
}
}
.builderActions {
padding: 16px 32px;
display: flex;
justify-content: space-between;
.mainActions {
display: grid;
grid-template-columns: auto auto;
grid-gap: 8px;
}
}
.newGroup,
.operator {
display: flex;
align-items: center;
width: 100%;
.line {
flex-grow: 1;
height: 1px;
background: var(--divider);
}
.addButton {
margin: 0 8px;
}
}
.groupBy {
.line {
flex-grow: 1;
height: 1px;
background: var(--divider);
}
.fieldSelector {
margin-top: 16px;
}
}
.savePopover {
width: 300px;
.popoverActions {
display: flex;
}
.saveActions {
display: grid;
grid-template-columns: auto auto;
grid-gap: 8px;
padding-top: 16px;
}
}
.menuPaper {
padding: 0 !important;
}
.link {
color: var(--primary);
font-size: 12px;
.icon {
margin-right: 4px;
vertical-align: text-top;
}
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
.infoMessage {
padding: 16px;
border: 1px solid rgba(var(--primary-rgb), 0.5);
background: rgba(var(--primary-rgb), 0.1);
border-radius: 4px;
margin: 4px 0 16px;
}
.resultsLoading {
margin-left: 8px;
}
.savedQueries {
margin-top: 48px;
}

View file

@ -0,0 +1,804 @@
import React, { FC, Fragment, useState } from 'react';
import AddIcon from '@material-ui/icons/Add';
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import Button from '@material-ui/core/Button';
import CircularProgress from '@material-ui/core/CircularProgress';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/Info';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Popover from '@material-ui/core/Popover';
import Split from 'react-split';
import TextField from '@material-ui/core/TextField';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { ColumnSelector } from './ColumnSelector';
import { formatDuration } from 'shared/utils/formatDuration';
import {
Operator,
groupOperators,
RowCallType,
RowGrouping,
RowsCallType
} from './rowTypes';
import { getIPRange } from 'get-ip-range';
import { QueryResults } from 'App/Query/QueryResults';
import { ResultType } from 'App/Query/QueryContainer';
import { RowCall } from './RowCall';
import { SavedQueries } from './SavedQueries';
import { Select } from 'shared/Select';
import css from './QueryBuilder.module.scss';
type QueryBuilderProps = {
tables: any[];
results?: ResultType;
fullResultsCount?: number;
fullRecordsCount?: number;
error?: ResultType;
loading: boolean;
onQuery: (
table: any,
operation: string,
rowData: RowGrouping[],
columns: string[],
operator?: Operator
) => void;
onRunGroupBy: (table: any, rowsData: RowsCallType) => void;
onExternalLookup: (table: string, columns: number[]) => void;
onClear: () => void;
};
export const QueryBuilder: FC<QueryBuilderProps> = ({
tables,
results,
fullResultsCount,
fullRecordsCount,
error,
loading,
onQuery,
onRunGroupBy,
onExternalLookup,
onClear
}) => {
const [newOperatorEl, setNewOperatorEl] = useState<null | HTMLElement>(null);
const [operatorEl, setOperatorEl] = useState<null | HTMLElement>(null);
const [saveButtonEl, setSaveButtonEl] = useState<null | HTMLElement>(null);
const [showBuilder, setShowBuilder] = useState<boolean>(true);
const [showColumnSelector, setShowColumnSelector] = useState<boolean>(false);
const [hasInvalid, setHasInvalid] = useState<boolean>(false);
const [selectedTable, setSelectedTable] = useState<any>(tables[0]);
const [selectedColumns, setSelectedColumns] = useState<
{ name: string; show: boolean }[]
>(
tables[0].fields.map((field) => ({
name: field.name,
show: true
}))
);
const [operation, setOperation] = useState<string>('Extract');
const [groupByCall, setGroupByCall] = useState<RowsCallType>({
primary: '',
secondary: ''
});
const [rowCalls, setRowCalls] = useState<RowGrouping[]>([]);
const [operator, setOperator] = useState<Operator>();
const [editSavedIdx, setEditSavedIdx] = useState<number>(-1);
const [queryName, setQueryName] = useState<string>('');
const [queryDescription, setQueryDescription] = useState<string>('');
const [updating, setUpdating] = useState<boolean>(false);
const [queriesList, setQueriesList] = useState<any[]>(
JSON.parse(localStorage.getItem('saved-queries') || '[]')
);
const colSizes = JSON.parse(
localStorage.getItem('builderColSizes') || '[25, 75]'
);
const queryNameIdx = queriesList.findIndex((q) => q.name === queryName);
const inEditMode = editSavedIdx >= 0;
const getColumnsToShow = (cols?: { name: string; show: boolean }[]) => {
let columns: string[] = [];
const list = cols ? cols : selectedColumns;
list.forEach((field) => {
if (field.show) {
columns.push(field.name);
}
});
return columns;
};
const onChangeColumns = (cols) => {
setSelectedColumns(cols);
if (results) {
const { cleanRowCalls, isInvalid } = cleanupRows();
setHasInvalid(isInvalid);
setRowCalls(cleanRowCalls);
if (!isInvalid) {
let columns: string[] = [];
if (operation === 'Extract') {
columns = getColumnsToShow(cols);
}
onQuery(selectedTable, operation, cleanRowCalls, columns, operator);
}
}
};
const onNewGroup = () => {
const newRow: RowCallType[] = [
{
field: '',
rowOperator: '=',
value: '',
type: 'set'
}
];
setRowCalls([...rowCalls, { row: newRow }]);
};
const onRemoveGroup = (groupIdx: number) => {
if (groupIdx === 0) {
setRowCalls(rowCalls.slice(1));
} else {
const updatedRowCalls = [...rowCalls];
updatedRowCalls.splice(groupIdx, 1);
setRowCalls(updatedRowCalls);
if (updatedRowCalls.length < 1) {
setOperator(undefined);
}
}
};
const onUpdateRow = (
groupIdx: number,
newRowData?: RowCallType[],
operator?: Operator,
isNot?: boolean
) => {
const updatedRowCalls = [...rowCalls];
if (newRowData) {
const clone: RowGrouping = {
...rowCalls[groupIdx],
row: newRowData,
isNot
};
if (newRowData.length <= 1) {
delete clone.operator;
} else if (operator) {
clone.operator = operator;
}
updatedRowCalls.splice(groupIdx, 1, clone);
} else {
updatedRowCalls.splice(groupIdx, 1);
}
setRowCalls(updatedRowCalls);
};
const cleanupRows = () => {
// remove empty groups and row calls
let isInvalid = false;
let cleanRowCalls: RowGrouping[] = [];
let cleanGroups: RowGrouping[] = [];
rowCalls.forEach((group, groupIdx) => {
cleanGroups.push({ ...group, row: [] });
group.row.forEach((row) => {
const { field, value, rowOperator } = row;
if (field || value) {
cleanGroups[groupIdx].row.push(row);
if (!field || !value) {
isInvalid = true;
} else if (rowOperator === 'cidr') {
try {
getIPRange(value);
} catch (err) {
isInvalid = true;
}
}
}
});
});
cleanGroups.forEach((group) => {
if (group.row.length > 0) {
cleanRowCalls.push(group);
}
});
return { cleanRowCalls, isInvalid };
};
const onRunClick = () => {
if (operation === 'GroupBy') {
onRunGroupBy(selectedTable, groupByCall);
} else {
const { cleanRowCalls, isInvalid } = cleanupRows();
setHasInvalid(isInvalid);
setRowCalls(cleanRowCalls);
if (!isInvalid) {
let columns: string[] = [];
if (operation === 'Extract') {
columns = getColumnsToShow();
}
onQuery(selectedTable, operation, cleanRowCalls, columns, operator);
}
}
};
const onSave = () => {
setUpdating(true);
setTimeout(() => {
setUpdating(false);
}, 1500);
const { cleanRowCalls, isInvalid } = cleanupRows();
let queries = [...queriesList];
const queryObj = {
name: queryName,
description: queryDescription,
table: selectedTable.name,
operation,
columns: operation === 'Extract' ? getColumnsToShow() : undefined,
operator,
rowCalls: cleanRowCalls,
groupByCall,
isInvalid
};
if (inEditMode && queries.length > 0) {
const clone = [...queries];
clone.splice(editSavedIdx, 1, queryObj);
localStorage.setItem('saved-queries', JSON.stringify(clone));
setQueriesList(clone);
} else {
queries.push(queryObj);
localStorage.setItem('saved-queries', JSON.stringify(queries));
setQueriesList(queries);
setEditSavedIdx(queries.length - 1);
}
};
const onExportLogs = () => {
if (results) {
const columns = results.rows.map((row) => row[0].uint64val);
const table = results.index ? results.index : '';
onExternalLookup(table, columns);
}
};
const onSavedQueryClick = (idx: number) => {
const {
name,
description,
table,
operation,
columns,
operator,
rowCalls,
groupByCall,
isInvalid
} = queriesList[idx];
const tableDetails = tables.find((t) => t.name === table);
setEditSavedIdx(idx);
setSelectedTable(tableDetails);
if (columns) {
setSelectedColumns(
tableDetails.fields.map((field) =>
columns.includes(field.name)
? { name: field.name, show: true }
: { name: field.name, show: false }
)
);
}
setOperation(operation);
setOperator(operator);
setRowCalls(rowCalls);
setGroupByCall(groupByCall);
setQueryName(name);
setQueryDescription(description || '');
setHasInvalid(!!isInvalid);
if (!isInvalid) {
if (operation === 'GroupBy') {
onRunGroupBy(tableDetails, groupByCall);
} else {
onQuery(tableDetails, operation, rowCalls, columns, operator);
}
}
};
const onRemoveQuery = (queryIdx: number) => {
let queries = [...queriesList];
queries.splice(queryIdx, 1);
localStorage.setItem('saved-queries', JSON.stringify(queries));
setQueriesList(queries);
};
const reset = () => {
setEditSavedIdx(-1);
setSelectedTable(tables[0]);
setSelectedColumns(
tables[0].fields.map((field) => ({
name: field.name,
show: true
}))
);
setOperation('Extract');
setRowCalls([]);
setQueryName('');
setQueryDescription('');
setHasInvalid(false);
setGroupByCall({
primary: '',
secondary: ''
});
onClear();
};
return (
<Split
sizes={showBuilder ? colSizes : [0, 100]}
cursor="col-resize"
minSize={showBuilder ? 350 : 50}
onDragEnd={(sizes) =>
localStorage.setItem('builderColSizes', JSON.stringify(sizes))
}
gutter={(_index, direction) => {
const gutter = document.createElement('div');
gutter.className = `gutter gutter-${direction}`;
const dragbars = document.createElement('div');
dragbars.className = 'dragBar';
gutter.appendChild(dragbars);
return gutter;
}}
style={{ display: 'flex' }}
className={!showBuilder ? 'hide-gutter' : undefined}
>
{showBuilder ? (
<div className={css.builderColumn}>
<div className={css.openClose}>
<IconButton onClick={() => setShowBuilder(false)} size="small">
<CloseIcon />
</IconButton>
</div>
<div className={css.builderHeader}>
{inEditMode ? (
<span className={css.link} onClick={reset}>
<ArrowBackIcon fontSize="inherit" className={css.icon} />
Back to new query
</span>
) : null}
<Typography variant="h5" color="textSecondary">
{inEditMode ? 'Edit Saved Query' : 'New Query'}
</Typography>
</div>
<div className={css.builder}>
{inEditMode ? (
<div className={css.queryMetadata}>
<TextField
variant="outlined"
label="Query Name"
placeholder="Unique name to identify query"
value={queryName}
error={
!queryName ||
(queryNameIdx >= 0 && queryNameIdx !== editSavedIdx)
}
helperText={
queryNameIdx >= 0 && queryNameIdx !== editSavedIdx
? 'Query name must be unique'
: ''
}
onChange={(event) => setQueryName(event.target.value)}
margin="normal"
size="small"
required
fullWidth
/>
<TextField
variant="outlined"
label="Description"
value={queryDescription}
onChange={(event) => setQueryDescription(event.target.value)}
margin="normal"
size="small"
fullWidth
/>
</div>
) : null}
<div className={css.tableSelector}>
<Select
label="Table"
value={selectedTable.name}
options={tables.map((table) => {
return { label: table.name, value: table.name };
})}
onChange={(value) => {
const table = tables.find((t) => t.name === value);
setSelectedTable(table);
setSelectedColumns(
table.fields.map((field) => ({
name: field.name,
show: true
}))
);
setRowCalls([]);
setOperator(undefined);
}}
/>
</div>
<div className={css.operationSelector}>
<Select
label="Operation"
value={operation}
options={[
{ label: 'Extract', value: 'Extract' },
{ label: 'Count', value: 'Count' },
{ label: 'GroupBy', value: 'GroupBy' }
]}
onChange={(value) => setOperation(value)}
/>
{operation === 'Extract' ? (
<div className={css.columnsSelector}>
<span
className={css.link}
onClick={() => setShowColumnSelector(true)}
>
Configure result fields
</span>
<Tooltip
className={css.info}
title="This controls the fields that will show up in the results table after querying"
placement="top"
arrow
>
<InfoIcon fontSize="inherit" />
</Tooltip>
</div>
) : null}
<ColumnSelector
open={showColumnSelector}
fieldsList={selectedColumns}
onChange={onChangeColumns}
onClose={() => setShowColumnSelector(false)}
/>
</div>
{selectedTable
? rowCalls.map((rowCall, idx) => (
<Fragment key={`group-${idx}`}>
{idx > 0 ? (
<Fragment>
<div className={css.operator}>
<div className={css.line} />
<Button
className={css.addButton}
size="small"
onClick={(event) =>
setOperatorEl(event.currentTarget)
}
>
{operator}
</Button>
<div className={css.line} />
</div>
<Menu
open={!!operatorEl}
anchorEl={operatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={`${idx}-${op}`}
onClick={() => {
setOperator(op);
setOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</Fragment>
) : null}
<RowCall
fields={selectedTable.fields}
rowData={rowCall.row}
isNot={rowCall.isNot}
operator={rowCall.operator}
showErrors={hasInvalid}
onUpdate={(updatedRow, operator, isNot) =>
onUpdateRow(idx, updatedRow, operator, isNot)
}
onRemoveGroup={() => onRemoveGroup(idx)}
/>
</Fragment>
))
: null}
{selectedTable && operation === 'GroupBy' ? (
<div className={css.groupBy}>
<div className={css.line} />
<Select
className={css.fieldSelector}
label="Primary Field"
value={groupByCall.primary}
options={selectedTable.fields.map((field) => {
return {
label: `${field.name} (${field.options.type})`,
value: field.name
};
})}
onChange={(value) =>
setGroupByCall({
...groupByCall,
primary: value.toString()
})
}
/>
<Select
className={css.fieldSelector}
label="Secondary Field (optional)"
value={groupByCall.secondary}
allowEmpty={true}
options={selectedTable.fields.map((field) => {
return {
label: `${field.name} (${field.options.type})`,
value: field.name
};
})}
onChange={(value) =>
setGroupByCall({
...groupByCall,
secondary: value.toString()
})
}
/>
</div>
) : (
<div className={css.newGroup}>
<div className={css.line} />
<IconButton
className={css.addButton}
size="small"
onClick={(event) => {
if (rowCalls.length === 0 || !!operator) {
onNewGroup();
} else {
setNewOperatorEl(event.currentTarget);
}
}}
>
<AddIcon fontSize="inherit" />
</IconButton>
<div className={css.line} />
</div>
)}
<Menu
open={!!newOperatorEl}
anchorEl={newOperatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setNewOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={`operator-${op}`}
onClick={() => {
setOperator(op);
onNewGroup();
setNewOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</div>
<div className={css.builderActions}>
<div className={css.mainActions}>
<Button variant="contained" color="primary" onClick={onRunClick}>
Run
</Button>
{inEditMode ? (
<Button
variant="contained"
color="default"
onClick={onSave}
disabled={
!queryName ||
(queryNameIdx >= 0 && queryNameIdx !== editSavedIdx)
}
>
{updating ? <CircularProgress size="0.875rem" /> : 'Update'}
</Button>
) : (
<Fragment>
<Button
variant="contained"
color="default"
onClick={(event) => {
setSaveButtonEl(event.currentTarget);
setQueryName('');
setQueryDescription('');
}}
>
Save
</Button>
<Popover
open={!!saveButtonEl}
anchorEl={saveButtonEl}
onClose={() => setSaveButtonEl(null)}
anchorOrigin={{
vertical: 'top',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
>
<div className={css.savePopover}>
<Typography variant="caption" paragraph>
Give your query a unique name and a short description to
help you identify it for future use.
</Typography>
<TextField
variant="outlined"
label="Query Name"
placeholder="Unique name to identify query"
value={queryName}
error={queryNameIdx >= 0}
helperText={
queryNameIdx >= 0 ? 'Query name must be unique' : ''
}
onChange={(event) => setQueryName(event.target.value)}
margin="dense"
required
fullWidth
/>
<TextField
variant="outlined"
label="Description"
value={queryDescription}
onChange={(event) =>
setQueryDescription(event.target.value)
}
margin="dense"
fullWidth
/>
<div className={css.popoverActions}>
<div className={css.saveActions}>
<Button
variant="contained"
color="default"
onClick={() => {
onSave();
setSaveButtonEl(null);
}}
disabled={!queryName || queryNameIdx >= 0}
>
Ok
</Button>
<Button
color="default"
onClick={() => setSaveButtonEl(null)}
>
Cancel
</Button>
</div>
</div>
</div>
</Popover>
</Fragment>
)}
</div>
<Button
onClick={() => {
setRowCalls([]);
setHasInvalid(false);
setEditSavedIdx(-1);
setSelectedColumns(
selectedTable.fields.map((field) => ({
name: field.name,
show: true
}))
);
setGroupByCall({
primary: '',
secondary: ''
});
onClear();
}}
>
Clear
</Button>
</div>
</div>
) : (
<div className={css.collapsedBuilder}>
<IconButton onClick={() => setShowBuilder(true)} size="small">
<ArrowForwardIosIcon />
</IconButton>
</div>
)}
<div className={css.results}>
<Block className={css.resultsBlock}>
<div className={css.resultsHeader}>
<Typography variant="h5" color="textSecondary">
Results{' '}
</Typography>
{results?.query.includes('Extract(') ? (
<div className={css.download}>
<Tooltip
className={css.downloadInfo}
title="Download raw data from query results"
placement="top"
arrow
>
<InfoIcon fontSize="inherit" />
</Tooltip>
<Button onClick={onExportLogs}>Download</Button>
</div>
) : null}
</div>
{loading ? <div>Loading...</div> : null}
{results && !error && !loading ? (
<Fragment>
{fullResultsCount && fullRecordsCount ? (
<div className={css.infoMessage}>
{results.duration ? (
<div>
{fullRecordsCount.toLocaleString()} records scanned in{' '}
{formatDuration(results.duration, true)}.
</div>
) : null}
<div>
Showing{' '}
{fullResultsCount > 1000 ? 'first 1,000 rows of' : 'all'}{' '}
{fullResultsCount.toLocaleString()} results.
</div>
</div>
) : null}
<QueryResults results={results} />
</Fragment>
) : null}
{error && !loading ? <div>{error.error}</div> : null}
{!loading && !results && !error ? (
<Fragment>
<Typography variant="caption" color="textSecondary">
Build a query to see results
</Typography>
{queriesList.length > 0 ? (
<div className={css.savedQueries}>
<Typography variant="h5" color="textSecondary">
Saved Queries
</Typography>
<SavedQueries
queries={queriesList}
tables={tables}
onClickSaved={onSavedQueryClick}
onRemoveQuery={onRemoveQuery}
/>
</div>
) : null}
</Fragment>
) : null}
</Block>
</div>
</Split>
);
};

View file

@ -0,0 +1,4 @@
.noTables {
margin-top: 20px;
padding: 12px 16px;
}

View file

@ -0,0 +1,281 @@
import React, { Fragment, useState } from 'react';
import moment, { Moment } from 'moment';
import Alert from '@material-ui/lab/Alert';
import Paper from '@material-ui/core/Paper';
import Snackbar from '@material-ui/core/Snackbar';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { Operator, RowGrouping, RowsCallType } from './rowTypes';
import { pilosa } from 'services/eventServices';
import { QueryBuilder } from './QueryBuilder';
import { useEffectOnce } from 'react-use';
import { ResultType } from 'App/Query/QueryContainer';
import { queryPQL } from 'services/grpcServices';
import { grpc } from '@improbable-eng/grpc-web';
import { RowResponse } from 'proto/pilosa_pb';
import { getIPRange } from 'get-ip-range';
import css from './QueryBuilderContainer.module.scss';
let streamingResults: ResultType = {
query: '',
operation: '',
type: 'PQL',
headers: [],
rows: [],
roundtrip: 0,
error: ''
};
export const QueryBuilderContainer = () => {
let startTime: Moment;
let exportRows: any[] = [];
const [tables, setTables] = useState<any[]>();
const [results, setResults] = useState<ResultType>();
const [fullCount, setFullCount] = useState<number>();
const [recordsCount, setRecordsCount] = useState<number>();
const [errorResult, setErrorResult] = useState<ResultType>();
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
useEffectOnce(() => {
pilosa.get.schema().then((res) => {
setTables(res.data.indexes);
});
});
const handleQueryMessages = (message: RowResponse) => {
const response = message.toObject();
if (response.headersList.length > 0 && response.duration > 0) {
streamingResults.headers = response.headersList;
streamingResults.duration = response.duration;
}
streamingResults.rows.push(response.columnsList);
};
const handleQueryEnd = (status: grpc.Code, statusMessage: string) => {
if (status !== grpc.Code.OK) {
streamingResults.error = statusMessage;
setErrorResult(streamingResults);
} else {
streamingResults.roundtrip = moment
.duration(moment().diff(startTime))
.as('milliseconds');
setErrorResult(undefined);
setResults(streamingResults);
}
setLoading(false);
};
const onRunQuery = (
table: any,
operation: string,
rowData: RowGrouping[],
columnsList: string[],
operator?: Operator
) => {
streamingResults = {
query: '',
operation,
type: 'PQL',
headers: [],
rows: [],
index: table.name,
roundtrip: 0,
error: ''
};
startTime = moment();
setLoading(true);
let query: string = '';
if (rowData.length === 0) {
query = 'All()';
} else {
let rowsMap: string[][] = [];
rowData.forEach((group, groupIdx) => {
rowsMap.push([]);
group.row.forEach((row) => {
let rowString = '';
const { field, rowOperator, value, type } = row;
const isNegatory = rowOperator === '!=';
const isUnion =
['=', '!='].includes(rowOperator) && value.split(',').length > 1;
const operator = isNegatory ? '=' : rowOperator;
if (isUnion) {
const values = value.split(',');
const unionRows = values
.map((v) => `Row(${field}="${v.trim()}")`)
.join(', ');
rowString = `Union(${unionRows})`;
} else if (rowOperator === 'cidr') {
try {
const ipRange = getIPRange(value);
const ipRows = ipRange
.map((ip) => `Row(${field}="${ip}")`)
.join(', ');
rowString = `Union(${ipRows})`;
} catch (error) {
streamingResults.error = error.message;
}
} else {
rowString = ['set', 'timestamp'].includes(type)
? `Row(${field}${operator}"${value}")`
: `Row(${field}${operator}${value})`;
}
if (isNegatory) {
rowsMap[groupIdx].push(`Not(${rowString})`);
} else {
rowsMap[groupIdx].push(rowString);
}
});
});
query = rowsMap
.map((group, idx) => {
let joined = '';
if (group.length > 1) {
joined = group.map((r) => r).join(', ');
const operator = rowData[idx].operator;
if (operator === 'and') {
joined = `Intersect(${joined})`;
} else if (operator === 'or') {
joined = `Union(${joined})`;
}
} else {
joined = group[0];
}
return rowData[idx].isNot ? `Not(${joined})` : joined;
})
.join(', ');
if (rowData.length > 1 && operator) {
if (operator === 'and') {
query = `Intersect(${query})`;
} else if (operator === 'or') {
query = `Union(${query})`;
}
}
}
if (operation !== 'Count') {
const countQuery = `Count(${query})`;
pilosa.post
.query(table.name, countQuery)
.then((res) => setFullCount(res.data.results[0]));
pilosa.post
.query(table.name, `Count(All())`)
.then((res) => setRecordsCount(res.data.results[0]));
} else {
setFullCount(undefined);
}
if (operation === 'Extract') {
const fields = columnsList.map((field) => `Rows(${field})`);
const allRows = fields.join(', ');
query = `${operation}(Limit(${query}, limit=1000), ${allRows})`;
} else {
query = `${operation}(${query})`;
}
streamingResults.query = query;
queryPQL(table.name, query, handleQueryMessages, handleQueryEnd);
};
const handleExternalLookup = (message: RowResponse) => {
const response = message.toObject();
let rowStr: string[] = [];
if (exportRows.length === 0) {
const headers = response.headersList.map((header) => header.name);
exportRows.push(headers.join('\t'));
}
response.headersList.forEach((header, idx) =>
rowStr.push(response.columnsList[idx][`${header.datatype}val`])
);
exportRows.push(rowStr.join('\t'));
};
const handleExternalLookupEnd = (
status: grpc.Code,
statusMessage: string
) => {
if (status !== grpc.Code.OK) {
setError(statusMessage);
} else if (exportRows.length === 0) {
setError('No record attributes for current query.');
} else {
const dateTime = moment().unix();
const element = document.createElement('a');
const file = new Blob([exportRows.join('\n')], {
type: 'text/plain;charset=utf-8'
});
element.href = URL.createObjectURL(file);
element.download = `molecula-${results?.index}-${dateTime}.csv`;
document.body.appendChild(element);
element.click();
exportRows = [];
}
};
const onRunGroupBy = (table: any, rowsData: RowsCallType) => {
streamingResults = {
query: '',
operation: 'GroupBy',
type: 'PQL',
headers: [],
rows: [],
index: table.name,
roundtrip: 0,
error: ''
};
startTime = moment();
setLoading(true);
const query = rowsData.secondary
? `GroupBy(Rows(${rowsData.primary}), Rows(${rowsData.secondary}))`
: `GroupBy(Rows(${rowsData.primary}))`;
streamingResults.query = query;
queryPQL(table.name, query, handleQueryMessages, handleQueryEnd);
};
const onExternalLookup = (table: string, columns: number[]) => {
const cols = JSON.stringify(columns);
const query = `ExternalLookup(ConstRow(columns=${cols}), query='select id, "rawlog" from "${table}" where id = ANY($1)')`;
queryPQL(table, query, handleExternalLookup, handleExternalLookupEnd);
};
return tables && tables.length > 0 ? (
<Fragment>
<QueryBuilder
tables={tables}
results={results}
fullResultsCount={fullCount}
fullRecordsCount={recordsCount}
error={errorResult}
loading={loading}
onQuery={onRunQuery}
onRunGroupBy={onRunGroupBy}
onExternalLookup={onExternalLookup}
onClear={() => {
setResults(undefined);
setFullCount(undefined);
setErrorResult(undefined);
}}
/>
<Snackbar open={!!error}>
<Alert severity="info" onClose={() => setError('')}>
{error}
</Alert>
</Snackbar>
</Fragment>
) : (
<Block>
<Typography variant="h5" color="textSecondary">
Query Builder
</Typography>
<Paper className={css.noTables}>
<Typography variant="caption" color="textSecondary">
There are no tables to query.
</Typography>
</Paper>
</Block>
);
};

View file

@ -0,0 +1,102 @@
.rowGroup {
position: relative;
.removeGroup {
position: absolute;
right: -21px;
top: 6px;
width: 20px;
line-height: 1;
padding: 4px 1px 2px 0;
background: var(--divider);
border-radius: 0 4px 4px 0;
text-align: center;
transition: background-color 0.3s ease;
&:hover {
cursor: pointer;
background: rgba(var(--primary-rgb), 0.3);
}
}
.removeGroupIcon {
font-size: 12px;
}
}
.isNot {
display: flex;
align-items: center;
margin-bottom: 4px;
.isNotCheckbox {
margin-right: 4px;
fill: var(--divider);
&.checked {
fill: var(--primary);
}
&:hover {
cursor: pointer;
}
}
.isNotLabel {
&:hover {
cursor: pointer;
}
}
}
.row {
display: flex;
align-items: center;
padding: 8px 0;
.fieldSelector {
width: auto;
min-width: 85px;
}
}
.operator {
margin: 0 8px;
text-align: center;
}
.rowValue {
flex-grow: 1;
}
.rowGroup {
border: 1px solid var(--divider);
border-radius: 4px;
padding: 8px;
margin: 8px 0;
}
.link,
.removeRowIcon {
color: var(--text-secondary);
font-size: 12px;
white-space: nowrap;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
.link {
color: var(--primary);
}
.removeRowIcon {
margin-left: 8px;
}
.menuPaper {
padding: 0 !important;
}

View file

@ -0,0 +1,302 @@
import React, { FC, Fragment, useState } from 'react';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import classNames from 'classnames';
import CloseIcon from '@material-ui/icons/Close';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import moment from 'moment';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import { getIPRange } from 'get-ip-range';
import { operators } from './helpers';
import { groupOperators, Operator, RowCallType } from '../rowTypes';
import { Select } from 'shared/Select';
import MomentUtils from '@date-io/moment';
import { MuiPickersUtilsProvider, DateTimePicker } from '@material-ui/pickers';
import css from './RowCall.module.scss';
type RowCallProps = {
fields: any[];
rowData: RowCallType[];
isNot?: boolean;
operator?: Operator;
showErrors: boolean;
onUpdate: (
updatedRow: RowCallType[],
operator?: Operator,
isNot?: boolean
) => void;
onRemoveGroup: () => void;
};
export const RowCall: FC<RowCallProps> = ({
fields,
rowData,
isNot,
operator,
showErrors,
onUpdate,
onRemoveGroup
}) => {
const [operatorEl, setOperatorEl] = useState<null | HTMLElement>(null);
const [activeOperatorEl, setActiveOperatorEl] = useState<null | HTMLElement>(
null
);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [activeAnchor, setActiveAnchor] = useState<number>();
const onNewRow = (op?: Operator) => {
const newRow: RowCallType = {
field: '',
rowOperator: '=',
value: '',
type: 'set'
};
if (op) {
onUpdate([...rowData, newRow], op, isNot);
} else {
onUpdate([...rowData, newRow], undefined, isNot);
}
};
const onRowUpdate = (rowIdx: number, data?: RowCallType) => {
const updatedRows = [...rowData];
if (data) {
updatedRows.splice(rowIdx, 1, data);
} else {
updatedRows.splice(rowIdx, 1);
}
onUpdate(updatedRows, operator, isNot);
};
return (
<div className={css.rowGroup}>
<div className={css.removeGroup}>
<CloseIcon
fontSize="inherit"
className={css.removeGroupIcon}
onClick={onRemoveGroup}
/>
</div>
<div className={css.isNot}>
{isNot ? (
<CheckBoxIcon
fontSize="small"
className={classNames(css.isNotCheckbox, css.checked)}
onClick={() => onUpdate(rowData, operator, !isNot)}
/>
) : (
<CheckBoxOutlineBlankIcon
fontSize="small"
className={css.isNotCheckbox}
onClick={() => onUpdate(rowData, operator, !isNot)}
/>
)}
<Typography
variant="caption"
color="textSecondary"
className={css.isNotLabel}
onClick={() => onUpdate(rowData, operator, !isNot)}
>
Not
</Typography>
</div>
{rowData.map((row, idx) => {
const { field, rowOperator, value, type } = row;
let isInvalidValue = !value;
if (rowOperator === 'cidr') {
try {
getIPRange(value);
} catch (err) {
isInvalidValue = true;
}
}
return (
<Fragment key={`row-${idx}`}>
{idx > 0 && operator ? (
<div>
<span
className={css.link}
onClick={(event) => setActiveOperatorEl(event.currentTarget)}
>
{operator}
</span>
<Menu
open={!!activeOperatorEl}
anchorEl={activeOperatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setActiveOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={op}
onClick={() => {
onUpdate(rowData, op);
setActiveOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</div>
) : null}
<div className={css.row}>
<Select
className={css.fieldSelector}
label="Field"
value={field}
error={showErrors && !field}
fullWidth={false}
options={fields.map((field) => {
return {
label: `${field.name} (${field.options.type})`,
value: field.name,
disabled:
!Object.keys(operators).includes(field.options.type) ||
(field.options.type === 'set' && !field.options.keys)
};
})}
onChange={(value) => {
const updatedField = fields.find((f) => f.name === value);
const rowUpdate =
row.type === updatedField.options.type
? { ...row, field: value, value: row.value }
: {
field: value,
rowOperator: '=',
type: updatedField.options.type,
value:
updatedField.options.type === 'timestamp'
? moment.utc().format()
: ''
};
onRowUpdate(idx, rowUpdate);
}}
/>
<div className={css.operator}>
<span
className={css.link}
onClick={(event) => {
setAnchorEl(event.currentTarget);
setActiveAnchor(idx);
}}
>
{
operators[type].find((op) => op.value === rowOperator)
?.label
}
</span>
<Menu
open={!!anchorEl && activeAnchor === idx}
anchorEl={anchorEl}
classes={{ paper: css.menuPaper }}
onClose={() => {
setAnchorEl(null);
setActiveAnchor(undefined);
}}
>
{operators[type].map((op) => (
<MenuItem
key={`${type}-${op.label}`}
onClick={() => {
onRowUpdate(idx, { ...row, rowOperator: op.value });
setAnchorEl(null);
setActiveAnchor(undefined);
}}
>
{op.label}
</MenuItem>
))}
</Menu>
</div>
{['int', 'set'].includes(type) ? (
<TextField
className={css.rowValue}
variant="outlined"
size="small"
value={value}
type={type === 'int' ? 'number' : 'text'}
error={showErrors && isInvalidValue}
onChange={(event) =>
onRowUpdate(idx, { ...row, value: event.target.value })
}
/>
) : type === 'timestamp' ? (
<MuiPickersUtilsProvider utils={MomentUtils}>
<DateTimePicker
variant="inline"
inputVariant="outlined"
size="small"
value={moment.utc(value)}
onChange={(date) => {
onRowUpdate(idx, {
...row,
value: date ? date.format() : ''
});
}}
format="MM/DD/YYYY hh:mm:ss a"
InputProps={{
endAdornment: (
<Typography variant="caption" color="textSecondary">
UTC
</Typography>
)
}}
fullWidth
/>
</MuiPickersUtilsProvider>
) : null}
{rowData.length > 1 ? (
<CloseIcon
fontSize="inherit"
className={css.removeRowIcon}
onClick={() => onRowUpdate(idx)}
/>
) : null}
</div>
</Fragment>
);
})}
<span
className={css.link}
onClick={(event) => {
if (operator) {
onNewRow();
} else {
setOperatorEl(event.currentTarget);
}
}}
>
+ Add Row
</span>
<Menu
open={!!operatorEl}
anchorEl={operatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={op}
onClick={() => {
onNewRow(op);
setOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</div>
);
};

View file

@ -0,0 +1,20 @@
export const operators = {
int: [
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: '==', value: '=' },
{ label: '!=', value: '!=' },
],
set: [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' },
{ label: 'CIDR', value: 'cidr' }
],
timestamp: [
{ label: 'is before', value: '<' },
{ label: 'is after', value: '>' },
{ label: 'is', value: '=' },
]
}

View file

@ -0,0 +1 @@
export * from './RowCall';

View file

@ -0,0 +1,90 @@
import React, { FC } from 'react';
import DeleteIcon from '@material-ui/icons/Delete';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import ListItemText from '@material-ui/core/ListItemText';
import Paper from '@material-ui/core/Paper';
import Tooltip from '@material-ui/core/Tooltip';
import WarningIcon from '@material-ui/icons/Warning';
import { MotionGroup, MotionSlideItem } from 'shared/Animations';
import { Operator, RowGrouping } from '../rowTypes';
type SavedQueriesProps = {
queries: {
name: string;
description?: string;
table: string;
operation: string;
operator?: Operator;
rowCalls: RowGrouping[];
isInvalid?: boolean;
}[];
tables: any[];
onClickSaved: (queryIdx: number) => void;
onRemoveQuery: (queryIdx: number) => void;
};
export const SavedQueries: FC<SavedQueriesProps> = ({
queries,
tables,
onClickSaved,
onRemoveQuery
}) => {
const tablesList = tables.map((table) => table.name);
return (
<Paper>
<List>
{queries.length > 0 ? (
<MotionGroup>
{queries.map((query, idx) => {
const { table, name, description } = query;
const tableExists = tablesList.includes(query.table);
return (
<MotionSlideItem key={name}>
<ListItem
role={undefined}
onClick={() => onClickSaved(idx)}
disabled={!tableExists}
dense
button
>
<ListItemText
primary={name}
secondary={`[${table}] ${description}`}
/>
<ListItemSecondaryAction>
{!tableExists ? (
<Tooltip
title="Table does not exist on this pilosa cluster."
placement="top"
arrow
>
<span>
<IconButton edge="end" disabled>
<WarningIcon color="error" />
</IconButton>
</span>
</Tooltip>
) : null}
<IconButton
edge="end"
aria-label="Delete saved query"
onClick={() => onRemoveQuery(idx)}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</MotionSlideItem>
);
})}
</MotionGroup>
) : null}
</List>
</Paper>
);
};

View file

@ -0,0 +1 @@
export * from './SavedQueries';

View file

@ -0,0 +1,2 @@
export * from './QueryBuilder';
export * from './QueryBuilderContainer';

View file

@ -0,0 +1,21 @@
export type Operator = 'and' | 'or';
export const groupOperators: Operator[] = ['and', 'or'];
export type RowGrouping = {
row: RowCallType[];
isNot?: boolean;
operator?: Operator;
}
export type RowCallType = {
field: string;
rowOperator: string;
value: string;
type: 'set' | 'int' | 'timestamp';
};
export type RowsCallType = {
primary: string;
secondary: string;
};

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42.46 48.12"><defs><style>.mbug-cls-1{fill:#ffffff;}.mbug-cls-2{fill:#ffffff;}.mbug-cls-3{fill:#ffffff;}</style></defs><title>MoleculaBug</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="art"><path class="mbug-cls-1" d="M22.46.32a2.38,2.38,0,1,0-2.38,4.13L37.7,14.65V35a2.38,2.38,0,0,0,4.76,0V11.9Z"/><path class="mbug-cls-2" d="M9.78,6.62a2.37,2.37,0,0,0,.85,3.24h0L28.27,20.08V40.45a2.39,2.39,0,0,0,4.77,0V17.33L13,5.75A2.39,2.39,0,0,0,9.78,6.62Z"/><path class="mbug-cls-3" d="M.32,12.09A2.35,2.35,0,0,0,0,13.22V35a2.4,2.4,0,0,0,4.8,0V17.43l4.62,2.68V40.47a2.38,2.38,0,0,0,4.76,0V22.87l4.66,2.7V45.89a2.39,2.39,0,0,0,4.77,0V22.82l-20-11.6a2.38,2.38,0,0,0-3.27.84Z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 779 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 265.39 48.27"><defs><style>.molecula-1{fill:#ffffff;}.molecula-2{fill:#c3d3dc;}.molecula-3{fill:#48b5cd;}</style></defs><title>Molecula Logo</title><g id="Layer_2" data-name="Layer 2"><g id="art"><path class="molecula-1" d="M55.15,13.89a2.18,2.18,0,0,1,2.19-2.2h.46a2.29,2.29,0,0,1,2,1.17l7.51,11.76,7.54-11.8a2.2,2.2,0,0,1,2-1.13h.46a2.18,2.18,0,0,1,2.19,2.2V34.64a2.15,2.15,0,0,1-2.19,2.16,2.19,2.19,0,0,1-2.16-2.16V19.77l-6.06,9.1a2,2,0,0,1-3.65,0l-6-9V34.68a2.11,2.11,0,0,1-2.16,2.12,2.08,2.08,0,0,1-2.12-2.12Z"/><path class="molecula-1" d="M85.68,24.3v-.07a13,13,0,0,1,25.92-.07v.07a13,13,0,0,1-25.92.07Zm21.35,0v-.07c0-4.82-3.5-8.82-8.42-8.82s-8.36,3.93-8.36,8.75v.07A8.46,8.46,0,0,0,98.68,33C103.6,33,107,29.12,107,24.3Z"/><path class="molecula-1" d="M117.77,13.82a2.18,2.18,0,0,1,4.35,0V32.66h11.23a2,2,0,0,1,0,4H120a2.16,2.16,0,0,1-2.19-2.2Z"/><path class="molecula-1" d="M140.58,34.43V14a2.16,2.16,0,0,1,2.19-2.2h14.42a2,2,0,0,1,1.95,1.95,2,2,0,0,1-1.95,2H144.93v6.44h10.66a2,2,0,0,1,1.95,2,1.93,1.93,0,0,1-1.95,1.91H144.93v6.66h12.43a1.95,1.95,0,0,1,0,3.9H142.77A2.16,2.16,0,0,1,140.58,34.43Z"/><path class="molecula-1" d="M164.09,24.3v-.07a12.55,12.55,0,0,1,12.79-12.82,13,13,0,0,1,8.5,2.8,2.16,2.16,0,0,1,.85,1.73,2.17,2.17,0,0,1-3.47,1.7,9,9,0,0,0-5.92-2.23c-4.71,0-8.18,3.9-8.18,8.75v.07c0,4.85,3.44,8.78,8.18,8.78a8.87,8.87,0,0,0,6.2-2.37,2,2,0,0,1,1.31-.5,2,2,0,0,1,1.35,3.58,12.64,12.64,0,0,1-9,3.33A12.47,12.47,0,0,1,164.09,24.3Z"/><path class="molecula-1" d="M191.83,26.11V13.82a2.18,2.18,0,0,1,4.35,0V25.93c0,4.6,2.38,7,6.27,7s6.24-2.3,6.24-6.87V13.82a2.18,2.18,0,0,1,4.35,0V25.89C213,33.3,208.86,37,202.38,37S191.83,33.3,191.83,26.11Z"/><path class="molecula-1" d="M219.91,13.82a2.18,2.18,0,0,1,4.36,0V32.66H235.5a2,2,0,0,1,0,4H222.11a2.17,2.17,0,0,1-2.2-2.2Z"/><path class="molecula-1" d="M241.31,33.79l9.21-20.54a2.75,2.75,0,0,1,2.62-1.77h.21a2.7,2.7,0,0,1,2.58,1.77l9.21,20.54a1.88,1.88,0,0,1,.25.92,2,2,0,0,1-2.05,2.09,2.25,2.25,0,0,1-2.13-1.52l-2-4.64h-12l-2.09,4.78a2.09,2.09,0,0,1-2,1.38,2,2,0,0,1-2-2A2.22,2.22,0,0,1,241.31,33.79Zm16.22-7-4.36-10-4.36,10Z"/><path class="molecula-1" d="M22.46.32a2.38,2.38,0,0,0-2.38,4.13L37.7,14.65V35a2.38,2.38,0,0,0,4.76,0V11.9Z"/><path class="molecula-1" d="M9.78,6.62a2.37,2.37,0,0,0,.87,3.25L28.27,20.08V40.45a2.39,2.39,0,0,0,4.77,0V17.33L13,5.75A2.39,2.39,0,0,0,9.78,6.62Z"/><path class="molecula-1" d="M.32,12.09A2.33,2.33,0,0,0,0,13.22C0,13.43,0,35,0,35A2.39,2.39,0,0,0,4.8,35V17.43l4.62,2.68V40.47a2.38,2.38,0,0,0,4.76,0V22.87l4.66,2.7V45.89a2.39,2.39,0,0,0,4.77,0V22.82l-20-11.6A2.39,2.39,0,0,0,.32,12.09Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show more