mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -08:00
* 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:
parent
f4bf9df4ec
commit
d35cf369f2
|
@ -19,10 +19,8 @@ describe('ExpressionInput', () => {
|
|||
const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total'];
|
||||
const expressionInputProps = {
|
||||
value: 'node_cpu',
|
||||
autocompleteSections: {
|
||||
'Query History': [],
|
||||
'Metric Names': metricNames,
|
||||
},
|
||||
queryHistory: [],
|
||||
metricNames,
|
||||
executeQuery: (): void => {
|
||||
// Do nothing.
|
||||
},
|
||||
|
@ -133,14 +131,16 @@ describe('ExpressionInput', () => {
|
|||
describe('handleKeyPress', () => {
|
||||
it('should call executeQuery on Enter key pressed', () => {
|
||||
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();
|
||||
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter' });
|
||||
expect(spyExecuteQuery).toHaveBeenCalled();
|
||||
});
|
||||
it('should NOT call executeQuery on Enter + Shift', () => {
|
||||
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();
|
||||
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter', shiftKey: true });
|
||||
expect(spyExecuteQuery).not.toHaveBeenCalled();
|
||||
|
@ -159,8 +159,13 @@ describe('ExpressionInput', () => {
|
|||
});
|
||||
|
||||
describe('createAutocompleteSection', () => {
|
||||
const props = {
|
||||
...expressionInputProps,
|
||||
metricNames: ['foo', 'bar', 'baz'],
|
||||
};
|
||||
|
||||
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 spyCloseMenu = jest.fn();
|
||||
instance.createAutocompleteSection({ inputValue: 'qqqqqq', closeMenu: spyCloseMenu });
|
||||
|
@ -168,34 +173,22 @@ describe('ExpressionInput', () => {
|
|||
expect(spyCloseMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it('should not render lsit if inputValue not exist', () => {
|
||||
const input = mount(<ExpressionInput autocompleteSections={{ title: ['foo', 'bar', 'baz'] }} {...({} as any)} />);
|
||||
it('should not render list if inputValue not exist', () => {
|
||||
const input = mount(<ExpressionInput {...props} />);
|
||||
const instance: any = input.instance();
|
||||
const spyCloseMenu = jest.fn();
|
||||
instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
|
||||
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
|
||||
});
|
||||
it('should not render list if enableAutocomplete is false', () => {
|
||||
const input = mount(
|
||||
<ExpressionInput
|
||||
autocompleteSections={{ title: ['foo', 'bar', 'baz'] }}
|
||||
{...({} as any)}
|
||||
enableAutocomplete={false}
|
||||
/>
|
||||
);
|
||||
const input = mount(<ExpressionInput {...props} enableAutocomplete={false} />);
|
||||
const instance: any = input.instance();
|
||||
const spyCloseMenu = jest.fn();
|
||||
instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
|
||||
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
|
||||
});
|
||||
it('should render autosuggest-dropdown', () => {
|
||||
const input = mount(
|
||||
<ExpressionInput
|
||||
autocompleteSections={{ title: ['foo', 'bar', 'baz'] }}
|
||||
{...({} as any)}
|
||||
enableAutocomplete={true}
|
||||
/>
|
||||
);
|
||||
const input = mount(<ExpressionInput {...props} enableAutocomplete={true} />);
|
||||
const instance: any = input.instance();
|
||||
const spyGetMenuProps = jest.fn();
|
||||
const sections = instance.createAutocompleteSection({
|
||||
|
@ -264,8 +257,10 @@ describe('ExpressionInput', () => {
|
|||
|
||||
it('renders an execute Button', () => {
|
||||
const addon = expressionInput.find(InputGroupAddon).filterWhere(addon => addon.prop('addonType') === 'append');
|
||||
const button = addon.find(Button);
|
||||
expect(button.prop('className')).toEqual('execute-btn');
|
||||
const button = addon
|
||||
.find(Button)
|
||||
.find('.execute-btn')
|
||||
.first();
|
||||
expect(button.prop('color')).toEqual('primary');
|
||||
expect(button.text()).toEqual('Execute');
|
||||
});
|
||||
|
|
|
@ -6,12 +6,14 @@ import fuzzy from 'fuzzy';
|
|||
import sanitizeHTML from 'sanitize-html';
|
||||
|
||||
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 {
|
||||
value: string;
|
||||
onExpressionChange: (expr: string) => void;
|
||||
autocompleteSections: { [key: string]: string[] };
|
||||
queryHistory: string[];
|
||||
metricNames: string[];
|
||||
executeQuery: () => void;
|
||||
loading: boolean;
|
||||
enableAutocomplete: boolean;
|
||||
|
@ -19,6 +21,7 @@ interface ExpressionInputProps {
|
|||
|
||||
interface ExpressionInputState {
|
||||
height: number | string;
|
||||
showMetricsExplorer: boolean;
|
||||
}
|
||||
|
||||
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
|
||||
|
@ -28,6 +31,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
|||
super(props);
|
||||
this.state = {
|
||||
height: 'auto',
|
||||
showMetricsExplorer: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -75,7 +79,10 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
|
|||
|
||||
createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => {
|
||||
const { inputValue = '', closeMenu, highlightedIndex } = downshift;
|
||||
const { autocompleteSections } = this.props;
|
||||
const autocompleteSections = {
|
||||
'Query History': this.props.queryHistory,
|
||||
'Metric Names': this.props.metricNames,
|
||||
};
|
||||
let index = 0;
|
||||
const sections =
|
||||
inputValue!.length && this.props.enableAutocomplete
|
||||
|
@ -125,67 +132,111 @@ 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() {
|
||||
const { executeQuery, value } = this.props;
|
||||
const { height } = this.state;
|
||||
return (
|
||||
<Downshift onSelect={this.setValue}>
|
||||
{downshift => (
|
||||
<div>
|
||||
<InputGroup className="expression-input">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<InputGroupText>
|
||||
{this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<Input
|
||||
onInput={this.handleInput}
|
||||
style={{ height }}
|
||||
autoFocus
|
||||
type="textarea"
|
||||
rows="1"
|
||||
onKeyPress={this.handleKeyPress}
|
||||
placeholder="Expression (press Shift+Enter for newlines)"
|
||||
innerRef={this.exprInputRef}
|
||||
{...downshift.getInputProps({
|
||||
onKeyDown: (event: React.KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'Home':
|
||||
case 'End':
|
||||
// We want to be able to jump to the beginning/end of the input field.
|
||||
// By default, Downshift otherwise jumps to the first/last suggestion item instead.
|
||||
(event.nativeEvent as any).preventDownshiftDefault = true;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
if (!downshift.isOpen) {
|
||||
<>
|
||||
<Downshift onSelect={this.setValue}>
|
||||
{downshift => (
|
||||
<div>
|
||||
<InputGroup className="expression-input">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<InputGroupText>
|
||||
{this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<Input
|
||||
onInput={this.handleInput}
|
||||
style={{ height }}
|
||||
autoFocus
|
||||
type="textarea"
|
||||
rows="1"
|
||||
onKeyPress={this.handleKeyPress}
|
||||
placeholder="Expression (press Shift+Enter for newlines)"
|
||||
innerRef={this.exprInputRef}
|
||||
{...downshift.getInputProps({
|
||||
onKeyDown: (event: React.KeyboardEvent): void => {
|
||||
switch (event.key) {
|
||||
case 'Home':
|
||||
case 'End':
|
||||
// We want to be able to jump to the beginning/end of the input field.
|
||||
// By default, Downshift otherwise jumps to the first/last suggestion item instead.
|
||||
(event.nativeEvent as any).preventDownshiftDefault = true;
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
downshift.closeMenu();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (!downshift.isOpen) {
|
||||
this.exprInputRef.current!.blur();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
} as any)}
|
||||
value={value}
|
||||
/>
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button className="execute-btn" color="primary" onClick={executeQuery}>
|
||||
Execute
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{downshift.isOpen && this.createAutocompleteSection(downshift)}
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
if (!downshift.isOpen) {
|
||||
(event.nativeEvent as any).preventDownshiftDefault = true;
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
downshift.closeMenu();
|
||||
break;
|
||||
case 'Escape':
|
||||
if (!downshift.isOpen) {
|
||||
this.exprInputRef.current!.blur();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
},
|
||||
} as any)}
|
||||
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">
|
||||
<Button className="execute-btn" color="primary" onClick={executeQuery}>
|
||||
Execute
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{downshift.isOpen && this.createAutocompleteSection(downshift)}
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
|
||||
<MetricsExplorer
|
||||
show={this.state.showMetricsExplorer}
|
||||
updateShow={this.updateShowMetricsExplorer}
|
||||
metrics={this.props.metricNames}
|
||||
insertAtCursor={this.insertAtCursor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
14
web/ui/react-app/src/pages/graph/MetricsExplorer.css
Normal file
14
web/ui/react-app/src/pages/graph/MetricsExplorer.css
Normal 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;
|
||||
}
|
38
web/ui/react-app/src/pages/graph/MetricsExplorer.tsx
Normal file
38
web/ui/react-app/src/pages/graph/MetricsExplorer.tsx
Normal 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;
|
|
@ -41,14 +41,12 @@ describe('Panel', () => {
|
|||
it('renders an ExpressionInput', () => {
|
||||
const input = panel.find(ExpressionInput);
|
||||
expect(input.prop('value')).toEqual('prometheus_engine');
|
||||
expect(input.prop('autocompleteSections')).toEqual({
|
||||
'Metric Names': [
|
||||
'prometheus_engine_queries',
|
||||
'prometheus_engine_queries_concurrent_max',
|
||||
'prometheus_engine_query_duration_seconds',
|
||||
],
|
||||
'Query History': [],
|
||||
});
|
||||
expect(input.prop('metricNames')).toEqual([
|
||||
'prometheus_engine_queries',
|
||||
'prometheus_engine_queries_concurrent_max',
|
||||
'prometheus_engine_query_duration_seconds',
|
||||
]);
|
||||
expect(input.prop('queryHistory')).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders NavLinks', () => {
|
||||
|
|
|
@ -238,10 +238,8 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
executeQuery={this.executeQuery}
|
||||
loading={this.state.loading}
|
||||
enableAutocomplete={this.props.enableAutocomplete}
|
||||
autocompleteSections={{
|
||||
'Query History': pastQueries,
|
||||
'Metric Names': metricNames,
|
||||
}}
|
||||
queryHistory={pastQueries}
|
||||
metricNames={metricNames}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
Loading…
Reference in a new issue