Add expression explorer (Closes #8211) (#8404)

* Add expression explorer

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Add final new line to all files

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Rename expression to metric

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Pass dedicated metrics array to metrics explorer

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix styling of button

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Use append instead of prepend

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Update max width of modal

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix code style

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix inconsistent variable naming

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix modal title

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix tests

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Prevent request from being cached

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Remove timestamp from request

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Update button selector in test

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Refactor passing down metric names and query history

Signed-off-by: Lucas Hild <git@lucas-hild.de>

* Fix code style

Signed-off-by: Lucas Hild <git@lucas-hild.de>
This commit is contained in:
Lucas Hild 2021-02-19 23:42:20 +01:00 committed by GitHub
parent f4bf9df4ec
commit d35cf369f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 190 additions and 96 deletions

View file

@ -19,10 +19,8 @@ describe('ExpressionInput', () => {
const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total']; const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total'];
const expressionInputProps = { const expressionInputProps = {
value: 'node_cpu', value: 'node_cpu',
autocompleteSections: { queryHistory: [],
'Query History': [], metricNames,
'Metric Names': metricNames,
},
executeQuery: (): void => { executeQuery: (): void => {
// Do nothing. // Do nothing.
}, },
@ -133,14 +131,16 @@ describe('ExpressionInput', () => {
describe('handleKeyPress', () => { describe('handleKeyPress', () => {
it('should call executeQuery on Enter key pressed', () => { it('should call executeQuery on Enter key pressed', () => {
const spyExecuteQuery = jest.fn(); const spyExecuteQuery = jest.fn();
const input = mount(<ExpressionInput executeQuery={spyExecuteQuery} {...({} as any)} />); const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance(); const instance: any = input.instance();
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter' }); instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter' });
expect(spyExecuteQuery).toHaveBeenCalled(); expect(spyExecuteQuery).toHaveBeenCalled();
}); });
it('should NOT call executeQuery on Enter + Shift', () => { it('should NOT call executeQuery on Enter + Shift', () => {
const spyExecuteQuery = jest.fn(); const spyExecuteQuery = jest.fn();
const input = mount(<ExpressionInput executeQuery={spyExecuteQuery} {...({} as any)} />); const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance(); const instance: any = input.instance();
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter', shiftKey: true }); instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter', shiftKey: true });
expect(spyExecuteQuery).not.toHaveBeenCalled(); expect(spyExecuteQuery).not.toHaveBeenCalled();
@ -159,8 +159,13 @@ describe('ExpressionInput', () => {
}); });
describe('createAutocompleteSection', () => { describe('createAutocompleteSection', () => {
const props = {
...expressionInputProps,
metricNames: ['foo', 'bar', 'baz'],
};
it('should close menu if no matches found', () => { it('should close menu if no matches found', () => {
const input = mount(<ExpressionInput autocompleteSections={{ title: ['foo', 'bar', 'baz'] }} {...({} as any)} />); const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance(); const instance: any = input.instance();
const spyCloseMenu = jest.fn(); const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ inputValue: 'qqqqqq', closeMenu: spyCloseMenu }); instance.createAutocompleteSection({ inputValue: 'qqqqqq', closeMenu: spyCloseMenu });
@ -168,34 +173,22 @@ describe('ExpressionInput', () => {
expect(spyCloseMenu).toHaveBeenCalled(); expect(spyCloseMenu).toHaveBeenCalled();
}); });
}); });
it('should not render lsit if inputValue not exist', () => { it('should not render list if inputValue not exist', () => {
const input = mount(<ExpressionInput autocompleteSections={{ title: ['foo', 'bar', 'baz'] }} {...({} as any)} />); const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance(); const instance: any = input.instance();
const spyCloseMenu = jest.fn(); const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ closeMenu: spyCloseMenu }); instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled()); setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
}); });
it('should not render list if enableAutocomplete is false', () => { it('should not render list if enableAutocomplete is false', () => {
const input = mount( const input = mount(<ExpressionInput {...props} enableAutocomplete={false} />);
<ExpressionInput
autocompleteSections={{ title: ['foo', 'bar', 'baz'] }}
{...({} as any)}
enableAutocomplete={false}
/>
);
const instance: any = input.instance(); const instance: any = input.instance();
const spyCloseMenu = jest.fn(); const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ closeMenu: spyCloseMenu }); instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled()); setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
}); });
it('should render autosuggest-dropdown', () => { it('should render autosuggest-dropdown', () => {
const input = mount( const input = mount(<ExpressionInput {...props} enableAutocomplete={true} />);
<ExpressionInput
autocompleteSections={{ title: ['foo', 'bar', 'baz'] }}
{...({} as any)}
enableAutocomplete={true}
/>
);
const instance: any = input.instance(); const instance: any = input.instance();
const spyGetMenuProps = jest.fn(); const spyGetMenuProps = jest.fn();
const sections = instance.createAutocompleteSection({ const sections = instance.createAutocompleteSection({
@ -264,8 +257,10 @@ describe('ExpressionInput', () => {
it('renders an execute Button', () => { it('renders an execute Button', () => {
const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'append'); const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'append');
const button = addon.find(Button); const button = addon
expect(button.prop('className')).toEqual('execute-btn'); .find(Button)
.find('.execute-btn')
.first();
expect(button.prop('color')).toEqual('primary'); expect(button.prop('color')).toEqual('primary');
expect(button.text()).toEqual('Execute'); expect(button.text()).toEqual('Execute');
}); });

View file

@ -6,12 +6,14 @@ import fuzzy from 'fuzzy';
import sanitizeHTML from 'sanitize-html'; import sanitizeHTML from 'sanitize-html';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons';
import MetricsExplorer from './MetricsExplorer';
interface ExpressionInputProps { interface ExpressionInputProps {
value: string; value: string;
onExpressionChange: (expr: string) => void; onExpressionChange: (expr: string) => void;
autocompleteSections: { [key: string]: string[] }; queryHistory: string[];
metricNames: string[];
executeQuery: () => void; executeQuery: () => void;
loading: boolean; loading: boolean;
enableAutocomplete: boolean; enableAutocomplete: boolean;
@ -19,6 +21,7 @@ interface ExpressionInputProps {
interface ExpressionInputState { interface ExpressionInputState {
height: number | string; height: number | string;
showMetricsExplorer: boolean;
} }
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> { class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
@ -28,6 +31,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
super(props); super(props);
this.state = { this.state = {
height: 'auto', height: 'auto',
showMetricsExplorer: false,
}; };
} }
@ -75,7 +79,10 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => { createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => {
const { inputValue = '', closeMenu, highlightedIndex } = downshift; const { inputValue = '', closeMenu, highlightedIndex } = downshift;
const { autocompleteSections } = this.props; const autocompleteSections = {
'Query History': this.props.queryHistory,
'Metric Names': this.props.metricNames,
};
let index = 0; let index = 0;
const sections = const sections =
inputValue!.length && this.props.enableAutocomplete inputValue!.length && this.props.enableAutocomplete
@ -125,10 +132,41 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
); );
}; };
openMetricsExplorer = () => {
this.setState({
showMetricsExplorer: true,
});
};
updateShowMetricsExplorer = (show: boolean) => {
this.setState({
showMetricsExplorer: show,
});
};
insertAtCursor = (value: string) => {
if (!this.exprInputRef.current) return;
const startPosition = this.exprInputRef.current.selectionStart;
const endPosition = this.exprInputRef.current.selectionEnd;
const previousValue = this.exprInputRef.current.value;
let newValue: string;
if (startPosition && endPosition) {
newValue =
previousValue.substring(0, startPosition) + value + previousValue.substring(endPosition, previousValue.length);
} else {
newValue = previousValue + value;
}
this.setValue(newValue);
};
render() { render() {
const { executeQuery, value } = this.props; const { executeQuery, value } = this.props;
const { height } = this.state; const { height } = this.state;
return ( return (
<>
<Downshift onSelect={this.setValue}> <Downshift onSelect={this.setValue}>
{downshift => ( {downshift => (
<div> <div>
@ -176,6 +214,11 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
} as any)} } as any)}
value={value} value={value}
/> />
<InputGroupAddon addonType="append">
<Button className="btn-light border" title="Open metrics explorer" onClick={this.openMetricsExplorer}>
<FontAwesomeIcon icon={faGlobeEurope} />
</Button>
</InputGroupAddon>
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button className="execute-btn" color="primary" onClick={executeQuery}> <Button className="execute-btn" color="primary" onClick={executeQuery}>
Execute Execute
@ -186,6 +229,14 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
</div> </div>
)} )}
</Downshift> </Downshift>
<MetricsExplorer
show={this.state.showMetricsExplorer}
updateShow={this.updateShowMetricsExplorer}
metrics={this.props.metricNames}
insertAtCursor={this.insertAtCursor}
/>
</>
); );
} }
} }

View file

@ -0,0 +1,14 @@
.metrics-explorer.modal-dialog {
max-width: 750px;
overflow-wrap: break-word;
}
.metrics-explorer .metric {
cursor: pointer;
margin: 0;
padding: 5px;
}
.metrics-explorer .metric:hover {
background: #efefef;
}

View file

@ -0,0 +1,38 @@
import React, { Component } from 'react';
import { Modal, ModalBody, ModalHeader } from 'reactstrap';
import './MetricsExplorer.css';
interface Props {
show: boolean;
updateShow(show: boolean): void;
metrics: string[];
insertAtCursor(value: string): void;
}
class MetricsExplorer extends Component<Props, {}> {
handleMetricClick = (query: string) => {
this.props.insertAtCursor(query);
this.props.updateShow(false);
};
toggle = () => {
this.props.updateShow(!this.props.show);
};
render() {
return (
<Modal isOpen={this.props.show} toggle={this.toggle} className="metrics-explorer">
<ModalHeader toggle={this.toggle}>Metrics Explorer</ModalHeader>
<ModalBody>
{this.props.metrics.map(metric => (
<p className="metric" key="metric" onClick={this.handleMetricClick.bind(this, metric)}>
{metric}
</p>
))}
</ModalBody>
</Modal>
);
}
}
export default MetricsExplorer;

View file

@ -41,14 +41,12 @@ describe('Panel', () => {
it('renders an ExpressionInput', () => { it('renders an ExpressionInput', () => {
const input = panel.find(ExpressionInput); const input = panel.find(ExpressionInput);
expect(input.prop('value')).toEqual('prometheus_engine'); expect(input.prop('value')).toEqual('prometheus_engine');
expect(input.prop('autocompleteSections')).toEqual({ expect(input.prop('metricNames')).toEqual([
'Metric Names': [
'prometheus_engine_queries', 'prometheus_engine_queries',
'prometheus_engine_queries_concurrent_max', 'prometheus_engine_queries_concurrent_max',
'prometheus_engine_query_duration_seconds', 'prometheus_engine_query_duration_seconds',
], ]);
'Query History': [], expect(input.prop('queryHistory')).toEqual([]);
});
}); });
it('renders NavLinks', () => { it('renders NavLinks', () => {

View file

@ -238,10 +238,8 @@ class Panel extends Component<PanelProps, PanelState> {
executeQuery={this.executeQuery} executeQuery={this.executeQuery}
loading={this.state.loading} loading={this.state.loading}
enableAutocomplete={this.props.enableAutocomplete} enableAutocomplete={this.props.enableAutocomplete}
autocompleteSections={{ queryHistory={pastQueries}
'Query History': pastQueries, metricNames={metricNames}
'Metric Names': metricNames,
}}
/> />
</Col> </Col>
</Row> </Row>