initial refactor commit

This commit is contained in:
Stephanie Yang 2021-07-27 11:22:08 -05:00
parent 6935ac2667
commit fa353faf72
21 changed files with 1666 additions and 2 deletions

View file

@ -9,7 +9,7 @@ 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 { QBuilderContainer } from 'App/QBuilder';
import css from './App.module.scss';
const App = () => {
@ -45,7 +45,7 @@ const App = () => {
<Route exact path="/" component={Home} />
<Route path="/tables/:id?" component={MoleculaTablesContainer} />
<Route exact path="/query" component={QueryContainer} />
<Route exact path="/querybuilder" component={QueryBuilderContainer} />
<Route exact path="/querybuilder" component={QBuilderContainer} />
<Route component={NotFound} />
</Switch>
</div>

View file

@ -0,0 +1,36 @@
import React, { FC } from 'react';
import { RowCallBuilder } from 'App/QBuilder/RowCallBuilder';
type CountBuilderProps = {
table: any;
query: any;
showInvalid: boolean;
onChange: (query: any) => void;
};
export const CountBuilder: FC<CountBuilderProps> = ({
table,
query,
showInvalid,
onChange
}) => {
const { rowCalls } = query ? query : { rowCalls: [] };
return (
<div>
<RowCallBuilder
rowCalls={rowCalls}
fields={table.fields}
showInvalid={showInvalid}
onChange={(rowCalls, operator, isInvalid) => {
onChange({
...query,
rowCalls,
operator,
isInvalid
});
}}
/>
</div>
);
};

View file

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

View file

@ -0,0 +1,21 @@
.columnsSelector {
margin: 4px 14px 4px 0;
display: flex;
text-align: center;
.info {
margin-left: 4px;
fill: var(--text-secondary);
}
}
.textLink {
color: var(--primary);
font-size: 12px;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}

View file

@ -0,0 +1,92 @@
import React, { FC, useState } from 'react';
import InfoIcon from '@material-ui/icons/Info';
import Tooltip from '@material-ui/core/Tooltip';
import { ColumnSelector } from 'App/QueryBuilder/ColumnSelector';
import { RowCallBuilder } from 'App/QBuilder/RowCallBuilder';
import { useEffectOnce } from 'react-use';
import css from './ExtractBuilder.module.scss';
type ExtractBuilderProps = {
table: any;
query: any;
showInvalid: boolean;
onChange: (query: any) => void;
};
export const ExtractBuilder: FC<ExtractBuilderProps> = ({
table,
query,
showInvalid,
onChange
}) => {
const { columns, rowCalls } = query
? query
: {
columns: [],
rowCalls: []
};
const [showColumnSelector, setShowColumnSelector] = useState<boolean>(false);
useEffectOnce(() => {
if (!query) {
onChange({
columns: table.fields.map((field) => field.name),
operation: 'Extract',
rowCalls: []
});
}
});
return (
<div className={css.extract}>
<div className={css.columnsSelector}>
<span
className={css.textLink}
onClick={() => setShowColumnSelector(true)}
>
Configure result fields
</span>
<Tooltip
className={css.info}
title="This controls the fields that will show up in the results table after querying"
placement="top"
arrow
>
<InfoIcon fontSize="inherit" />
</Tooltip>
</div>
<ColumnSelector
open={showColumnSelector}
fieldsList={table.fields.map((field) => ({
name: field.name,
show: columns ? columns.includes(field.name) : true
}))}
onChange={(allColumns) => {
let cols: string[] = [];
allColumns.forEach((col) => {
if (col.show) {
cols.push(col.name);
}
});
onChange({ ...query, columns: cols });
}}
onClose={() => setShowColumnSelector(false)}
/>
<RowCallBuilder
rowCalls={rowCalls}
fields={table.fields}
showInvalid={showInvalid}
onChange={(rowCalls, operator, isInvalid) => {
onChange({
...query,
rowCalls,
operator,
isInvalid
});
}}
/>
</div>
);
};

View file

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

View file

@ -0,0 +1,32 @@
.groupBy {
.fieldSelector {
margin-bottom: 16px;
}
.filtersHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.filtersInfo {
vertical-align: text-top;
margin-left: 4px;
}
}
.textLink {
color: var(--primary);
font-size: 12px;
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
.filtersSection {
margin-bottom: 16px;
}

View file

@ -0,0 +1,179 @@
import React, { FC } from 'react';
import InfoIcon from '@material-ui/icons/Info';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { GroupBySort } from 'App/QueryBuilder/GroupBySort';
import { SavedQueries } from 'App/QueryBuilder/SavedQueries';
import { Select } from 'shared/Select';
import { stringifyRowData } from 'App/QueryBuilder/stringifyRowData';
import css from './GroupByBuilder.module.scss';
type GroupByBuilderProps = {
table: any;
query: any;
showInvalid: boolean;
onChange: (query: any) => void;
};
export const GroupByBuilder: FC<GroupByBuilderProps> = ({
table,
query,
showInvalid,
onChange
}) => {
const { groupByCall, filter, sort } = query;
const filters = JSON.parse(
localStorage.getItem('saved-queries') || '[]'
).filter(
(q) =>
q.table === table.name &&
q.operation === 'Extract' &&
q.rowCalls.length > 0
);
return (
<div className={css.groupBy}>
<Select
className={css.fieldSelector}
label="Primary Field"
value={
groupByCall ? (groupByCall.primary ? groupByCall.primary : '') : ''
}
options={table.fields
.filter((f) =>
['set', 'time', 'mutex', 'bool', 'int', 'timestamp'].includes(
f.options.type
)
)
.map((field) => {
return {
label: `${field.name} (${field.options.type})`,
value: field.name
};
})}
onChange={(value) =>
onChange({
...query,
groupByCall: {
...query.groupByCall,
primary: value.toString()
}
})
}
error={showInvalid && !groupByCall.primary}
/>
<Select
className={css.fieldSelector}
label="Secondary Field (optional)"
value={
groupByCall
? groupByCall.secondary
? groupByCall.secondary
: ''
: ''
}
allowEmpty={true}
options={table.fields
.filter((f) =>
['set', 'time', 'mutex', 'bool', 'int', 'timestamp'].includes(
f.options.type
)
)
.map((field) => {
return {
label: `${field.name} (${field.options.type})`,
value: field.name
};
})}
onChange={(value) =>
onChange({
...query,
groupByCall: {
...query.groupByCall,
secondary: value.toString()
}
})
}
/>
<div className={css.filtersSection}>
<div className={css.filtersHeader}>
<Typography variant="caption" color="textSecondary">
Filter (optional)
{filters.length > 0 && (
<Tooltip
className={css.filtersInfo}
title={`To use filters, save an Extract query for ${table.name} with at least one field constraint.`}
placement="top"
arrow
>
<InfoIcon fontSize="inherit" />
</Tooltip>
)}
</Typography>
{filter ? (
<span
className={css.textLink}
onClick={() => onChange({ ...query, filter: undefined })}
>
Clear Filter
</span>
) : null}
</div>
{filter ? (
<Typography variant="caption">{filter}</Typography>
) : filters.length > 0 ? (
<SavedQueries
queries={filters}
tables={[table]}
onClickSaved={(queryIdx) => {
const { rowCalls, operator } = filters[queryIdx];
const res = stringifyRowData(rowCalls, operator);
if (!res.error) {
onChange({ ...query, filter: res.query });
}
}}
/>
) : (
<div className={css.infoMessage}>
No available filters. To use filters, save an Extract query for{' '}
{table.name} with at least one field constraint.
</div>
)}
</div>
<div className={css.sortSection}>
<Typography variant="caption" color="textSecondary">
Sort (optional)
</Typography>
<GroupBySort
sort={sort ? sort : []}
onUpdate={(value) => {
let isInvalid = false;
if (
value.length > 0 &&
value[0].sortValue.includes('sum') &&
!value[0].field
) {
isInvalid = true;
} else if (
value.length > 1 &&
value[1].sortValue.includes('sum') &&
!value[1].field
) {
isInvalid = true;
}
onChange({ ...query, sort: value, isInvalid });
}}
fields={table.fields
.filter((field) => field.options.type === 'int')
.map((field) => {
return { label: field.name, value: field.name };
})}
showErrors={showInvalid}
/>
</div>
</div>
);
};

View file

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

View file

@ -0,0 +1,52 @@
.sharedConfig {
padding: 32px 32px 0;
.configSelector {
margin-bottom: 24px;
}
}
.textLink {
color: var(--primary);
font-size: 12px;
.icon {
margin-right: 4px;
vertical-align: text-top;
}
&:hover {
cursor: pointer;
text-decoration: underline;
color: var(--primary);
}
}
.builder {
display: flex;
flex-direction: column;
height: 100%;
.queryMetadata {
border-bottom: 1px solid var(--divider);
padding-bottom: 16px;
margin-bottom: 16px;
}
}
.config {
padding: 24px 32px 0;
flex-grow: 1;
}
.builderActions {
padding: 16px 32px;
display: flex;
justify-content: space-between;
.mainActions {
display: grid;
grid-template-columns: auto auto;
grid-gap: 8px;
}
}

View file

@ -0,0 +1,388 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import ArrowBackIcon from '@material-ui/icons/ArrowBack';
import Button from '@material-ui/core/Button';
import CircularProgress from '@material-ui/core/CircularProgress';
import Divider from '@material-ui/core/Divider';
import Popover from '@material-ui/core/Popover';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import {
cleanupRows,
stringifyCount,
stringifyExtract,
stringifyGroupBy
} from './utils';
import { CountBuilder } from './CountBuilder';
import { ExtractBuilder } from './ExtractBuilder';
import { GroupByBuilder } from './GroupByBuilder';
import { Select } from 'shared/Select';
import css from './QBuilder.module.scss';
type QBuilderProps = {
tables: any[];
savedQuery: number;
onRun: (
table: string,
operation: string,
query: string,
countQuery?: string
) => void;
onClear: () => void;
onExitEdit: () => void;
onSaveQuery: () => void;
};
export const QBuilder: FC<QBuilderProps> = ({
tables,
savedQuery,
onRun,
onClear,
onExitEdit,
onSaveQuery
}) => {
const [saveButtonEl, setSaveButtonEl] = useState<null | HTMLElement>(null);
const queriesList = JSON.parse(localStorage.getItem('saved-queries') || '[]');
const [editMode, setEditMode] = useState<boolean>(false);
const [selectedTable, setSelectedTable] = useState<any>(tables[0]);
const [operation, setOperation] = useState<string>('Extract');
const [query, setQuery] = useState<any>({
table: tables[0].name,
operation: 'Extract',
columns: tables[0].fields.map((field) => field.name)
});
const [showInvalid, setShowInvalid] = useState<boolean>(false);
const [updating, setUpdating] = useState<boolean>(false);
const [queryNameIdx, setQueryNameIdx] = useState<number>(-1);
useEffect(() => {
if (savedQuery > -1) {
const updatedList = JSON.parse(
localStorage.getItem('saved-queries') || '[]'
);
setQuery(updatedList[savedQuery]);
setEditMode(true);
setOperation(updatedList[savedQuery].operation);
const table = tables.find(
(table) => table.name === updatedList[savedQuery].table
);
setSelectedTable(table);
}
}, [savedQuery, tables]);
useEffect(() => {
if (query?.name) {
setQueryNameIdx(queriesList.findIndex((q) => q.name === query.name));
}
}, [query, queriesList]);
const cleanRows = () => {
const cleanRows = cleanupRows(query?.rowCalls);
return {
...query,
rowCalls: cleanRows.cleanRowCalls,
isInvalid: cleanRows.isInvalid
};
};
const runQuery = () => {
setShowInvalid(true);
let queryString = '';
let countQuery = '';
switch (operation) {
case 'Extract':
const cleanExtractQuery = cleanRows();
setQuery(cleanExtractQuery);
const extract = stringifyExtract(cleanExtractQuery);
if (!extract.error && !cleanExtractQuery.isInvalid) {
queryString = extract.queryString;
countQuery = extract.countQuery;
}
break;
case 'GroupBy':
queryString = stringifyGroupBy(query);
break;
case 'Count':
const cleanCountQuery = cleanRows();
setQuery(cleanCountQuery);
const count = stringifyCount(cleanCountQuery);
if (!count.error && !cleanCountQuery.isInvalid) {
queryString = count.queryString;
}
break;
default:
break;
}
if (queryString && !query.isInvalid) {
setShowInvalid(false);
onRun(selectedTable.name, operation, queryString, countQuery);
} else {
setShowInvalid(true);
}
};
const reset = () => {
setQuery({
table: selectedTable.name,
columns: selectedTable.fields.map(field => field.name),
operation,
isInvalid: false
});
setOperation('Extract');
setShowInvalid(false);
setEditMode(false);
onExitEdit();
onClear();
};
const onSave = () => {
setUpdating(true);
setTimeout(() => {
setUpdating(false);
}, 1500);
const cleanQuery = cleanRows();
let queries = [...queriesList];
if (editMode && queries.length > 0) {
const clone = [...queries];
clone.splice(savedQuery, 1, cleanQuery);
localStorage.setItem('saved-queries', JSON.stringify(clone));
} else {
queries.push(cleanQuery);
localStorage.setItem('saved-queries', JSON.stringify(queries));
}
onSaveQuery();
};
const renderBuilder = () => {
switch (operation) {
case 'Extract':
return (
<ExtractBuilder
table={selectedTable}
showInvalid={showInvalid}
query={query}
onChange={(q) => setQuery(q)}
/>
);
case 'GroupBy':
return (
<GroupByBuilder
table={selectedTable}
showInvalid={showInvalid}
query={query}
onChange={(q) => setQuery(q)}
/>
);
case 'Count':
return (
<CountBuilder
table={selectedTable}
showInvalid={showInvalid}
query={query}
onChange={(q) => setQuery(q)}
/>
);
default:
return <div>Invalid Operation</div>;
}
};
return (
<div className={css.builder}>
<div className={css.sharedConfig}>
{editMode ? (
<span className={css.textLink} onClick={reset}>
<ArrowBackIcon fontSize="inherit" className={css.icon} />
Back to new query
</span>
) : null}
<Typography variant="h5" color="textSecondary" paragraph>
{editMode ? 'Edit Saved Query' : 'New Query'}
</Typography>
{editMode ? (
<div className={css.queryMetadata}>
<TextField
variant="outlined"
label="Query Name"
placeholder="Unique name to identify query"
value={query?.name}
error={
!query?.name ||
(queryNameIdx >= 0 && queryNameIdx !== savedQuery)
}
helperText={
queryNameIdx >= 0 && queryNameIdx !== savedQuery
? 'Query name must be unique'
: ''
}
onChange={(event) =>
setQuery({ ...query, name: event.target.value })
}
margin="normal"
size="small"
required
fullWidth
/>
<TextField
variant="outlined"
label="Description"
value={query?.description}
onChange={(event) =>
setQuery({ ...query, description: event.target.value })
}
margin="normal"
size="small"
fullWidth
/>
</div>
) : null}
<Select
className={css.configSelector}
label="Table"
value={selectedTable.name}
options={tables.map((table) => {
return { label: table.name, value: table.name };
})}
onChange={(value) => {
const table = tables.find((t) => t.name === value);
setSelectedTable(table);
setShowInvalid(false);
setQuery({
table: table.name,
columns: table.fields.map((field) => field.name),
operation: query.operation,
isInvalid: false
});
}}
/>
<Select
className={css.configSelector}
label="Operation"
value={operation}
options={[
{ label: 'Extract', value: 'Extract' },
{ label: 'Count', value: 'Count' },
{ label: 'GroupBy', value: 'GroupBy' }
]}
onChange={(value) => {
setOperation(value);
setShowInvalid(false);
setQuery({
table: selectedTable.name,
operation: value,
isInvalid: false
});
}}
/>
<Divider />
</div>
<div className={css.config}>{renderBuilder()}</div>
<div className={css.builderActions}>
<div className={css.mainActions}>
<Button variant="contained" color="primary" onClick={runQuery}>
Run
</Button>
{editMode ? (
<Button
variant="contained"
color="default"
onClick={onSave}
disabled={
!query?.name || (savedQuery >= 0 && queryNameIdx !== savedQuery)
}
>
{updating ? <CircularProgress size="0.875rem" /> : 'Update'}
</Button>
) : (
<Fragment>
<Button
variant="contained"
color="default"
onClick={(event) => setSaveButtonEl(event.currentTarget)}
>
Save
</Button>
<Popover
open={!!saveButtonEl}
anchorEl={saveButtonEl}
onClose={() => setSaveButtonEl(null)}
anchorOrigin={{
vertical: 'top',
horizontal: 'left'
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'left'
}}
>
<div className={css.savePopover}>
<Typography variant="caption" paragraph>
Give your query a unique name and a short description to
help you identify it for future use.
</Typography>
<TextField
variant="outlined"
label="Query Name"
placeholder="Unique name to identify query"
value={query?.name ? query.name : ''}
error={queryNameIdx >= 0}
helperText={
queryNameIdx >= 0 ? 'Query name must be unique' : ''
}
onChange={(event) =>
setQuery({ ...query, name: event.target.value })
}
margin="dense"
required
fullWidth
/>
<TextField
variant="outlined"
label="Description"
value={query?.description ? query.description : ''}
onChange={(event) =>
setQuery({ ...query, description: event.target.value })
}
margin="dense"
fullWidth
/>
<div className={css.popoverActions}>
<div className={css.saveActions}>
<Button
variant="contained"
color="default"
onClick={() => {
onSave();
setSaveButtonEl(null);
}}
disabled={!query?.name || queryNameIdx >= 0}
>
Ok
</Button>
<Button
color="default"
onClick={() => setSaveButtonEl(null)}
>
Cancel
</Button>
</div>
</div>
</div>
</Popover>
</Fragment>
)}
</div>
<Button onClick={reset}>Clear</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,66 @@
.builderColumn,
.results {
height: calc(100vh - 64px);
overflow: scroll;
position: relative;
.openClose {
position: absolute;
right: 10px;
top: 10px;
}
}
.split {
display: flex;
}
.collapsedBuilder {
border-right: 1px solid rgba(var(--contrast-rgb), 0.1);
text-align: center;
padding-top: 10px;
}
.resultsBlock {
height: 100%;
}
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
.download {
display: flex;
align-items: center;
}
.downloadInfo {
margin-right: 8px;
}
}
.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;
}
.spacer{
padding-bottom: 16px;
}
.resultsLoading {
margin-left: 8px;
}
.savedQueries {
margin-top: 48px;
}
.noTables {
margin-top: 20px;
padding: 12px 16px;
}

View file

@ -0,0 +1,323 @@
import React, { Fragment, useState } from 'react';
import Alert from '@material-ui/lab/Alert';
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
import Button from '@material-ui/core/Button';
import classNames from 'classnames';
import CloseIcon from '@material-ui/icons/Close';
import IconButton from '@material-ui/core/IconButton';
import InfoIcon from '@material-ui/icons/Info';
import moment, { Moment } from 'moment';
import Paper from '@material-ui/core/Paper';
import Split from 'react-split';
import Snackbar from '@material-ui/core/Snackbar';
import Tooltip from '@material-ui/core/Tooltip';
import Typography from '@material-ui/core/Typography';
import { Block } from 'shared/Block';
import { formatDuration } from 'shared/utils/formatDuration';
import { grpc } from '@improbable-eng/grpc-web';
import { pilosa } from 'services/eventServices';
import { QBuilder } from './QBuilder';
import { queryPQL } from 'services/grpcServices';
import { QueryResults } from 'App/Query/QueryResults';
import { ResultType } from 'App/Query/QueryContainer';
import { RowResponse } from 'proto/pilosa_pb';
import { SavedQueries } from 'App/QueryBuilder/SavedQueries';
import { useEffectOnce } from 'react-use';
import css from './QBuilderContainer.module.scss';
let streamingResults: ResultType = {
query: '',
operation: '',
type: 'PQL',
headers: [],
rows: [],
roundtrip: 0,
error: ''
};
export const QBuilderContainer = () => {
let startTime: Moment;
let exportRows: any[] = [];
const colSizes = JSON.parse(
localStorage.getItem('builderColSizes') || '[25, 75]'
);
const [queriesList, setQueriesList] = useState(
JSON.parse(localStorage.getItem('saved-queries') || '[]')
);
const [tables, setTables] = useState<any[]>([]);
const [showBuilder, setShowBuilder] = useState<boolean>(true);
const [results, setResults] = useState<ResultType>();
const [fullCount, setFullCount] = useState<number>();
const [recordsCount, setRecordsCount] = useState<number>();
const [errorResult, setErrorResult] = useState<ResultType>();
const [error, setError] = useState<string>('');
const [loading, setLoading] = useState<boolean>(false);
const [savedQuery, setSavedQuery] = useState<number>(-1);
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 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 onRunQuery = (
table: string,
operation: string,
query: string,
countQuery?: string
) => {
streamingResults = {
query,
operation,
type: 'PQL',
headers: [],
rows: [],
index: table,
roundtrip: 0,
error: ''
};
startTime = moment();
setLoading(true);
if (operation !== 'Count') {
if (countQuery) {
pilosa.post.query(table, countQuery).then((res) => {
setFullCount(res.data.results[0]);
});
}
pilosa.post
.query(table, `Count(All())`)
.then((res) => setRecordsCount(res.data.results[0]));
} else {
setFullCount(undefined);
}
queryPQL(table, query, handleQueryMessages, handleQueryEnd);
};
const onClear = () => {
setResults(undefined);
setFullCount(undefined);
setErrorResult(undefined);
};
const onRemoveQuery = (queryIdx: number) => {
let queries = [...queriesList];
queries.splice(queryIdx, 1);
localStorage.setItem('saved-queries', JSON.stringify(queries));
setQueriesList(queries);
};
const onSaveQuery = () => {
const updatedList = JSON.parse(
localStorage.getItem('saved-queries') || '[]'
);
if (savedQuery < 0) {
setSavedQuery(updatedList.length - 1);
}
setQueriesList(updatedList);
};
const onExportLogs = () => {
if (results) {
const columns = results.rows.map((row) => row[0].uint64val);
const table = results.index ? results.index : '';
onExternalLookup(table, columns);
}
};
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 (
<Fragment>
{tables.length > 0 ? (
<Split
sizes={showBuilder ? colSizes : [0, 100]}
cursor="col-resize"
minSize={showBuilder ? 350 : 50}
onDragEnd={(sizes) =>
localStorage.setItem('builderColSizes', JSON.stringify(sizes))
}
gutter={(_index, direction) => {
const gutter = document.createElement('div');
gutter.className = `gutter gutter-${direction}`;
const dragbars = document.createElement('div');
dragbars.className = 'dragBar';
gutter.appendChild(dragbars);
return gutter;
}}
className={classNames(css.split, !showBuilder ? 'hide-gutter' : '')}
>
{showBuilder ? (
<div className={css.builderColumn}>
<div className={css.openClose}>
<IconButton onClick={() => setShowBuilder(false)} size="small">
<CloseIcon />
</IconButton>
</div>
<QBuilder
tables={tables}
savedQuery={savedQuery}
onRun={onRunQuery}
onClear={onClear}
onExitEdit={() => setSavedQuery(-1)}
onSaveQuery={onSaveQuery}
/>
</div>
) : (
<div className={css.collapsedBuilder}>
<IconButton onClick={() => setShowBuilder(true)} size="small">
<ArrowForwardIosIcon />
</IconButton>
</div>
)}
<div className={css.results}>
<Block className={css.resultsBlock}>
<div className={css.resultsHeader}>
<Typography variant="h5" color="textSecondary">
Results{' '}
</Typography>
{results?.query.includes('Extract(') ? (
<div className={css.download}>
<Tooltip
className={css.downloadInfo}
title="Download raw data from query results"
placement="top"
arrow
>
<InfoIcon fontSize="inherit" />
</Tooltip>
<Button onClick={onExportLogs}>Download</Button>
</div>
) : null}
</div>
{loading ? <div>Loading...</div> : null}
{results && !loading ? (
<Fragment>
{fullCount && recordsCount ? (
<div className={css.infoMessage}>
{results.duration ? (
<div>
{recordsCount.toLocaleString()} records scanned in{' '}
{formatDuration(results.duration, true)}.
</div>
) : null}
<div>
Showing{' '}
{fullCount > 1000 ? 'first 1,000 rows of' : 'all'}{' '}
{fullCount.toLocaleString()} results.
</div>
</div>
) : null}
<QueryResults results={results} />
<div className={css.spacer} />
</Fragment>
) : null}
{errorResult && !loading ? <div>{errorResult.error}</div> : null}
{!loading && !results && !error ? (
<Fragment>
<Typography variant="caption" color="textSecondary">
Build a query to see results
</Typography>
{queriesList.length > 0 ? (
<div className={css.savedQueries}>
<Typography variant="h5" color="textSecondary">
Saved Queries
</Typography>
<SavedQueries
queries={queriesList}
tables={tables}
onClickSaved={(queryIdx) => setSavedQuery(queryIdx)}
onRemoveQuery={onRemoveQuery}
/>
</div>
) : null}
</Fragment>
) : null}
</Block>
</div>
<Snackbar open={!!error}>
<Alert severity="info" onClose={() => setError('')}>
{error}
</Alert>
</Snackbar>
</Split>
) : (
<Block>
<Typography variant="h5" color="textSecondary">
Query Builder
</Typography>
<Paper className={css.noTables}>
<Typography variant="caption" color="textSecondary">
There are no tables to query.
</Typography>
</Paper>
</Block>
)}
</Fragment>
);
};

View file

@ -0,0 +1,52 @@
export const operators = {
decimal: [
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: '==', value: '=' },
{ label: '!=', value: '!=' },
],
int: [
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: '==', value: '=' },
{ label: '!=', value: '!=' },
],
'mutex-id': [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' }
],
'mutex-keys': [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' },
{ label: 'like', value: 'like' },
{ label: 'CIDR', value: 'cidr' }
],
"set-id": [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' }
],
"set-keys": [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' },
{ label: 'like', value: 'like' },
{ label: 'CIDR', value: 'cidr' }
],
"time-id": [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' }
],
"time-keys": [
{ label: 'is', value: '=' },
{ label: 'is not', value: '!=' },
{ label: 'like', value: 'like' }
],
timestamp: [
{ label: 'is before', value: '<' },
{ label: 'is after', value: '>' },
{ label: 'is', value: '=' },
]
}

View file

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

View file

@ -0,0 +1,40 @@
.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;
}
.filterHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.filterInfo {
vertical-align: text-top;
margin-left: 4px;
}
}

