Skip to content

Commit

Permalink
add support for aggregations on quickwit ui (#4974)
Browse files Browse the repository at this point in the history
* make monaco editor respond to window resize

fix #3223

* add selector for aggregation

* support sending aggregations

* show aggregation results

* fix formating

* fix crash on empty result

* try to make typing experience better

* codestyle and fix typo

* use 'in' instead of hasOwn

* fix most errors

use FormControl properly

don't use lists, or set a key for each element

change casing of some css properties

* remove listener

* Move jest mocks to dedicated folder

* Add x-charts LineChart mock

* Remove the auto aggregation featur, put 1day by default instead

* Remove console logs and add camelCase

* Run search and update UI when changing search tab

* Fix the render side-effect by using the useEffect feature

* Do not re run search in each aggregation change. It can be too heavy for user. It's better to clic on Run

---------

Co-authored-by: Damien de Lemeny <[email protected]>
Co-authored-by: JulesVautier-io <[email protected]>
Co-authored-by: François Massot <[email protected]>
  • Loading branch information
4 people authored Jun 20, 2024
1 parent 511e1dd commit 22dea2d
Show file tree
Hide file tree
Showing 17 changed files with 937 additions and 38 deletions.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions quickwit/quickwit-ui/mocks/x-charts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const LineChart = ({ children }) => children;
6 changes: 4 additions & 2 deletions quickwit/quickwit-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@mui/lab": "^5.0.0-alpha.114",
"@mui/material": "^5.11.2",
"@mui/system": "^5.11.2",
"@mui/x-charts": "^7.3.2",
"@mui/x-date-pickers": "^5.0.12",
"@types/jest": "^29.5.6",
"@types/node": "^20.8.7",
Expand Down Expand Up @@ -78,8 +79,9 @@
},
"jest": {
"moduleNameMapper": {
"monaco-editor": "<rootDir>/monacoMock.js",
"swagger-ui-react": "<rootDir>/swaggerUIMock.js"
"monaco-editor": "<rootDir>/mocks/monacoMock.js",
"swagger-ui-react": "<rootDir>/mocks/swaggerUIMock.js",
"@mui/x-charts": "<rootDir>/mocks/x-charts.js"
}
}
}
1 change: 1 addition & 0 deletions quickwit/quickwit-ui/src/components/ApiUrlFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default function ApiUrlFooter(url: string) {
const origin = process.env.NODE_ENV === 'development' ? 'http://localhost:7280' : window.location.origin;
const completeUrl = `${origin}/${url}`;
const isTooLong = completeUrl.length > urlMaxLength;
// TODO show generated aggregation
return <Footer>
<Typography sx={{ padding: '4px 5px', fontSize: '0.95em'}}>
API URL:
Expand Down
21 changes: 19 additions & 2 deletions quickwit/quickwit-ui/src/components/QueryActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

import { Box, Button } from "@mui/material";
import { Box, Button, Tabs, Tab } from "@mui/material";
import { TimeRangeSelect } from './TimeRangeSelect';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { SearchComponentProps } from "../utils/SearchComponentProps";

export function QueryEditorActionBar(props: SearchComponentProps) {
const timestamp_field_name = props.index?.metadata.index_config.doc_mapping.timestamp_field;
const shouldDisplayTimeRangeSelect = timestamp_field_name ?? false;

const handleChange = (_event: React.SyntheticEvent, newTab: number) => {
const updatedSearchRequest = {...props.searchRequest, aggregation: newTab != 0};
props.onSearchRequestUpdate(updatedSearchRequest);
props.runSearch(updatedSearchRequest)
};

return (
<Box sx={{ display: 'flex'}}>
<Box sx={{ flexGrow: 1 }}>
<Box sx={{ flexGrow: 0, padding: '10px' }}>
<Button
onClick={() => props.runSearch(props.searchRequest)}
variant="contained"
Expand All @@ -38,6 +45,16 @@ export function QueryEditorActionBar(props: SearchComponentProps) {
Run
</Button>
</Box>
<Box sx={{ flexGrow: 0 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', flexGrow: 1}}>
<Tabs value={Number(props.searchRequest.aggregation)} onChange={handleChange}>
<Tab label="Search"/>
<Tab label="Aggregation"/>
</Tabs>
</Box>
</Box>
<Box sx={{ flexGrow: 1 }}>
</Box>
{ shouldDisplayTimeRangeSelect && <TimeRangeSelect
timeRange={{
startTimestamp:props.searchRequest.startTimestamp,
Expand Down
315 changes: 315 additions & 0 deletions quickwit/quickwit-ui/src/components/QueryEditor/AggregationEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
// Copyright (C) 2024 Quickwit, Inc.
//
// Quickwit is offered under the AGPL v3.0 and as commercial software.
// For commercial licensing, contact us at [email protected].
//
// AGPL:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

import { useRef, useEffect, useState } from 'react';
import { SearchComponentProps } from '../../utils/SearchComponentProps';
import { TermAgg, HistogramAgg } from '../../utils/models';
import { Box } from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import TextField from '@mui/material/TextField';

export function AggregationEditor(props: SearchComponentProps) {
return (
<Box hidden={!props.searchRequest.aggregation}>
<MetricKind
searchRequest={props.searchRequest}
onSearchRequestUpdate={props.onSearchRequestUpdate}
runSearch={props.runSearch}
index={props.index}
queryRunning={props.queryRunning} />
<AggregationKind
searchRequest={props.searchRequest}
onSearchRequestUpdate={props.onSearchRequestUpdate}
runSearch={props.runSearch}
index={props.index}
queryRunning={props.queryRunning} />
</Box>
)
}

export function MetricKind(props: SearchComponentProps) {
// TODO add percentiles
const metricRef = useRef(props.searchRequest.aggregationConfig.metric);

const handleTypeChange = (event: SelectChangeEvent) => {
const value = event.target.value;
const updatedMetric = value != "count" ? {...metricRef.current!, type: value} : null;
const updatedAggregation = {...props.searchRequest.aggregationConfig, metric: updatedMetric};
const updatedSearchRequest = {...props.searchRequest, aggregationConfig: updatedAggregation};
props.onSearchRequestUpdate(updatedSearchRequest);
metricRef.current = updatedMetric;
};

const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
if (metricRef.current == null) {
return;
}
const updatedMetric = {...metricRef.current!, field: value};
const updatedAggregation = {...props.searchRequest.aggregationConfig, metric: updatedMetric};
const updatedSearchRequest = {...props.searchRequest, aggregationConfig: updatedAggregation};
props.onSearchRequestUpdate(updatedSearchRequest);
metricRef.current = updatedMetric;
};

return (
<Box sx={{ m: 1, minWidth: 120, display: 'flex', flexDirection: 'row', }}>
<FormControl variant="standard">
<Select
value={metricRef.current ? metricRef.current.type : "count"}
onChange={handleTypeChange}
sx={{ "minHeight": "44px" }}
>
<MenuItem value="count">Count</MenuItem>
<MenuItem value="avg">Average</MenuItem>
<MenuItem value="sum">Sum</MenuItem>
<MenuItem value="max">Max</MenuItem>
<MenuItem value="min">Min</MenuItem>
</Select>
</FormControl>
<FormControl variant="standard">
<TextField
variant="standard"
label="Field"
onChange={handleNameChange}
sx={{ "marginLeft": "10px", ... ( !metricRef.current && {display: "none"}) }}
/>
</FormControl>
</Box>
)
}

export function AggregationKind(props: SearchComponentProps) {
props;
const defaultAgg = {
histogram: {
interval: "1d",
}
};
const [aggregations, setAggregations] = useState<({term: TermAgg} | {histogram: HistogramAgg})[]>(
[defaultAgg]);

useEffect(() => {
// do the initial filling of parameters
const aggregationConfig = props.searchRequest.aggregationConfig;
if (aggregationConfig.histogram === null && aggregationConfig.term === null) {
const initialAggregation = Object.assign({}, ...aggregations);
const initialSearchRequest = {...props.searchRequest, aggregationConfig: initialAggregation};
props.onSearchRequestUpdate(initialSearchRequest);
}
}, []); // Empty dependency array means this runs once after mount

const updateAggregationProp = (newAggregations: ({term: TermAgg} | {histogram: HistogramAgg})[]) => {
const metric = props.searchRequest.aggregationConfig.metric;
const updatedAggregation = Object.assign({}, {metric: metric}, ...newAggregations);
const updatedSearchRequest = {...props.searchRequest, aggregationConfig: updatedAggregation};
props.onSearchRequestUpdate(updatedSearchRequest);
};

const handleAggregationChange = (pos: number, event: SelectChangeEvent) => {
const value = event.target.value;
setAggregations((agg) => {
const newAggregations = [...agg];
switch(value) {
case "histogram": {
newAggregations[pos] = {
histogram: {
interval: "1d",
}
};
break;
}
case "term": {
newAggregations[pos] = {
term: {
field: "",
size: 10,
}
};
break;
}
case "rm": {
newAggregations.splice(pos, 1);
}
}
updateAggregationProp(newAggregations);
return newAggregations;
});
};

const handleHistogramChange = (pos: number, event: SelectChangeEvent) => {
const value = event.target.value;
setAggregations((agg) => {
const newAggregations = [...agg];
newAggregations[pos] = {histogram: {interval:value}};
updateAggregationProp(newAggregations);
return newAggregations;
});
}

const handleTermFieldChange = (pos: number, event: React.ChangeEvent<HTMLInputElement|HTMLTextAreaElement>) => {
const value = event.target.value;
setAggregations((agg) => {
const newAggregations = [...agg];
const term = newAggregations[pos]
if (isTerm(term)) {
term.term.field = value;
}
updateAggregationProp(newAggregations);
return newAggregations;
});
};

const handleTermCountChange = (pos: number, event: React.ChangeEvent<HTMLInputElement|HTMLTextAreaElement>) => {
const value = event.target.value;
setAggregations((agg) => {
const newAggregations = [...agg];
const term = newAggregations[pos]
if (isTerm(term)) {
term.term.size = Number(value);
}
updateAggregationProp(newAggregations);
return newAggregations;
});
};

function isHistogram(agg: {term: TermAgg} | {histogram: HistogramAgg} | undefined): agg is {histogram: HistogramAgg} {
if (!agg) return false;
return "histogram" in agg;
}

function isTerm(agg: {term: TermAgg} | {histogram: HistogramAgg} | undefined): agg is {term: TermAgg} {
if (!agg) return false;
return "term" in agg;
}

const getAggregationKind = (agg: {term: TermAgg} | {histogram: HistogramAgg} | undefined) => {
if (isHistogram(agg)) {
return "histogram";
}
if (isTerm(agg)) {
return "term";
}
return "new";
};

const makeOptions = (pos: number, agg: ({term: TermAgg} | {histogram: HistogramAgg})[]) => {
const options = [];
if (pos >= agg.length) {
options.push((
<MenuItem value="new" key="new">Add aggregation</MenuItem>
))
}
let addHistogram = true;
let addTerm = true;
for(let i = 0; i < agg.length; i++) {
if (i == pos) continue;
if (getAggregationKind(agg[i]) === "histogram") addHistogram = false;
if (getAggregationKind(agg[i]) === "term") addTerm = false;
}
if (addHistogram) {
options.push((<MenuItem value="histogram" key="histogram">Histogram aggregation</MenuItem>))
}
if (addTerm) {
options.push((<MenuItem value="term" key="term">Term aggregation</MenuItem>));
}
if (agg.length > 1) {
options.push((
<MenuItem value="rm" key="rm">Remove aggregation</MenuItem>
))
}
return options;
}

const drawAdditional = (pos: number, aggs: ({term: TermAgg} | {histogram: HistogramAgg})[]) => {
const agg = aggs[pos]
if (isHistogram(agg)) {
return (
<FormControl variant="standard">
<Select
value={agg.histogram.interval}
onChange={(e) => handleHistogramChange(pos, e)}
sx={{ "marginLeft": "10px", "minHeight": "44px" }}
>
<MenuItem value="10s">10 seconds</MenuItem>
<MenuItem value="1m">1 minute</MenuItem>
<MenuItem value="5m">5 minutes</MenuItem>
<MenuItem value="10m">10 minutes</MenuItem>
<MenuItem value="1h">1 hour</MenuItem>
<MenuItem value="1d">1 day</MenuItem>
</Select>
</FormControl>
);
}
if (isTerm(agg)) {
return (<>
<FormControl variant="standard">
<TextField
variant="standard"
label="Field"
onChange={(e) => handleTermFieldChange(pos, e)}
sx={{ "marginLeft": "10px" }}
/>
</FormControl>
<FormControl variant="standard">
<TextField
variant="standard"
label="Return top"
type="number"
onChange={(e) => handleTermCountChange(pos, e)}
value={agg.term.size}
sx={{ "marginLeft": "10px" }}
/>
</FormControl>
</>)
}
return (null);
}

return (
<>
<Box sx={{ m: 1, minWidth: 120, display: 'flex', flexDirection: 'row', }}>
<FormControl variant="standard">
<Select
value={getAggregationKind(aggregations[0])}
onChange={(e) => handleAggregationChange(0, e)}
sx={{ "minHeight": "44px", width: "190px" }}
>
{ makeOptions(0, aggregations) }
</Select>
</FormControl>
{drawAdditional(0, aggregations)}
</Box>
<Box sx={{ m: 1, minWidth: 120, display: 'flex', flexDirection: 'row', }}>
<FormControl variant="standard" sx={{ m: 1, minWidth: 120, display: 'flex', flexDirection: 'row', }}>
<Select
value={getAggregationKind(aggregations[1])}
onChange={(e) => handleAggregationChange(1, e)}
sx={{ "minHeight": "44px", width: "190px" }}
>
{ makeOptions(1, aggregations) }
</Select>
{drawAdditional(1, aggregations)}
</FormControl>
</Box>
</>
)
}
Loading

0 comments on commit 22dea2d

Please sign in to comment.