diff --git a/lattice/Caddyfile b/lattice/Caddyfile
new file mode 100644
index 000000000..2607efef3
--- /dev/null
+++ b/lattice/Caddyfile
@@ -0,0 +1,3 @@
+:80
+root * /lattice
+file_server
diff --git a/lattice/Dockerfile b/lattice/Dockerfile
new file mode 100644
index 000000000..2b30d6cfb
--- /dev/null
+++ b/lattice/Dockerfile
@@ -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
diff --git a/lattice/Makefile b/lattice/Makefile
new file mode 100644
index 000000000..419a0c6c7
--- /dev/null
+++ b/lattice/Makefile
@@ -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
diff --git a/lattice/README.md b/lattice/README.md
new file mode 100644
index 000000000..92a20641a
--- /dev/null
+++ b/lattice/README.md
@@ -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.
+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).
diff --git a/lattice/package.json b/lattice/package.json
new file mode 100644
index 000000000..15211e27f
--- /dev/null
+++ b/lattice/package.json
@@ -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"
+ ]
+}
diff --git a/lattice/public/favicon.ico b/lattice/public/favicon.ico
new file mode 100755
index 000000000..2fc5595e1
Binary files /dev/null and b/lattice/public/favicon.ico differ
diff --git a/lattice/public/favicon.png b/lattice/public/favicon.png
new file mode 100644
index 000000000..fca3ac8c0
Binary files /dev/null and b/lattice/public/favicon.png differ
diff --git a/lattice/public/favicon.svg b/lattice/public/favicon.svg
new file mode 100644
index 000000000..c8f396a21
--- /dev/null
+++ b/lattice/public/favicon.svg
@@ -0,0 +1,7 @@
+
diff --git a/lattice/public/index.html b/lattice/public/index.html
new file mode 100644
index 000000000..8d4c3f68e
--- /dev/null
+++ b/lattice/public/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Molecula
+
+
+
+
+
+
+
+
diff --git a/lattice/public/manifest.json b/lattice/public/manifest.json
new file mode 100644
index 000000000..1a608464d
--- /dev/null
+++ b/lattice/public/manifest.json
@@ -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"
+}
diff --git a/lattice/src/App.module.scss b/lattice/src/App.module.scss
new file mode 100644
index 000000000..59677d391
--- /dev/null
+++ b/lattice/src/App.module.scss
@@ -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;
+}
diff --git a/lattice/src/App.test.tsx b/lattice/src/App.test.tsx
new file mode 100644
index 000000000..f3fe19ab5
--- /dev/null
+++ b/lattice/src/App.test.tsx
@@ -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(, div);
+ ReactDOM.unmountComponentAtNode(div);
+});
diff --git a/lattice/src/App.tsx b/lattice/src/App.tsx
new file mode 100644
index 000000000..f465bd480
--- /dev/null
+++ b/lattice/src/App.tsx
@@ -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(
+ 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 (
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/lattice/src/App/Home/ClusterHealth/ClusterHealth.module.scss b/lattice/src/App/Home/ClusterHealth/ClusterHealth.module.scss
new file mode 100644
index 000000000..c2f178732
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ClusterHealth.module.scss
@@ -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;
+}
+
\ No newline at end of file
diff --git a/lattice/src/App/Home/ClusterHealth/ClusterHealth.tsx b/lattice/src/App/Home/ClusterHealth/ClusterHealth.tsx
new file mode 100644
index 000000000..2809b2fd2
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ClusterHealth.tsx
@@ -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();
+ const [metrics, setMetrics] = useState();
+ const [info, setInfo] = useState();
+ const [clusterData, setClusterData] = useState();
+ const [expanded, setExpanded] = useState([]);
+ const [showMetrics, setShowMetrics] = useState();
+ 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 (
+
+
+
+ {cluster ? (
+
+ ) : null}
+
+ Cluster Health
+
+ {cluster && cluster.nodes.length > 1 && (
+
+
+ {allExpanded ? (
+ setExpanded([])}>
+
+
+ ) : (
+
+
+
+ )}
+
+
+ )}
+
+
+ {cluster && info ? (
+
+ {cluster.nodes.map((node) => (
+ toggleAccordion(node.id)}
+ onMetricClick={() => setShowMetrics(node)}
+ />
+ ))}
+
+ ) : (
+
+
+ There is a problem connecting to Pilosa.
+
+
+ )}
+
+ {metrics ? (
+
+
+
+ ) : null}
+
+ {showMetrics && (
+ setShowMetrics(undefined)}
+ node={showMetrics}
+ />
+ )}
+
+ );
+};
diff --git a/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.module.scss b/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.module.scss
new file mode 100644
index 000000000..f8ac8b908
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.tsx b/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.tsx
new file mode 100644
index 000000000..fa9187246
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ClusterInfo/ClusterInfo.tsx
@@ -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 (
+
+ {keys.map((key) => (
+
+ ))}
+
+ );
+};
diff --git a/lattice/src/App/Home/ClusterHealth/ClusterInfo/index.ts b/lattice/src/App/Home/ClusterHealth/ClusterInfo/index.ts
new file mode 100644
index 000000000..797378b3f
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ClusterInfo/index.ts
@@ -0,0 +1 @@
+export * from './ClusterInfo';
diff --git a/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.module.scss b/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.module.scss
new file mode 100644
index 000000000..c6ed02702
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.tsx b/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.tsx
new file mode 100644
index 000000000..f072c90f3
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ImportStatus/ImportStatus.tsx
@@ -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 = ({ metrics }) => {
+ const theme = useTheme();
+ const isDark = theme.palette.type === 'dark';
+ const [time, setTime] = useState(new Date());
+ const [data, setData] = useState();
+
+ 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 (
+
+
+ Activity
+
+ {data ? (
+
+
+
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 }) => (
+
+
+
+ {format(
+ slice.points[0].data.x as Date,
+ 'M/d/yyyy h:mm:ss aaaa'
+ )}
+
+
+
+ {slice.points.map((p) => {
+ const label = p.serieId.toString();
+ const splitIdx = label.indexOf(' ');
+
+ return (
+
+
+ {label.substring(0, splitIdx)}
+
+ {label.substring(splitIdx + 1)}
+
+
+
+ {p.data.yFormatted}
+
+
+ );
+ })}
+
+
+ )}
+ curve="monotoneX"
+ animate={false}
+ />
+
+
+
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 }) => (
+
+
+
+ {format(
+ slice.points[0].data.x as Date,
+ 'M/d/yyyy h:mm:ss aaaa'
+ )}
+
+
+
+ {slice.points.map((p) => {
+ const label = p.serieId.toString();
+ const splitIdx = label.indexOf(' ');
+
+ return (
+
+
+ {label.substring(0, splitIdx)}
+
+ {label.substring(splitIdx + 1)}
+
+
+
+ {p.data.yFormatted}
+
+
+ );
+ })}
+
+
+ )}
+ curve="monotoneX"
+ animate={false}
+ />
+
+
+ ) : null}
+
+ );
+};
diff --git a/lattice/src/App/Home/ClusterHealth/ImportStatus/helpers.ts b/lattice/src/App/Home/ClusterHealth/ImportStatus/helpers.ts
new file mode 100644
index 000000000..e50405644
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ImportStatus/helpers.ts
@@ -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'
+];
diff --git a/lattice/src/App/Home/ClusterHealth/ImportStatus/index.ts b/lattice/src/App/Home/ClusterHealth/ImportStatus/index.ts
new file mode 100644
index 000000000..535db8622
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/ImportStatus/index.ts
@@ -0,0 +1 @@
+export * from './ImportStatus';
diff --git a/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.module.scss b/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.module.scss
new file mode 100644
index 000000000..8579ec372
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.module.scss
@@ -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);
+ }
+}
diff --git a/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.tsx b/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.tsx
new file mode 100644
index 000000000..e2d3a5229
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Metrics/Metrics.tsx
@@ -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 = ({ open, node, onClose }) => {
+ const [loading, setLoading] = useState(false);
+ const [searchText, setSearchText] = useState('');
+ const [showTypes, setShowTypes] = useState([
+ 'gauge',
+ 'counter',
+ 'summary'
+ ]);
+ const [metrics, setMetrics] = useState();
+ const [filteredMetrics, setFilteredMetrics] = useState([]);
+
+ 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) => {
+ 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 ? (
+ {JSON.stringify(value, null, 2)}
+ ) : (
+ {JSON.stringify(value, null, 2)}
+ );
+ } else if (!isNaN(Number(value))) {
+ return {Number(value).toLocaleString()};
+ }
+
+ return value;
+ };
+
+ const renderFilter = () => (
+
+
+
+ }
+ label={Gauge}
+ />
+
+ }
+ label={Counter}
+ />
+
+ }
+ label={Summary}
+ />
+
+
+ );
+
+ const renderMetric = (item: any) => {
+ const metricsKeys = Object.keys(item.metrics[0]);
+
+ return (
+
+
+
+ 1 ? [searchText] : []}
+ textToHighlight={item.name}
+ autoEscape={true}
+ />
+
+
+ 1 ? [searchText] : []}
+ textToHighlight={item.help}
+ autoEscape={true}
+ />
+
+
+
+
+
+ {metricsKeys.map((key) => (
+
+
+ {key}
+
+
+ ))}
+
+
+
+ {item.metrics.map((metric, idx) => (
+
+ {metricsKeys.map((key) => (
+
+ {renderValue(metric[key])}
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+ };
+
+ return (
+
+ );
+};
diff --git a/lattice/src/App/Home/ClusterHealth/Metrics/index.ts b/lattice/src/App/Home/ClusterHealth/Metrics/index.ts
new file mode 100644
index 000000000..5f4b6dcd0
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Metrics/index.ts
@@ -0,0 +1 @@
+export * from './Metrics';
diff --git a/lattice/src/App/Home/ClusterHealth/Metrics/priorityMetrics.ts b/lattice/src/App/Home/ClusterHealth/Metrics/priorityMetrics.ts
new file mode 100644
index 000000000..12e4f5c7b
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Metrics/priorityMetrics.ts
@@ -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'
+];
diff --git a/lattice/src/App/Home/ClusterHealth/Node/Node.module.scss b/lattice/src/App/Home/ClusterHealth/Node/Node.module.scss
new file mode 100644
index 000000000..ac2a21ada
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Node/Node.module.scss
@@ -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);
+ }
+ }
+}
diff --git a/lattice/src/App/Home/ClusterHealth/Node/Node.tsx b/lattice/src/App/Home/ClusterHealth/Node/Node.tsx
new file mode 100644
index 000000000..d9a8a5cf3
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Node/Node.tsx
@@ -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 = ({
+ node,
+ info,
+ usage,
+ expanded,
+ onToggle,
+ onMetricClick
+}) => {
+ const [copyHost, setCopyHost] = useState('Copy Host');
+ const [copyID, setCopyID] = useState('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 (
+
+ }>
+
+
+ {`${node.uri.host}:${node.uri.port}`}
+
+ {
+ e.stopPropagation();
+ onCopyHostClick();
+ }}
+ >
+
+
+
+
+ {isPrimary ? (Primary) : null}
+
+
+
+
+ Node Id:
+
+
+ {node.id}
+
+
+
+
+
+
Disk Usage:
+
+ {usage ? (
+
+
+ {formatBytes(diskTotalInUse)}
+ {diskCapacity
+ ? ` used out of ${formatBytes(diskCapacity)}`
+ : null}
+
+
+ {diskUsagePercentage ? (
+
+ {diskUsagePercentage < 1
+ ? '< 1'
+ : diskUsagePercentage.toLocaleString(
+ undefined,
+ { maximumFractionDigits: 1 }
+ )}
+ % used
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : (
+
+
+ {formatBytes(diskTotalInUse)} used
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+
+ Node disk capacity unknown
+
+
+ )}
+
+
+ ) : (
+
+ Calculating...
+
+ )}
+
+
+
+
Memory Usage:
+
+ {usage ? (
+
+
+ {formatBytes(memoryTotalInUse)}
+ {memoryCapacity
+ ? ` used out of ${formatBytes(memoryCapacity)}`
+ : null}
+
+
+ {memoryUsagePercentage ? (
+
+ {memoryUsagePercentage < 1
+ ? '< 1'
+ : memoryUsagePercentage.toLocaleString(
+ undefined,
+ { maximumFractionDigits: 1 }
+ )}
+ % used
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : (
+
+
+ {formatBytes(memoryTotalInUse)} used
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+
+ Node memory capacity unknown
+
+
+ )}
+
+
+ ) : (
+
+ Calculating...
+
+ )}
+
+
+
+
+ {keys.map((key) => {
+ const showNode = Find(nodeInfo, (node) => node.name === key);
+ if (showNode) {
+ return (
+
+
+
+ {key === 'memory'
+ ? formatBytes(info[key])
+ : info[key].toLocaleString()}
+
+
+ );
+ }
+ return null;
+ })}
+
+
+
+
Scheme
+
Host
+
Port
+
+ {node.uri && (
+
+
+ uri
+
+
{node.uri.scheme}
+
{node.uri.host}
+
{node.uri.port}
+
+ )}
+ {node['grpc-uri'] && (
+
+
+ grpc-uri
+
+
{node['grpc-uri'].scheme}
+
{node['grpc-uri'].host}
+
{node['grpc-uri'].port}
+
+ )}
+
+
+ }
+ >
+ View Metrics
+
+
+
+
+
+ );
+};
diff --git a/lattice/src/App/Home/ClusterHealth/Node/index.ts b/lattice/src/App/Home/ClusterHealth/Node/index.ts
new file mode 100644
index 000000000..673cbdf3e
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Node/index.ts
@@ -0,0 +1 @@
+export * from './Node';
diff --git a/lattice/src/App/Home/ClusterHealth/Node/nodeInfo.ts b/lattice/src/App/Home/ClusterHealth/Node/nodeInfo.ts
new file mode 100644
index 000000000..4a5fe7451
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Node/nodeInfo.ts
@@ -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: '' }
+];
diff --git a/lattice/src/App/Home/ClusterHealth/Node/nodeStatus.ts b/lattice/src/App/Home/ClusterHealth/Node/nodeStatus.ts
new file mode 100644
index 000000000..79234683b
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/Node/nodeStatus.ts
@@ -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'
+ }
+}
diff --git a/lattice/src/App/Home/ClusterHealth/clusterStatus.ts b/lattice/src/App/Home/ClusterHealth/clusterStatus.ts
new file mode 100644
index 000000000..204225a13
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/clusterStatus.ts
@@ -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'
+ }
+};
diff --git a/lattice/src/App/Home/ClusterHealth/index.ts b/lattice/src/App/Home/ClusterHealth/index.ts
new file mode 100644
index 000000000..33980c839
--- /dev/null
+++ b/lattice/src/App/Home/ClusterHealth/index.ts
@@ -0,0 +1 @@
+export * from './ClusterHealth';
diff --git a/lattice/src/App/Home/Home.module.scss b/lattice/src/App/Home/Home.module.scss
new file mode 100644
index 000000000..ffabd3cfd
--- /dev/null
+++ b/lattice/src/App/Home/Home.module.scss
@@ -0,0 +1,3 @@
+.prodPadSpacer {
+ padding-bottom: 38px;
+}
diff --git a/lattice/src/App/Home/Home.tsx b/lattice/src/App/Home/Home.tsx
new file mode 100644
index 000000000..147945ce5
--- /dev/null
+++ b/lattice/src/App/Home/Home.tsx
@@ -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 = () => (
+
+
+
+
+
+
+);
diff --git a/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.module.scss b/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.module.scss
new file mode 100644
index 000000000..9691a42d8
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.tsx b/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.tsx
new file mode 100644
index 000000000..d55aebcdb
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/NodeIndicator/NodeIndicator.tsx
@@ -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 = ({
+ node,
+ nodeIdx,
+ tooltip = false,
+ off = false,
+ onClick = undefined
+}) => {
+ if (tooltip) {
+ return (
+
+ {node}
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ );
+ } else {
+ return (
+
+ );
+ }
+};
diff --git a/lattice/src/App/Home/QueryHistory/NodeIndicator/index.ts b/lattice/src/App/Home/QueryHistory/NodeIndicator/index.ts
new file mode 100644
index 000000000..1f658e2ae
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/NodeIndicator/index.ts
@@ -0,0 +1 @@
+export * from './NodeIndicator';
diff --git a/lattice/src/App/Home/QueryHistory/QueryHistory.module.scss b/lattice/src/App/Home/QueryHistory/QueryHistory.module.scss
new file mode 100644
index 000000000..50de30761
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/QueryHistory.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/Home/QueryHistory/QueryHistory.tsx b/lattice/src/App/Home/QueryHistory/QueryHistory.tsx
new file mode 100644
index 000000000..d688212ad
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/QueryHistory.tsx
@@ -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([]);
+ const [nodes, setNodes] = useState([]);
+ const [hideNodes, setHideNodes] = useState([]);
+ 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 (
+
+
+
+ Recent Query History
+
+
+
+
+
+ {nodes.length > 1 ? (
+
+
+
+ {nodes.map((node, idx) => (
+ handleNodeFilterClick(node)}
+ />
+ ))}
+
+
+ ) : null}
+
+
+ {queries
+ .filter((q) => !hideNodes.includes(q.nodeID))
+ .map((query) => (
+
+ ))}
+
+ {queries.filter((q) => !hideNodes.includes(q.nodeID)).length === 0 ? (
+
+
+ No recent queries.
+
+
+ ) : null}
+
+
+ );
+};
diff --git a/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.module.scss b/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.module.scss
new file mode 100644
index 000000000..5805ed670
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.tsx b/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.tsx
new file mode 100644
index 000000000..1f730f4df
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/QueryItem/QueryItem.tsx
@@ -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 = ({ item, nodeIdx }) => {
+ const [expanded, setExpanded] = useState(false);
+ const [showFullQuery, setShowFullQuery] = useState(false);
+ const query = item.SQL ? item.SQL : `[${item.index}]${item.PQL}`;
+
+ return (
+ setExpanded(!expanded)}
+ className={css.item}
+ >
+ }
+ classes={{ content: css.summary }}
+ >
+
+
+
+ {formatDuration(item.runtimeNanoseconds, true)}
+
+
+
+ {query}
+
+
+
+
+
+
+
+
+ {item.nodeID}
+
+
+
+ {showFullQuery ? (
+
+ {query}
+ {item.SQL ? (
+
+
+ {item.PQL}
+
+ ) : null}
+ setShowFullQuery(false)}
+ >
+ Hide Query
+
+
+ ) : (
+
setShowFullQuery(true)}
+ >
+ Show Full Query
+
+ )}
+
+
+
+ );
+};
diff --git a/lattice/src/App/Home/QueryHistory/QueryItem/index.ts b/lattice/src/App/Home/QueryHistory/QueryItem/index.ts
new file mode 100644
index 000000000..d9f31843b
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/QueryItem/index.ts
@@ -0,0 +1 @@
+export * from './QueryItem';
diff --git a/lattice/src/App/Home/QueryHistory/index.ts b/lattice/src/App/Home/QueryHistory/index.ts
new file mode 100644
index 000000000..5922ff277
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/index.ts
@@ -0,0 +1 @@
+export * from './QueryHistory';
diff --git a/lattice/src/App/Home/QueryHistory/nodeColors.ts b/lattice/src/App/Home/QueryHistory/nodeColors.ts
new file mode 100644
index 000000000..bc489b0b0
--- /dev/null
+++ b/lattice/src/App/Home/QueryHistory/nodeColors.ts
@@ -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',
+];
diff --git a/lattice/src/App/Home/Transactions/Transaction/Transaction.module.scss b/lattice/src/App/Home/Transactions/Transaction/Transaction.module.scss
new file mode 100644
index 000000000..6e4959482
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transaction/Transaction.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/Home/Transactions/Transaction/Transaction.tsx b/lattice/src/App/Home/Transactions/Transaction/Transaction.tsx
new file mode 100644
index 000000000..c9d9da9e1
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transaction/Transaction.tsx
@@ -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 = ({
+ transaction,
+ className,
+ forceFinish
+}) => {
+ const { id, active, exclusive, timeout, deadline, error } = transaction;
+
+ return (
+
+
+
+ Transaction Id:
+
+ {id}
+
+ {exclusive && (
+
+
+
+ )}
+
+
+
+
+
+
+ {error ? 'Error' : active ? 'Active' : 'Waiting'}
+
+
+
+
+
+ {typeof timeout === 'string'
+ ? formatTimeoutString(timeout)
+ : `${timeout} secs`}
+
+
+
+
+ }
+ placement="right"
+ arrow
+ >
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* TODO: Once we have blocked-by */}
+ {/* {transaction['blocked-by'] && (
+
+
+
+
+ {transaction['blocked-by'].map((txId) => (
+
+
+ {txId}
+
+ ))}
+
+
+
+ )} */}
+
+
+ forceFinish(id)}
+ >
+ Force Finish
+
+
+
+ );
+};
diff --git a/lattice/src/App/Home/Transactions/Transaction/index.ts b/lattice/src/App/Home/Transactions/Transaction/index.ts
new file mode 100644
index 000000000..bacbf811e
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transaction/index.ts
@@ -0,0 +1 @@
+export * from './Transaction';
diff --git a/lattice/src/App/Home/Transactions/Transaction/utils.test.ts b/lattice/src/App/Home/Transactions/Transaction/utils.test.ts
new file mode 100644
index 000000000..6193be052
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transaction/utils.test.ts
@@ -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');
+});
diff --git a/lattice/src/App/Home/Transactions/Transaction/utils.ts b/lattice/src/App/Home/Transactions/Transaction/utils.ts
new file mode 100644
index 000000000..7a19a8566
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transaction/utils.ts
@@ -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;
+};
diff --git a/lattice/src/App/Home/Transactions/Transactions.module.scss b/lattice/src/App/Home/Transactions/Transactions.module.scss
new file mode 100644
index 000000000..3993f9d49
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transactions.module.scss
@@ -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);
+ }
+}
diff --git a/lattice/src/App/Home/Transactions/Transactions.tsx b/lattice/src/App/Home/Transactions/Transactions.tsx
new file mode 100644
index 000000000..749a0b0c0
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/Transactions.tsx
@@ -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(0);
+ const [transactions, setTransactions] = useState([]);
+ const [page, setPage] = useState(1);
+ const [selected, setSelected] = useState();
+ 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 (
+ setSelected(transaction)}
+ selected={selected?.id === id}
+ divider
+ button
+ >
+ {error && (
+
+
+
+ )}
+ {active && !error && (
+
+
+
+ )}
+ {!active && !error && (
+
+
+
+ )}
+ {id}
+ {exclusive && (
+
+
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+ Transactions
+
+
+
+ Last refreshed{' '}
+ ago
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+
+
+
+
+
+
+
+
+ {transactions
+ .slice(sliceStart, sliceStart + 5)
+ .map((transaction) => renderListItem(transaction))}
+
+ {!hasTransactions && (
+
+
+ No active transactions.
+
+
+ )}
+
+
+ {hasTransactions && (
+
+ )}
+
+ {selected && (
+
+ )}
+
+
+ );
+};
diff --git a/lattice/src/App/Home/Transactions/index.ts b/lattice/src/App/Home/Transactions/index.ts
new file mode 100644
index 000000000..2ce547413
--- /dev/null
+++ b/lattice/src/App/Home/Transactions/index.ts
@@ -0,0 +1 @@
+export * from './Transactions';
diff --git a/lattice/src/App/Home/index.ts b/lattice/src/App/Home/index.ts
new file mode 100644
index 000000000..6fd0b5ba7
--- /dev/null
+++ b/lattice/src/App/Home/index.ts
@@ -0,0 +1 @@
+export * from './Home';
diff --git a/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.module.scss b/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.module.scss
new file mode 100644
index 000000000..8095b549c
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.module.scss
@@ -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);
+ }
+ }
+}
diff --git a/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.tsx b/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.tsx
new file mode 100644
index 000000000..cfe2d6dc8
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTable/MoleculaTable.tsx
@@ -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 = ({
+ table,
+ dataDistribution
+}) => {
+ const [page, setPage] = useState(1);
+ const [resultsPerPage, setResultsPerPage] = useState(10);
+ const sliceStart = (page - 1) * resultsPerPage;
+ const [searchText, setSearchText] = useState('');
+ const [filteredFields, setFiltereedFields] = useState(table.fields);
+ const [fieldsData, setFieldsData] = useState<{}>({});
+ const [maxFieldSize, setMaxFieldSize] = useState(0);
+ const [sort, setSort] = useState('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 (
+
+
+
+ Tables
+
+
+ {table.name}
+
+
+
+ {table.name}
+
+
+
+
+
+
+ {table.options.keys ? 'TRUE' : 'FALSE'}
+
+
+
+
+
+
+
+
+
+
+ {searchText.length > 1 && (
+
+ setSearchText('')}>
+ Clear filter
+
+
+ )}
+
+
+ setSearchText(e.target.value)}
+ placeholder="Search for Field"
+ variant="outlined"
+ size="small"
+ fullWidth
+ />
+
+
+
+
+
+ onSortClick('name')}
+ >
+ Name{' '}
+
+
+
+ Type
+ Cardinality
+ Options
+
+ onSortClick('total')}
+ >
+ Disk Usage{' '}
+
+
+
+
+
+
+ {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 (
+
+
+ 1 ? [searchText] : []}
+ textToHighlight={name}
+ autoEscape={true}
+ />
+
+
+
+ {type} {showKeys ? (keys ? '(keys)' : '(ID)') : null}
+
+
+
+ {cardinality ? cardinality.toLocaleString() : '-'}
+
+
+
+ {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 (
+
+ {key}
+ |
+ {renderValue(value)}
+
+ );
+ }
+ }
+ return null;
+ })}
+
+
+
+
+
+
+ );
+ })}
+
+
+ {filteredFields.length > 0 && (
+
+ )}
+ {filteredFields.length === 0 && (
+
+ No fields to show.
+ {searchText && (
+ setSearchText('')}>
+ Clear filter
+
+ )}
+
+ )}
+
+
+ );
+};
diff --git a/lattice/src/App/MoleculaTables/MoleculaTable/index.ts b/lattice/src/App/MoleculaTables/MoleculaTable/index.ts
new file mode 100644
index 000000000..9d13e2f6f
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTable/index.ts
@@ -0,0 +1 @@
+export * from './MoleculaTable';
diff --git a/lattice/src/App/MoleculaTables/MoleculaTables.module.scss b/lattice/src/App/MoleculaTables/MoleculaTables.module.scss
new file mode 100644
index 000000000..ec34ce4b3
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTables.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/MoleculaTables/MoleculaTables.tsx b/lattice/src/App/MoleculaTables/MoleculaTables.tsx
new file mode 100644
index 000000000..a3a4a119a
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTables.tsx
@@ -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 = ({
+ tables,
+ dataDistribution,
+ maxSize
+}) => {
+ const history = useHistory();
+ const [sortedTables, setSortedTables] = useState([]);
+
+ 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 (
+
+
+
+ Tables
+
+
+
+
+
+ {sortedTables.map((table) => {
+ const { name, options, fields } = table;
+
+ return (
+
+
+ {name}
+
+
+
+
+
+ keys
+
+ {options.keys ? 'TRUE' : 'FALSE'}
+
+
+
+ history.push(`/tables/${name}`)}
+ >
+ Show Fields ({fields.length})
+
+
+
+
+ );
+ })}
+
+ {tables && tables.length === 0 && (
+
+
+ There are no tables to show.
+
+
+ )}
+ {!tables && (
+
+
+ There is a problem connecting to Pilosa.
+
+
+ )}
+
+
+ );
+};
diff --git a/lattice/src/App/MoleculaTables/MoleculaTablesContainer.tsx b/lattice/src/App/MoleculaTables/MoleculaTablesContainer.tsx
new file mode 100644
index 000000000..7556acd16
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/MoleculaTablesContainer.tsx
@@ -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();
+ const [selectedTable, setSelectedTable] = useState();
+ const [dataDistribution, setDataDistribution] = useState();
+ const [maxSize, setMaxSize] = useState(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 ? (
+
+ ) : (
+
+ );
+};
diff --git a/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.module.scss b/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.module.scss
new file mode 100644
index 000000000..6e3cf558f
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.tsx b/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.tsx
new file mode 100644
index 000000000..562069458
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/UsageBreakdown/UsageBreakdown.tsx
@@ -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 = ({
+ 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 (
+
+ {showLabel ? : null}
+
+ {total ? (
+
+
+ {formatBytes(total)}
+
+
+ {fieldKeysTotal ? (
+
+
+
+ {formatBytes(fieldKeysTotal)} (
+ {fieldKeysPercentage.toLocaleString(undefined, {
+ maximumFractionDigits: 1
+ })}
+ %)
+
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : null}
+ {indexKeys ? (
+
+
+
+ {formatBytes(indexKeys)} (
+ {indexKeysPercentage.toLocaleString(undefined, {
+ maximumFractionDigits: 1
+ })}
+ %)
+
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : null}
+ {keys ? (
+
+
+
+ {formatBytes(keys)} (
+ {keysPercentage.toLocaleString(undefined, {
+ maximumFractionDigits: 1
+ })}
+ %)
+
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : null}
+ {fragments ? (
+
+
+
+ {formatBytes(fragments)} (
+ {fragmentsPercentage.toLocaleString(undefined, {
+ maximumFractionDigits: 1
+ })}
+ %)
+
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : null}
+ {metadata ? (
+
+
+
+ {formatBytes(metadata)} (
+ {metadataPercentage.toLocaleString(undefined, {
+ maximumFractionDigits: 1
+ })}
+ %)
+
+
+ }
+ placement="top"
+ arrow
+ >
+
+
+ ) : null}
+
+
+ ) : (
+
+ Calculating...
+
+ )}
+
+
+ );
+};
diff --git a/lattice/src/App/MoleculaTables/UsageBreakdown/index.ts b/lattice/src/App/MoleculaTables/UsageBreakdown/index.ts
new file mode 100644
index 000000000..36362bf49
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/UsageBreakdown/index.ts
@@ -0,0 +1 @@
+export * from './UsageBreakdown';
diff --git a/lattice/src/App/MoleculaTables/index.ts b/lattice/src/App/MoleculaTables/index.ts
new file mode 100644
index 000000000..2beb59dfe
--- /dev/null
+++ b/lattice/src/App/MoleculaTables/index.ts
@@ -0,0 +1,2 @@
+export * from './MoleculaTables';
+export * from './MoleculaTablesContainer';
diff --git a/lattice/src/App/NotFound/NotFound.tsx b/lattice/src/App/NotFound/NotFound.tsx
new file mode 100644
index 000000000..044fedf53
--- /dev/null
+++ b/lattice/src/App/NotFound/NotFound.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import Typography from '@material-ui/core/Typography';
+
+export const NotFound = () => (
+
+
+ Page Not Found
+
+
+);
diff --git a/lattice/src/App/NotFound/index.tsx b/lattice/src/App/NotFound/index.tsx
new file mode 100644
index 000000000..ea2877e32
--- /dev/null
+++ b/lattice/src/App/NotFound/index.tsx
@@ -0,0 +1 @@
+export * from './NotFound';
diff --git a/lattice/src/App/Query/Console/Console.module.scss b/lattice/src/App/Query/Console/Console.module.scss
new file mode 100644
index 000000000..604aef6e3
--- /dev/null
+++ b/lattice/src/App/Query/Console/Console.module.scss
@@ -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);
+ }
+}
diff --git a/lattice/src/App/Query/Console/Console.tsx b/lattice/src/App/Query/Console/Console.tsx
new file mode 100644
index 000000000..1b933ff19
--- /dev/null
+++ b/lattice/src/App/Query/Console/Console.tsx
@@ -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('');
+ const [showError, setShowError] = useState(false);
+ const [autocomplete, setAutocomplete] = useState('');
+ const [showAutocomplete, setShowAutocomplete] = useState(false);
+ const [inputIsDirty, setInputIsDirty] = useState(false);
+ const [clearedAfterQuery, setClearedAfterQuery] = useState(true);
+ const [queryType, setQueryType] = useState<'PQL' | 'SQL'>('SQL');
+ const [queryHistory, setQueryHistory] = useState(-1);
+ const [focused, setFocused] = useState(false);
+ const keys = Object.keys(keywordHelpers);
+ const inputEl = useRef(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) => {
+ 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) => {
+ 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 (
+
+
+
+
+ {showError ? (
+ {error?.error}
+ ) : null}
+
+ );
+});
diff --git a/lattice/src/App/Query/Console/helpers.ts b/lattice/src/App/Query/Console/helpers.ts
new file mode 100644
index 000000000..e5437566e
--- /dev/null
+++ b/lattice/src/App/Query/Console/helpers.ts
@@ -0,0 +1,151 @@
+export const keywordHelpers = {
+ Distinct: ['Distinct([ROW_CALL], field=, [index=])'],
+ Row: [
+ 'Row(=)',
+ 'Row(=, from=, to=)',
+ 'Row([ ] )'
+ ],
+ Union: ['Union([ROW_CALL ...])'],
+ Intersect: ['Intersect(, [ROW_CALL ...])'],
+ Difference: ['Difference(, [ROW_CALL ...])'],
+ Xor: ['Xor(, [ROW_CALL ...])'],
+ Not: ['Not('],
+ Count: ['Count()'],
+ Shift: ['Shift(, [n=UINT])'],
+ TopK: ['TopK(, [k=UINT], [filter=ROW_CALL], [from=TIMESTAMP], [to=TIMESTAMP])'],
+ TopN: [
+ 'TopN(, [ROW_CALL], [n=UINT], [attrName=, attrValues=<[]ATTR_VALUE>])'
+ ],
+ Min: ['Min([ROW_CALL], field=)'],
+ Max: ['Max([ROW_CALL], field=)'],
+ Sum: ['Sum([ROW_CALL], field=)'],
+ Rows:
+ ['Rows(, previous=, limit=, column=, from=, to=)'],
+ GroupBy: ['GroupBy(, [...], limit=, filter=, having=Condition([ ] ), aggregate=, sort=)'],
+ Extract: ['Extract(, [...])'],
+ Limit: ['Limit(, [limit=], [offset=])'],
+ Set: ['Set(, =, [TIMESTAMP])'],
+ SetColumnAttrs: ['SetColumnAttrs(, , [ATTR_NAME=ATTR_VALUE ...])'],
+ SetRowAttrs: ['SetRowAttrs(, , , [ATTR_NAME=ATTR_VALUE ...])'],
+ Store: ['Store(, =)'],
+ ClearRow: ['ClearRow(=)'],
+ Clear: ['Clear(, =)'],
+ Options: ['Options(, columnAttrs=, excludeColumns=, excludeRowAttrs=, shards=[UINT ...])'],
+ IncludesColumn: ['IncludesColumn(, column=)'],
+ select: [
+ 'select * from where _id = 1',
+ 'select , from ',
+ 'select _id, from where _id = <_ID>',
+ 'select _id from ',
+ 'select distinct from ',
+ 'select count(',
+ 'select min() from ',
+ 'select max() from ',
+ 'select sum() from ',
+ 'select avg() from ',
+ ],
+ 'select * from': [
+ 'select * from where _id = 1',
+ 'select , from ',
+ 'select _id, from where _id = <_ID>',
+ 'select _id from ',
+ 'select distinct from ',
+ 'select count(',
+ 'select min() from ',
+ 'select max() from ',
+ 'select sum() from ',
+ 'select avg() from ',
+ ],
+ 'select _id from': [
+ 'select _id from where = 1',
+ 'select _id from where in (1, 2)',
+ 'select _id from where = 1 limit 1',
+ 'select _id from where = 1 and = 2',
+ ],
+ 'select count(': [
+ 'select count(*) from ',
+ 'select count(*) from where = 1',
+ 'select count(*) from where = 1 and = 2',
+ 'select count(distinct ) from ',
+ ],
+ 'select distinct': ['select distinct from '],
+ 'select avg(': [
+ 'select avg() from ',
+ 'select avg() from where = 1',
+ ],
+ 'select min(': [
+ 'select min() from ',
+ 'select min() from where = 1',
+ ],
+ 'select max(': [
+ 'select max() from ',
+ 'select max() from where = 1',
+ ],
+ 'select sum(': [
+ 'select sum() from ',
+ 'select sum() from where = 1',
+ ],
+ show: ['show tables', 'show fields from '],
+ 'show tables': ['show tables', 'show fields from '],
+ 'show fields from': ['show tables', 'show fields from '],
+};
+
+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('
+];
diff --git a/lattice/src/App/Query/Console/index.ts b/lattice/src/App/Query/Console/index.ts
new file mode 100644
index 000000000..b2bf505d3
--- /dev/null
+++ b/lattice/src/App/Query/Console/index.ts
@@ -0,0 +1 @@
+export * from './Console';
diff --git a/lattice/src/App/Query/Query.module.scss b/lattice/src/App/Query/Query.module.scss
new file mode 100644
index 000000000..700f52514
--- /dev/null
+++ b/lattice/src/App/Query/Query.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/Query/Query.tsx b/lattice/src/App/Query/Query.tsx
new file mode 100644
index 000000000..ae35a40b0
--- /dev/null
+++ b/lattice/src/App/Query/Query.tsx
@@ -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 = ({
+ indexList = [],
+ results,
+ error,
+ loading,
+ onRemoveResult,
+ onClear,
+ onQuery
+}) => {
+ const [index, setIndex] = useState();
+ const [helperText, setHelperText] = useState([]);
+ const [showRecent, setShowRecent] = useState(false);
+ const colSizes = JSON.parse(localStorage.getItem('colSizes') || '[80, 20]');
+ const inputRef = createRef();
+ 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 (
+
+
+
+ Query
+
+
+
+ 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' }}
+ >
+
+
+
+
+
onQuery(query, type, index)}
+ error={error}
+ onHelperTextChange={setHelperText}
+ onSetIndex={onSetIndex}
+ loading={loading}
+ indexList={indexList}
+ previousQueries={queries}
+ />
+
+
+ {helperText
+ ? helperText.map((txt) => {txt}
)
+ : null}
+
+
+
+
+ {results.length > 0 ? (
+
+ setShowRecent(true)}
+ >
+ Show Recent Queries
+
+ |
+
+ Clear Results
+
+
+ ) : null}
+ {!results.length && hasRecentQueries ? (
+
+
+ Recent Queries
+
+ onReRunQuery(query)} />
+
+ ) : null}
+ {loading && (
+
+
+
+ )}
+ {results.map((result, idx) => (
+
+ onRemoveResult(idx)}
+ />
+
+ ))}
+
+ {index ? (
+
+
+ Available Fields
+
+
+ {index.fields.map((field) => (
+
+ {field.name} ({field.options.type})
+
+ ))}
+
+
+ ) : (
+
+
+ Available Indexes
+
+ {indexList ? (
+
+ {indexList.map((i) => (
+
+ {i.name}
+
+ ))}
+
+ ) : (
+
+ No indexes available
+
+ )}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/lattice/src/App/Query/QueryContainer.tsx b/lattice/src/App/Query/QueryContainer.tsx
new file mode 100644
index 000000000..bd1a41791
--- /dev/null
+++ b/lattice/src/App/Query/QueryContainer.tsx
@@ -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();
+ const [results, setResults] = useState([]);
+ const [errorResult, setErrorResult] = useState();
+ const [loading, setLoading] = useState(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 (
+ setResults([])}
+ onRemoveResult={removeResultItem}
+ />
+ );
+};
diff --git a/lattice/src/App/Query/QueryResults/QueryResults.module.scss b/lattice/src/App/Query/QueryResults/QueryResults.module.scss
new file mode 100644
index 000000000..cd83bf011
--- /dev/null
+++ b/lattice/src/App/Query/QueryResults/QueryResults.module.scss
@@ -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;
+ }
+}
diff --git a/lattice/src/App/Query/QueryResults/QueryResults.tsx b/lattice/src/App/Query/QueryResults/QueryResults.tsx
new file mode 100644
index 000000000..abab622e0
--- /dev/null
+++ b/lattice/src/App/Query/QueryResults/QueryResults.tsx
@@ -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 = ({
+ collapsibleQuery = true,
+ results,
+ onRemoveResult
+}) => {
+ const [showQuery, setShowQuery] = useState(false);
+ const [copyTooltip, setCopyTooltip] = useState('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 (
+
+
+
+
+ {!collapsibleQuery ? (
+
+ Query
+
+ ) : (
+ setShowQuery(!showQuery)}
+ className={css.link}
+ >
+ {showQuery ? 'Hide' : 'Show'} Query
+
+ )}
+
+
+
+
+
+
+
+ {results.duration ? (
+
+
+ {formatDuration(results.duration, true)}
+
+ | query time
+
+ {formatDuration(results.roundtrip)}
+
+ | total roundtrip time
+
+ }
+ placement="top"
+ arrow
+ >
+
+ {formatDuration(results.duration, true)}
+
+
+ ) : null}
+
+ {onRemoveResult ? (
+
+
+
+ ) : null}
+
+
+ {!collapsibleQuery || showQuery ? (
+
{queryString}
+ ) : null}
+
+ {results.operation === 'GroupBy' && results.rows.length <= 50 ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/lattice/src/App/Query/QueryResults/index.ts b/lattice/src/App/Query/QueryResults/index.ts
new file mode 100644
index 000000000..6a84a4e6a
--- /dev/null
+++ b/lattice/src/App/Query/QueryResults/index.ts
@@ -0,0 +1 @@
+export * from './QueryResults';
diff --git a/lattice/src/App/Query/RecentQueries/RecentQueries.module.scss b/lattice/src/App/Query/RecentQueries/RecentQueries.module.scss
new file mode 100644
index 000000000..1a301c189
--- /dev/null
+++ b/lattice/src/App/Query/RecentQueries/RecentQueries.module.scss
@@ -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);
+ }
+ }
+}
diff --git a/lattice/src/App/Query/RecentQueries/RecentQueries.tsx b/lattice/src/App/Query/RecentQueries/RecentQueries.tsx
new file mode 100644
index 000000000..2a46badfd
--- /dev/null
+++ b/lattice/src/App/Query/RecentQueries/RecentQueries.tsx
@@ -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 = ({ onQueryClick }) => {
+ const [copyText, setCopyText] = useState('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) => (
+ (onQueryClick ? onQueryClick(query) : null)}
+ >
+
+ {
+ e.stopPropagation();
+ onCopyQuery(query);
+ }}
+ >
+
+
+
+
+
+ {query}
+
+
+
+ );
+
+ return (
+
+ {queries && queries.length > 0 ? (
+
+ {queries.map((query) => (
+ {renderItem(query)}
+ ))}
+
+ ) : (
+
+ No recent queries.
+
+ )}
+
+ );
+};
diff --git a/lattice/src/App/Query/RecentQueries/index.ts b/lattice/src/App/Query/RecentQueries/index.ts
new file mode 100644
index 000000000..f39b26a5f
--- /dev/null
+++ b/lattice/src/App/Query/RecentQueries/index.ts
@@ -0,0 +1 @@
+export * from './RecentQueries';
diff --git a/lattice/src/App/Query/index.ts b/lattice/src/App/Query/index.ts
new file mode 100644
index 000000000..a33aaac0c
--- /dev/null
+++ b/lattice/src/App/Query/index.ts
@@ -0,0 +1,2 @@
+export * from './Query';
+export * from './QueryContainer';
diff --git a/lattice/src/App/Query/splitjs.scss b/lattice/src/App/Query/splitjs.scss
new file mode 100644
index 000000000..33b86fe6c
--- /dev/null
+++ b/lattice/src/App/Query/splitjs.scss
@@ -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);
+}
diff --git a/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.module.scss b/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.module.scss
new file mode 100644
index 000000000..107dbd154
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.tsx b/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.tsx
new file mode 100644
index 000000000..893fbf81c
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/ColumnSelector/ColumnSelector.tsx
@@ -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 = ({
+ 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 (
+
+ );
+};
diff --git a/lattice/src/App/QueryBuilder/ColumnSelector/index.ts b/lattice/src/App/QueryBuilder/ColumnSelector/index.ts
new file mode 100644
index 000000000..89b8b7034
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/ColumnSelector/index.ts
@@ -0,0 +1 @@
+export * from './ColumnSelector';
diff --git a/lattice/src/App/QueryBuilder/GroupByChart/GroupByChart.tsx b/lattice/src/App/QueryBuilder/GroupByChart/GroupByChart.tsx
new file mode 100644
index 000000000..fb323264e
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/GroupByChart/GroupByChart.tsx
@@ -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 = ({ 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 (
+
+ 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 }) => (
+
+ {id}: {value}
+
+ )}
+ 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}
+ />
+
+ );
+};
diff --git a/lattice/src/App/QueryBuilder/GroupByChart/index.ts b/lattice/src/App/QueryBuilder/GroupByChart/index.ts
new file mode 100644
index 000000000..98412a988
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/GroupByChart/index.ts
@@ -0,0 +1 @@
+export * from './GroupByChart';
diff --git a/lattice/src/App/QueryBuilder/QueryBuilder.module.scss b/lattice/src/App/QueryBuilder/QueryBuilder.module.scss
new file mode 100644
index 000000000..af2358697
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/QueryBuilder.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/QueryBuilder/QueryBuilder.tsx b/lattice/src/App/QueryBuilder/QueryBuilder.tsx
new file mode 100644
index 000000000..ec495294b
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/QueryBuilder.tsx
@@ -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 = ({
+ tables,
+ results,
+ fullResultsCount,
+ fullRecordsCount,
+ error,
+ loading,
+ onQuery,
+ onRunGroupBy,
+ onExternalLookup,
+ onClear
+}) => {
+ const [newOperatorEl, setNewOperatorEl] = useState(null);
+ const [operatorEl, setOperatorEl] = useState(null);
+ const [saveButtonEl, setSaveButtonEl] = useState(null);
+ const [showBuilder, setShowBuilder] = useState(true);
+ const [showColumnSelector, setShowColumnSelector] = useState(false);
+ const [hasInvalid, setHasInvalid] = useState(false);
+ const [selectedTable, setSelectedTable] = useState(tables[0]);
+ const [selectedColumns, setSelectedColumns] = useState<
+ { name: string; show: boolean }[]
+ >(
+ tables[0].fields.map((field) => ({
+ name: field.name,
+ show: true
+ }))
+ );
+ const [operation, setOperation] = useState('Extract');
+ const [groupByCall, setGroupByCall] = useState({
+ primary: '',
+ secondary: ''
+ });
+ const [rowCalls, setRowCalls] = useState([]);
+ const [operator, setOperator] = useState();
+ const [editSavedIdx, setEditSavedIdx] = useState(-1);
+ const [queryName, setQueryName] = useState('');
+ const [queryDescription, setQueryDescription] = useState('');
+ const [updating, setUpdating] = useState(false);
+ const [queriesList, setQueriesList] = useState(
+ 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 (
+
+ 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 ? (
+
+
+ setShowBuilder(false)} size="small">
+
+
+
+
+ {inEditMode ? (
+
+
+ Back to new query
+
+ ) : null}
+
+ {inEditMode ? 'Edit Saved Query' : 'New Query'}
+
+
+
+ {inEditMode ? (
+
+ = 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
+ />
+
+ setQueryDescription(event.target.value)}
+ margin="normal"
+ size="small"
+ fullWidth
+ />
+
+ ) : null}
+
+
+
+
+
+
+ {selectedTable
+ ? rowCalls.map((rowCall, idx) => (
+
+ {idx > 0 ? (
+
+
+
+
+
+
+
+
+ ) : null}
+
+ onUpdateRow(idx, updatedRow, operator, isNot)
+ }
+ onRemoveGroup={() => onRemoveGroup(idx)}
+ />
+
+ ))
+ : null}
+
+ {selectedTable && operation === 'GroupBy' ? (
+
+
+
+ ) : (
+
+
+
{
+ if (rowCalls.length === 0 || !!operator) {
+ onNewGroup();
+ } else {
+ setNewOperatorEl(event.currentTarget);
+ }
+ }}
+ >
+
+
+
+
+ )}
+
+
+
+
+
+ {inEditMode ? (
+
+ ) : (
+
+
+ setSaveButtonEl(null)}
+ anchorOrigin={{
+ vertical: 'top',
+ horizontal: 'left'
+ }}
+ transformOrigin={{
+ vertical: 'bottom',
+ horizontal: 'left'
+ }}
+ >
+
+
+ Give your query a unique name and a short description to
+ help you identify it for future use.
+
+
+
= 0}
+ helperText={
+ queryNameIdx >= 0 ? 'Query name must be unique' : ''
+ }
+ onChange={(event) => setQueryName(event.target.value)}
+ margin="dense"
+ required
+ fullWidth
+ />
+
+
+ setQueryDescription(event.target.value)
+ }
+ margin="dense"
+ fullWidth
+ />
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ ) : (
+
+
setShowBuilder(true)} size="small">
+
+
+
+ )}
+
+
+
+
+ Results{' '}
+
+ {results?.query.includes('Extract(') ? (
+
+
+
+
+
+
+ ) : null}
+
+ {loading ? Loading...
: null}
+ {results && !error && !loading ? (
+
+ {fullResultsCount && fullRecordsCount ? (
+
+ {results.duration ? (
+
+ {fullRecordsCount.toLocaleString()} records scanned in{' '}
+ {formatDuration(results.duration, true)}.
+
+ ) : null}
+
+ Showing{' '}
+ {fullResultsCount > 1000 ? 'first 1,000 rows of' : 'all'}{' '}
+ {fullResultsCount.toLocaleString()} results.
+
+
+ ) : null}
+
+
+ ) : null}
+ {error && !loading ? {error.error}
: null}
+ {!loading && !results && !error ? (
+
+
+ Build a query to see results
+
+ {queriesList.length > 0 ? (
+
+
+ Saved Queries
+
+
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+};
diff --git a/lattice/src/App/QueryBuilder/QueryBuilderContainer.module.scss b/lattice/src/App/QueryBuilder/QueryBuilderContainer.module.scss
new file mode 100644
index 000000000..cd406d4e8
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/QueryBuilderContainer.module.scss
@@ -0,0 +1,4 @@
+.noTables {
+ margin-top: 20px;
+ padding: 12px 16px;
+}
diff --git a/lattice/src/App/QueryBuilder/QueryBuilderContainer.tsx b/lattice/src/App/QueryBuilder/QueryBuilderContainer.tsx
new file mode 100644
index 000000000..477000a1f
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/QueryBuilderContainer.tsx
@@ -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();
+ const [results, setResults] = useState();
+ const [fullCount, setFullCount] = useState();
+ const [recordsCount, setRecordsCount] = useState();
+ const [errorResult, setErrorResult] = useState();
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(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 ? (
+
+ {
+ setResults(undefined);
+ setFullCount(undefined);
+ setErrorResult(undefined);
+ }}
+ />
+
+ setError('')}>
+ {error}
+
+
+
+ ) : (
+
+
+ Query Builder
+
+
+
+ There are no tables to query.
+
+
+
+ );
+};
diff --git a/lattice/src/App/QueryBuilder/RowCall/RowCall.module.scss b/lattice/src/App/QueryBuilder/RowCall/RowCall.module.scss
new file mode 100644
index 000000000..627659a9c
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/RowCall/RowCall.module.scss
@@ -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;
+}
diff --git a/lattice/src/App/QueryBuilder/RowCall/RowCall.tsx b/lattice/src/App/QueryBuilder/RowCall/RowCall.tsx
new file mode 100644
index 000000000..1b0279097
--- /dev/null
+++ b/lattice/src/App/QueryBuilder/RowCall/RowCall.tsx
@@ -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 = ({
+ fields,
+ rowData,
+ isNot,
+ operator,
+ showErrors,
+ onUpdate,
+ onRemoveGroup
+}) => {
+ const [operatorEl, setOperatorEl] = useState(null);
+ const [activeOperatorEl, setActiveOperatorEl] = useState(
+ null
+ );
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [activeAnchor, setActiveAnchor] = useState();
+
+ 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 (
+
+
+
+
+
+
+ {isNot ? (
+ onUpdate(rowData, operator, !isNot)}
+ />
+ ) : (
+ onUpdate(rowData, operator, !isNot)}
+ />
+ )}
+ onUpdate(rowData, operator, !isNot)}
+ >
+ Not
+
+
+
+ {rowData.map((row, idx) => {
+ const { field, rowOperator, value, type } = row;
+ let isInvalidValue = !value;
+ if (rowOperator === 'cidr') {
+ try {
+ getIPRange(value);
+ } catch (err) {
+ isInvalidValue = true;
+ }
+ }
+
+ return (
+
+ {idx > 0 && operator ? (
+
+ setActiveOperatorEl(event.currentTarget)}
+ >
+ {operator}
+
+
+
+ ) : null}
+
+
{
+ 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);
+ }}
+ />
+
+
+ {
+ setAnchorEl(event.currentTarget);
+ setActiveAnchor(idx);
+ }}
+ >
+ {
+ operators[type].find((op) => op.value === rowOperator)
+ ?.label
+ }
+
+
+
+
+ {['int', 'set'].includes(type) ? (
+
+ onRowUpdate(idx, { ...row, value: event.target.value })
+ }
+ />
+ ) : type === 'timestamp' ? (
+
+