-
Notifications
You must be signed in to change notification settings - Fork 350
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add support for aggregations on quickwit ui (#4974)
* 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
1 parent
511e1dd
commit 22dea2d
Showing
17 changed files
with
937 additions
and
38 deletions.
There are no files selected for viewing
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const LineChart = ({ children }) => children; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
315 changes: 315 additions & 0 deletions
315
quickwit/quickwit-ui/src/components/QueryEditor/AggregationEditor.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
</> | ||
) | ||
} |
Oops, something went wrong.