mirror of
https://github.com/featurebasedb/featurebase.git
synced 2026-08-28 10:54:59 +00:00
Copy lattice repo @8c29787 into subdirectory lattice/
This commit is contained in:
parent
4a00bd9d6e
commit
4aaf3e2fc2
158 changed files with 29368 additions and 0 deletions
3
lattice/Caddyfile
Normal file
3
lattice/Caddyfile
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
:80
|
||||
root * /lattice
|
||||
file_server
|
||||
14
lattice/Dockerfile
Normal file
14
lattice/Dockerfile
Normal 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
16
lattice/Makefile
Normal 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
22
lattice/README.md
Normal 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
71
lattice/package.json
Normal 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
BIN
lattice/public/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
lattice/public/favicon.png
Normal file
BIN
lattice/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8 KiB |
7
lattice/public/favicon.svg
Normal file
7
lattice/public/favicon.svg
Normal 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
44
lattice/public/index.html
Normal 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>
|
||||
15
lattice/public/manifest.json
Normal file
15
lattice/public/manifest.json
Normal 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"
|
||||
}
|
||||
15
lattice/src/App.module.scss
Normal file
15
lattice/src/App.module.scss
Normal 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
9
lattice/src/App.test.tsx
Normal 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
58
lattice/src/App.tsx
Normal 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;
|
||||
24
lattice/src/App/Home/ClusterHealth/ClusterHealth.module.scss
Normal file
24
lattice/src/App/Home/ClusterHealth/ClusterHealth.module.scss
Normal 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;
|
||||
}
|
||||
|
||||
173
lattice/src/App/Home/ClusterHealth/ClusterHealth.tsx
Normal file
173
lattice/src/App/Home/ClusterHealth/ClusterHealth.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/ClusterHealth/ClusterInfo/index.ts
Normal file
1
lattice/src/App/Home/ClusterHealth/ClusterInfo/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ClusterInfo';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
284
lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.tsx
Normal file
284
lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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'
|
||||
];
|
||||
1
lattice/src/App/Home/ClusterHealth/ImportStatus/index.ts
Normal file
1
lattice/src/App/Home/ClusterHealth/ImportStatus/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ImportStatus';
|
||||
119
lattice/src/App/Home/ClusterHealth/Metrics/Metrics.module.scss
Normal file
119
lattice/src/App/Home/ClusterHealth/Metrics/Metrics.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
334
lattice/src/App/Home/ClusterHealth/Metrics/Metrics.tsx
Normal file
334
lattice/src/App/Home/ClusterHealth/Metrics/Metrics.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/ClusterHealth/Metrics/index.ts
Normal file
1
lattice/src/App/Home/ClusterHealth/Metrics/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Metrics';
|
||||
|
|
@ -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'
|
||||
];
|
||||
160
lattice/src/App/Home/ClusterHealth/Node/Node.module.scss
Normal file
160
lattice/src/App/Home/ClusterHealth/Node/Node.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
320
lattice/src/App/Home/ClusterHealth/Node/Node.tsx
Normal file
320
lattice/src/App/Home/ClusterHealth/Node/Node.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/ClusterHealth/Node/index.ts
Normal file
1
lattice/src/App/Home/ClusterHealth/Node/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Node';
|
||||
10
lattice/src/App/Home/ClusterHealth/Node/nodeInfo.ts
Normal file
10
lattice/src/App/Home/ClusterHealth/Node/nodeInfo.ts
Normal 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: '' }
|
||||
];
|
||||
31
lattice/src/App/Home/ClusterHealth/Node/nodeStatus.ts
Normal file
31
lattice/src/App/Home/ClusterHealth/Node/nodeStatus.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
32
lattice/src/App/Home/ClusterHealth/clusterStatus.ts
Normal file
32
lattice/src/App/Home/ClusterHealth/clusterStatus.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
1
lattice/src/App/Home/ClusterHealth/index.ts
Normal file
1
lattice/src/App/Home/ClusterHealth/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ClusterHealth';
|
||||
3
lattice/src/App/Home/Home.module.scss
Normal file
3
lattice/src/App/Home/Home.module.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.prodPadSpacer {
|
||||
padding-bottom: 38px;
|
||||
}
|
||||
14
lattice/src/App/Home/Home.tsx
Normal file
14
lattice/src/App/Home/Home.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
1
lattice/src/App/Home/QueryHistory/NodeIndicator/index.ts
Normal file
1
lattice/src/App/Home/QueryHistory/NodeIndicator/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './NodeIndicator';
|
||||
28
lattice/src/App/Home/QueryHistory/QueryHistory.module.scss
Normal file
28
lattice/src/App/Home/QueryHistory/QueryHistory.module.scss
Normal 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;
|
||||
}
|
||||
106
lattice/src/App/Home/QueryHistory/QueryHistory.tsx
Normal file
106
lattice/src/App/Home/QueryHistory/QueryHistory.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
87
lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.tsx
Normal file
87
lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/QueryHistory/QueryItem/index.ts
Normal file
1
lattice/src/App/Home/QueryHistory/QueryItem/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './QueryItem';
|
||||
1
lattice/src/App/Home/QueryHistory/index.ts
Normal file
1
lattice/src/App/Home/QueryHistory/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './QueryHistory';
|
||||
10
lattice/src/App/Home/QueryHistory/nodeColors.ts
Normal file
10
lattice/src/App/Home/QueryHistory/nodeColors.ts
Normal 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',
|
||||
];
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
113
lattice/src/App/Home/Transactions/Transaction/Transaction.tsx
Normal file
113
lattice/src/App/Home/Transactions/Transaction/Transaction.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/Transactions/Transaction/index.ts
Normal file
1
lattice/src/App/Home/Transactions/Transaction/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Transaction';
|
||||
12
lattice/src/App/Home/Transactions/Transaction/utils.test.ts
Normal file
12
lattice/src/App/Home/Transactions/Transaction/utils.test.ts
Normal 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');
|
||||
});
|
||||
27
lattice/src/App/Home/Transactions/Transaction/utils.ts
Normal file
27
lattice/src/App/Home/Transactions/Transaction/utils.ts
Normal 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;
|
||||
};
|
||||
85
lattice/src/App/Home/Transactions/Transactions.module.scss
Normal file
85
lattice/src/App/Home/Transactions/Transactions.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
178
lattice/src/App/Home/Transactions/Transactions.tsx
Normal file
178
lattice/src/App/Home/Transactions/Transactions.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Home/Transactions/index.ts
Normal file
1
lattice/src/App/Home/Transactions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Transactions';
|
||||
1
lattice/src/App/Home/index.ts
Normal file
1
lattice/src/App/Home/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Home';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
295
lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.tsx
Normal file
295
lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/MoleculaTables/MoleculaTable/index.ts
Normal file
1
lattice/src/App/MoleculaTables/MoleculaTable/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './MoleculaTable';
|
||||
71
lattice/src/App/MoleculaTables/MoleculaTables.module.scss
Normal file
71
lattice/src/App/MoleculaTables/MoleculaTables.module.scss
Normal 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;
|
||||
}
|
||||
126
lattice/src/App/MoleculaTables/MoleculaTables.tsx
Normal file
126
lattice/src/App/MoleculaTables/MoleculaTables.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
96
lattice/src/App/MoleculaTables/MoleculaTablesContainer.tsx
Normal file
96
lattice/src/App/MoleculaTables/MoleculaTablesContainer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
171
lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.tsx
Normal file
171
lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/MoleculaTables/UsageBreakdown/index.ts
Normal file
1
lattice/src/App/MoleculaTables/UsageBreakdown/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './UsageBreakdown';
|
||||
2
lattice/src/App/MoleculaTables/index.ts
Normal file
2
lattice/src/App/MoleculaTables/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './MoleculaTables';
|
||||
export * from './MoleculaTablesContainer';
|
||||
10
lattice/src/App/NotFound/NotFound.tsx
Normal file
10
lattice/src/App/NotFound/NotFound.tsx
Normal 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>
|
||||
);
|
||||
1
lattice/src/App/NotFound/index.tsx
Normal file
1
lattice/src/App/NotFound/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './NotFound';
|
||||
92
lattice/src/App/Query/Console/Console.module.scss
Normal file
92
lattice/src/App/Query/Console/Console.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
762
lattice/src/App/Query/Console/Console.tsx
Normal file
762
lattice/src/App/Query/Console/Console.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
151
lattice/src/App/Query/Console/helpers.ts
Normal file
151
lattice/src/App/Query/Console/helpers.ts
Normal 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('
|
||||
];
|
||||
1
lattice/src/App/Query/Console/index.ts
Normal file
1
lattice/src/App/Query/Console/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Console';
|
||||
108
lattice/src/App/Query/Query.module.scss
Normal file
108
lattice/src/App/Query/Query.module.scss
Normal 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;
|
||||
}
|
||||
253
lattice/src/App/Query/Query.tsx
Normal file
253
lattice/src/App/Query/Query.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
lattice/src/App/Query/QueryContainer.tsx
Normal file
135
lattice/src/App/Query/QueryContainer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
58
lattice/src/App/Query/QueryResults/QueryResults.module.scss
Normal file
58
lattice/src/App/Query/QueryResults/QueryResults.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
149
lattice/src/App/Query/QueryResults/QueryResults.tsx
Normal file
149
lattice/src/App/Query/QueryResults/QueryResults.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Query/QueryResults/index.ts
Normal file
1
lattice/src/App/Query/QueryResults/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './QueryResults';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
lattice/src/App/Query/RecentQueries/RecentQueries.tsx
Normal file
72
lattice/src/App/Query/RecentQueries/RecentQueries.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/Query/RecentQueries/index.ts
Normal file
1
lattice/src/App/Query/RecentQueries/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './RecentQueries';
|
||||
2
lattice/src/App/Query/index.ts
Normal file
2
lattice/src/App/Query/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './Query';
|
||||
export * from './QueryContainer';
|
||||
32
lattice/src/App/Query/splitjs.scss
Normal file
32
lattice/src/App/Query/splitjs.scss
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
212
lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.tsx
Normal file
212
lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QueryBuilder/ColumnSelector/index.ts
Normal file
1
lattice/src/App/QueryBuilder/ColumnSelector/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ColumnSelector';
|
||||
128
lattice/src/App/QueryBuilder/GroupByChart/GroupByChart.tsx
Normal file
128
lattice/src/App/QueryBuilder/GroupByChart/GroupByChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QueryBuilder/GroupByChart/index.ts
Normal file
1
lattice/src/App/QueryBuilder/GroupByChart/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './GroupByChart';
|
||||
169
lattice/src/App/QueryBuilder/QueryBuilder.module.scss
Normal file
169
lattice/src/App/QueryBuilder/QueryBuilder.module.scss
Normal 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;
|
||||
}
|
||||
804
lattice/src/App/QueryBuilder/QueryBuilder.tsx
Normal file
804
lattice/src/App/QueryBuilder/QueryBuilder.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.noTables {
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
281
lattice/src/App/QueryBuilder/QueryBuilderContainer.tsx
Normal file
281
lattice/src/App/QueryBuilder/QueryBuilderContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
lattice/src/App/QueryBuilder/RowCall/RowCall.module.scss
Normal file
102
lattice/src/App/QueryBuilder/RowCall/RowCall.module.scss
Normal 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;
|
||||
}
|
||||
302
lattice/src/App/QueryBuilder/RowCall/RowCall.tsx
Normal file
302
lattice/src/App/QueryBuilder/RowCall/RowCall.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
lattice/src/App/QueryBuilder/RowCall/helpers.ts
Normal file
20
lattice/src/App/QueryBuilder/RowCall/helpers.ts
Normal 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: '=' },
|
||||
]
|
||||
}
|
||||
1
lattice/src/App/QueryBuilder/RowCall/index.ts
Normal file
1
lattice/src/App/QueryBuilder/RowCall/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './RowCall';
|
||||
90
lattice/src/App/QueryBuilder/SavedQueries/SavedQueries.tsx
Normal file
90
lattice/src/App/QueryBuilder/SavedQueries/SavedQueries.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
lattice/src/App/QueryBuilder/SavedQueries/index.ts
Normal file
1
lattice/src/App/QueryBuilder/SavedQueries/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './SavedQueries';
|
||||
2
lattice/src/App/QueryBuilder/index.ts
Normal file
2
lattice/src/App/QueryBuilder/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './QueryBuilder';
|
||||
export * from './QueryBuilderContainer';
|
||||
21
lattice/src/App/QueryBuilder/rowTypes.ts
Normal file
21
lattice/src/App/QueryBuilder/rowTypes.ts
Normal 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;
|
||||
};
|
||||
1
lattice/src/assets/darkTheme/MoleculaBug.svg
Normal file
1
lattice/src/assets/darkTheme/MoleculaBug.svg
Normal 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 |
1
lattice/src/assets/darkTheme/MoleculaLogo.svg
Normal file
1
lattice/src/assets/darkTheme/MoleculaLogo.svg
Normal 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
Loading…
Add table
Reference in a new issue