View file

@ -0,0 +1,180 @@
import React, { FC, Fragment, useState } from 'react';
import AddIcon from '@material-ui/icons/Add';
import Button from '@material-ui/core/Button';
import IconButton from '@material-ui/core/IconButton';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import {
groupOperators,
Operator,
RowCallType,
RowGrouping
} from 'App/QueryBuilder/rowTypes';
import { RowCall } from 'App/QueryBuilder/RowCall';
import css from './RowCallBuilder.module.scss';
type RowCallBuilderProps = {
rowCalls: RowGrouping[];
fields: any[];
showInvalid: boolean;
onChange: (
rows: RowGrouping[],
operator?: Operator,
hasInvalid?: boolean
) => void;
};
export const RowCallBuilder: FC<RowCallBuilderProps> = ({
rowCalls = [],
fields,
showInvalid,
onChange
}) => {
const [newOperatorEl, setNewOperatorEl] = useState<null | HTMLElement>(null);
const [operatorEl, setOperatorEl] = useState<null | HTMLElement>(null);
const [operator, setOperator] = useState<Operator>();
const onNewGroup = () => {
const newRow: RowCallType[] = [
{
field: '',
rowOperator: '=',
value: '',
type: 'set',
keys: true
}
];
onChange([...rowCalls, { row: newRow }]);
};
const onRemoveGroup = (groupIdx: number) => {
if (groupIdx === 0) {
onChange(rowCalls.slice(1));
} else {
const updatedRowCalls = [...rowCalls];
updatedRowCalls.splice(groupIdx, 1);
onChange(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);
}
onChange(updatedRowCalls);
};
return (
<Fragment>
{rowCalls?.map((rowCall, idx) => (
<Fragment key={`group-${idx}`}>
{idx > 0 ? (
<Fragment>
<div className={css.operator}>
<div className={css.line} />
<Button
className={css.addButton}
size="small"
onClick={(event) => setOperatorEl(event.currentTarget)}
>
{operator}
</Button>
<div className={css.line} />
</div>
<Menu
open={!!operatorEl}
anchorEl={operatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={`${idx}-${op}`}
onClick={() => {
setOperator(op);
setOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</Fragment>
) : null}
<RowCall
fields={fields}
rowData={rowCall.row}
isNot={rowCall.isNot}
operator={rowCall.operator}
showErrors={showInvalid}
onUpdate={(updatedRow, operator, isNot) =>
onUpdateRow(idx, updatedRow, operator, isNot)
}
onRemoveGroup={() => onRemoveGroup(idx)}
/>
</Fragment>
))}
<div className={css.newGroup}>
<div className={css.line} />
<IconButton
className={css.addButton}
size="small"
onClick={(event) => {
if (rowCalls.length === 0 || !!operator) {
onNewGroup();
} else {
setNewOperatorEl(event.currentTarget);
}
}}
>
<AddIcon fontSize="inherit" />
</IconButton>
<div className={css.line} />
</div>
<Menu
open={!!newOperatorEl}
anchorEl={newOperatorEl}
classes={{ paper: css.menuPaper }}
onClose={() => setNewOperatorEl(null)}
>
{groupOperators.map((op: Operator) => (
<MenuItem
key={`operator-${op}`}
onClick={() => {
setOperator(op);
onNewGroup();
setNewOperatorEl(null);
}}
>
{op}
</MenuItem>
))}
</Menu>
</Fragment>
);
};

View file

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

View file

@ -0,0 +1,2 @@
export * from './QBuilderContainer';
export * from './rowTypes';

View file

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

View file

@ -0,0 +1,174 @@
import { RowGrouping } from './rowTypes';
import { getIPRange } from 'get-ip-range';
export const stringifyExtract = (query: any) => {
const { columns, operator, rowCalls } = query;
const rowData = stringifyRowData(rowCalls, operator);
if (!rowData.error) {
const fields = columns.map((field) => `Rows(${field})`);
const allRows = fields.join(', ');
return {
error: false,
queryString: `Extract(Limit(${rowData.queryString}, limit=1000), ${allRows})`,
countQuery: `Count(${rowData.queryString})`
};
} else {
return { ...rowData, countQuery: '' };
}
}
export const stringifyCount = (query: any) => {
const { operator, rowCalls } = query;
const rowData = stringifyRowData(rowCalls, operator);
if (!rowData.error) {
return { error: false, queryString: `Count(${rowData.queryString})` };
} else {
return rowData;
}
}
const stringifyRowData = (rowCalls, operator) => {
let queryString = '';
if (rowCalls.length === 0) {
queryString = 'All()';
} else {
let rowsMap: string[][] = [];
rowCalls.forEach((group, groupIdx) => {
rowsMap.push([]);
group.row.forEach((row) => {
let rowString = '';
const { field, rowOperator, value, type, keys } = 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) => keys ? `Row(${field}="${v.trim()}")` : `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) {
return { error: true, query: error.message };
}
} else if (rowOperator === 'like') {
if (value.includes('%') || value.includes('_')) {
rowString = `UnionRows(Rows(field=${field}, like="${value}"))`;
} else {
rowString = `UnionRows(Rows(field=${field}, like="%${value}%"))`;
}
} else {
rowString = keys || type === 'timestamp'
? `Row(${field}${operator}"${value}")`
: `Row(${field}${operator}${value})`;
}
if (isNegatory) {
rowsMap[groupIdx].push(`Not(${rowString})`);
} else {
rowsMap[groupIdx].push(rowString);
}
});
});
queryString = rowsMap
.map((group, idx) => {
let joined = '';
if (group.length > 1) {
joined = group.map((r) => r).join(', ');
const operator = rowCalls[idx].operator;
if (operator === 'and') {
joined = `Intersect(${joined})`;
} else if (operator === 'or') {
joined = `Union(${joined})`;
}
} else {
joined = group[0];
}
return rowCalls[idx].isNot ? `Not(${joined})` : joined;
})
.join(', ');
if (rowCalls.length > 1 && operator) {
if (operator === 'and') {
queryString = `Intersect(${queryString})`;
} else if (operator === 'or') {
queryString = `Union(${queryString})`;
}
}
}
return { error: false, queryString };
}
export const stringifyGroupBy = (query: any) => {
const { groupByCall, filter, sort } = query;
let sortString = '';
let aggregateString = '';
if (sort?.length > 0) {
sortString = sort[0].sortValue;
if (sort[0].sortValue.includes('sum')) {
aggregateString = `, aggregate=Sum(field=${sort[0].field})`;
}
if (sort.length > 1) {
sortString = `${sortString}, ${sort[1].sortValue}`;
if (sort[1].sortValue.includes('sum')) {
aggregateString = `, aggregate=Sum(field=${sort[1].field})`;
}
}
sortString = `, sort="${sortString}"`;
}
const filterString = filter ? `, filter=${filter}` : '';
const queryString = groupByCall.secondary
? `GroupBy(Rows(${groupByCall.primary}), Rows(${groupByCall.secondary})${filterString}${sortString}${aggregateString})`
: `GroupBy(Rows(${groupByCall.primary})${filterString}${sortString}${aggregateString})`;
return queryString;
}
export const cleanupRows = (rowCalls: RowGrouping[]) => {
// remove empty groups and row calls
let isInvalid = false;
let cleanRowCalls: RowGrouping[] = [];
let cleanGroups: RowGrouping[] = [];
if (rowCalls?.length > 0) {
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 };
};