migrate query history (#6193)

* migrate query history

Signed-off-by: blalov <boyko.lalov@tick42.com>

* update lock file

Signed-off-by: blalov <boyko.lalov@tick42.com>

* set expression input height when item is selected

Signed-off-by: blalov <boyko.lalov@tick42.com>

* pr review changes

Signed-off-by: blalov <boyko.lalov@tick42.com>
This commit is contained in:
Boyko 2019-10-23 23:18:41 +03:00 committed by Julius Volz
parent 89e610ef4c
commit e235af9c47
10 changed files with 113 additions and 47 deletions

View file

@ -11,8 +11,8 @@
"@types/node": "^12.11.1", "@types/node": "^12.11.1",
"@types/react": "^16.8.2", "@types/react": "^16.8.2",
"@types/react-dom": "^16.8.0", "@types/react-dom": "^16.8.0",
"@types/sanitize-html": "^1.20.2",
"@types/react-resize-detector": "^4.0.2", "@types/react-resize-detector": "^4.0.2",
"@types/sanitize-html": "^1.20.2",
"bootstrap": "^4.2.1", "bootstrap": "^4.2.1",
"downshift": "^3.2.2", "downshift": "^3.2.2",
"flot": "^3.2.13", "flot": "^3.2.13",
@ -26,10 +26,10 @@
"popper.js": "^1.14.3", "popper.js": "^1.14.3",
"react": "^16.7.0", "react": "^16.7.0",
"react-dom": "^16.7.0", "react-dom": "^16.7.0",
"sanitize-html": "^1.20.1",
"react-resize-detector": "^4.2.1", "react-resize-detector": "^4.2.1",
"react-scripts": "^3.2.0", "react-scripts": "^3.2.0",
"reactstrap": "^8.0.1", "reactstrap": "^8.0.1",
"sanitize-html": "^1.20.1",
"tempusdominus-bootstrap-4": "^5.1.2", "tempusdominus-bootstrap-4": "^5.1.2",
"tempusdominus-core": "^5.0.3", "tempusdominus-core": "^5.0.3",
"typescript": "^3.3.3" "typescript": "^3.3.3"

View file

@ -10,6 +10,16 @@ button.classic-ui-btn {
margin-bottom: 20px; margin-bottom: 20px;
} }
input[type='checkbox']:checked + label {
color: #286090;
}
.custom-control-label {
cursor: pointer;
font-size: .875rem;
line-height: 1.8;
}
.expression-input { .expression-input {
margin-bottom: 10px; margin-bottom: 10px;
} }

View file

@ -1,31 +1,16 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { import {
Button,
Container, Container,
Col,
Row,
} from 'reactstrap'; } from 'reactstrap';
import PanelList from './PanelList';
import './App.css'; import './App.css';
import PanelList from './PanelList';
class App extends Component { class App extends Component {
render() { render() {
return ( return (
<Container fluid={true}> <Container fluid={true}>
<Row>
<Col>
<Button
className="float-right classic-ui-btn"
color="link"
onClick={() => {window.location.pathname = "../../graph"}}
size="sm">
Return to classic UI
</Button>
</Col>
</Row>
<PanelList /> <PanelList />
</Container> </Container>
); );

View file

@ -0,0 +1,22 @@
import React, { FC, HTMLProps, memo } from 'react';
import { FormGroup, Label, Input } from 'reactstrap';
import { uuidGen } from './utils/func';
const Checkbox: FC<HTMLProps<HTMLSpanElement>> = ({ children, onChange, style }) => {
const id = uuidGen();
return (
<FormGroup className="custom-control custom-checkbox" style={style}>
<Input
onChange={onChange}
type="checkbox"
className="custom-control-input"
id={`checkbox_${id}`}
placeholder="password placeholder" />
<Label style={{ userSelect: 'none' }} className="custom-control-label" for={`checkbox_${id}`}>
{children}
</Label>
</FormGroup>
)
}
export default memo(Checkbox);

View file

@ -11,12 +11,9 @@ import Downshift, { ControllerStateAndHelpers } from 'downshift';
import fuzzy from 'fuzzy'; import fuzzy from 'fuzzy';
import SanitizeHTML from './components/SanitizeHTML'; import SanitizeHTML from './components/SanitizeHTML';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
library.add(faSearch, faSpinner);
interface ExpressionInputProps { interface ExpressionInputProps {
value: string; value: string;
metricNames: string[]; metricNames: string[];
@ -59,8 +56,10 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
}, this.setHeight); }, this.setHeight);
} }
handleDropdownSelection = (value: string) => this.setState({ value }); handleDropdownSelection = (value: string) => {
this.setState({ value, height: 'auto' }, this.setHeight)
};
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => { handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
this.props.executeQuery(this.exprInputRef.current!.value); this.props.executeQuery(this.exprInputRef.current!.value);
@ -73,7 +72,9 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
renderAutosuggest = (downshift: ControllerStateAndHelpers<any>) => { renderAutosuggest = (downshift: ControllerStateAndHelpers<any>) => {
const { inputValue } = downshift const { inputValue } = downshift
if (!inputValue || (this.prevNoMatchValue && inputValue.includes(this.prevNoMatchValue))) { if (!inputValue || (this.prevNoMatchValue && inputValue.includes(this.prevNoMatchValue))) {
downshift.closeMenu(); // This is ugly but is needed in order to sync state updates.
// This way we force downshift to wait React render call to complete before closeMenu to be triggered.
setTimeout(downshift.closeMenu);
return null; return null;
} }
@ -84,7 +85,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
if (matches.length === 0) { if (matches.length === 0) {
this.prevNoMatchValue = inputValue; this.prevNoMatchValue = inputValue;
downshift.closeMenu(); setTimeout(downshift.closeMenu);
return null; return null;
} }
@ -128,7 +129,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
<InputGroup className="expression-input"> <InputGroup className="expression-input">
<InputGroupAddon addonType="prepend"> <InputGroupAddon addonType="prepend">
<InputGroupText> <InputGroupText>
{this.props.loading ? <FontAwesomeIcon icon="spinner" spin/> : <FontAwesomeIcon icon="search"/>} {this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
</InputGroupText> </InputGroupText>
</InputGroupAddon> </InputGroupAddon>
<Input <Input

View file

@ -8,7 +8,6 @@ import {
Input, Input,
} from 'reactstrap'; } from 'reactstrap';
import { library } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faPlus, faPlus,
@ -20,13 +19,6 @@ import {
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
import { parseRange, formatRange } from './utils/timeFormat'; import { parseRange, formatRange } from './utils/timeFormat';
library.add(
faPlus,
faMinus,
faChartArea,
faChartLine,
);
interface GraphControlsProps { interface GraphControlsProps {
range: number; range: number;
endTime: number | null; endTime: number | null;
@ -111,7 +103,7 @@ class GraphControls extends Component<GraphControlsProps> {
<Form inline className="graph-controls" onSubmit={e => e.preventDefault()}> <Form inline className="graph-controls" onSubmit={e => e.preventDefault()}>
<InputGroup className="range-input" size="sm"> <InputGroup className="range-input" size="sm">
<InputGroupAddon addonType="prepend"> <InputGroupAddon addonType="prepend">
<Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon="minus" fixedWidth/></Button> <Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon={faMinus} fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
<Input <Input
@ -121,7 +113,7 @@ class GraphControls extends Component<GraphControlsProps> {
/> />
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon="plus" fixedWidth/></Button> <Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon={faPlus} fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
@ -145,8 +137,8 @@ class GraphControls extends Component<GraphControlsProps> {
/> />
<ButtonGroup className="stacked-input" size="sm"> <ButtonGroup className="stacked-input" size="sm">
<Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon="chart-line" fixedWidth/></Button> <Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon={faChartLine} fixedWidth/></Button>
<Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon="chart-area" fixedWidth/></Button> <Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon={faChartArea} fixedWidth/></Button>
</ButtonGroup> </ButtonGroup>
</Form> </Form>
); );

View file

@ -26,6 +26,7 @@ interface PanelProps {
onOptionsChanged: (opts: PanelOptions) => void; onOptionsChanged: (opts: PanelOptions) => void;
metricNames: string[]; metricNames: string[];
removePanel: () => void; removePanel: () => void;
onExecuteQuery: (query: string) => void;
} }
interface PanelState { interface PanelState {
@ -102,6 +103,7 @@ class Panel extends Component<PanelProps, PanelState> {
executeQuery = (expr: string): void => { executeQuery = (expr: string): void => {
const queryStart = Date.now(); const queryStart = Date.now();
this.props.onExecuteQuery(expr)
if (this.props.options.expr !== expr) { if (this.props.options.expr !== expr) {
this.setOptions({expr: expr}); this.setOptions({expr: expr});
} }

View file

@ -1,9 +1,10 @@
import React, { Component } from 'react'; import React, { Component, ChangeEvent } from 'react';
import { Alert, Button, Col, Row } from 'reactstrap'; import { Alert, Button, Col, Row } from 'reactstrap';
import Panel, { PanelOptions, PanelDefaultOptions } from './Panel'; import Panel, { PanelOptions, PanelDefaultOptions } from './Panel';
import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams'; import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams';
import Checkbox from './Checkbox';
interface PanelListState { interface PanelListState {
panels: { panels: {
@ -17,7 +18,7 @@ interface PanelListState {
class PanelList extends Component<any, PanelListState> { class PanelList extends Component<any, PanelListState> {
private key: number = 0; private key: number = 0;
private initialMetricNames: string[] = [];
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -45,7 +46,10 @@ class PanelList extends Component<any, PanelListState> {
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
} }
}) })
.then(json => this.setState({ metricNames: json.data })) .then(json => {
this.initialMetricNames = json.data;
this.setMetrics();
})
.catch(error => this.setState({ fetchMetricsError: error.message })); .catch(error => this.setState({ fetchMetricsError: error.message }));
const browserTime = new Date().getTime() / 1000; const browserTime = new Date().getTime() / 1000;
@ -75,6 +79,40 @@ class PanelList extends Component<any, PanelListState> {
} }
} }
isHistoryEnabled = () => JSON.parse(localStorage.getItem('enable-query-history') || 'false') as boolean;
getHistoryItems = () => JSON.parse(localStorage.getItem('history') || '[]') as string[];
toggleQueryHistory = (e: ChangeEvent<HTMLInputElement>) => {
localStorage.setItem('enable-query-history', `${e.target.checked}`);
this.setMetrics();
}
setMetrics = () => {
if (this.isHistoryEnabled()) {
const historyItems = this.getHistoryItems();
const { length } = historyItems;
this.setState({
metricNames: [...historyItems.slice(length - 50, length), ...this.initialMetricNames],
});
} else {
this.setState({ metricNames: this.initialMetricNames });
}
}
handleQueryHistory = (query: string) => {
const isSimpleMetric = this.initialMetricNames.indexOf(query) !== -1;
if (isSimpleMetric || !query.length) {
return;
}
const historyItems = this.getHistoryItems();
const extendedItems = historyItems.reduce((acc, metric) => {
return metric === query ? acc : [...acc, metric]; // Prevent adding query twice.
}, [query]);
localStorage.setItem('history', JSON.stringify(extendedItems));
this.setMetrics();
}
getKey(): string { getKey(): string {
return (this.key++).toString(); return (this.key++).toString();
} }
@ -116,6 +154,23 @@ class PanelList extends Component<any, PanelListState> {
render() { render() {
return ( return (
<> <>
<Row className="mb-2">
<Checkbox
style={{ margin: '0 0 0 15px', alignSelf: 'center' }}
onChange={this.toggleQueryHistory}
checked={this.isHistoryEnabled()}>
Enable query history
</Checkbox>
<Col>
<Button
className="float-right classic-ui-btn"
color="link"
onClick={() => { window.location.pathname = "../../graph" }}
size="sm">
Return to classic UI
</Button>
</Col>
</Row>
<Row> <Row>
<Col> <Col>
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>} {this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>}
@ -128,6 +183,7 @@ class PanelList extends Component<any, PanelListState> {
</Row> </Row>
{this.state.panels.map(p => {this.state.panels.map(p =>
<Panel <Panel
onExecuteQuery={this.handleQueryHistory}
key={p.key} key={p.key}
options={p.options} options={p.options}
onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)} onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)}

View file

@ -20,14 +20,11 @@ import {
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
library.add( library.add(
faChevronLeft,
faChevronRight,
faCalendarCheck, faCalendarCheck,
faArrowUp, faArrowUp,
faArrowDown, faArrowDown,
faTimes, faTimes,
); );
// Sadly needed to also replace <i> within the date picker, since it's not a React component. // Sadly needed to also replace <i> within the date picker, since it's not a React component.
dom.watch(); dom.watch();
@ -99,7 +96,7 @@ class TimeInput extends Component<TimeInputProps> {
return ( return (
<InputGroup className="time-input" size="sm"> <InputGroup className="time-input" size="sm">
<InputGroupAddon addonType="prepend"> <InputGroupAddon addonType="prepend">
<Button title="Decrease time" onClick={this.decreaseTime}><FontAwesomeIcon icon="chevron-left" fixedWidth/></Button> <Button title="Decrease time" onClick={this.decreaseTime}><FontAwesomeIcon icon={faChevronLeft} fixedWidth /></Button>
</InputGroupAddon> </InputGroupAddon>
<Input <Input
@ -114,12 +111,12 @@ class TimeInput extends Component<TimeInputProps> {
that functionality is broken, so we create an external solution instead. */} that functionality is broken, so we create an external solution instead. */}
{this.props.time && {this.props.time &&
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button className="clear-time-btn" title="Clear time" onClick={this.clearTime}><FontAwesomeIcon icon="times" fixedWidth/></Button> <Button className="clear-time-btn" title="Clear time" onClick={this.clearTime}><FontAwesomeIcon icon={faTimes} fixedWidth /></Button>
</InputGroupAddon> </InputGroupAddon>
} }
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button title="Increase time" onClick={this.increaseTime}><FontAwesomeIcon icon="chevron-right" fixedWidth/></Button> <Button title="Increase time" onClick={this.increaseTime}><FontAwesomeIcon icon={faChevronRight} fixedWidth /></Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
); );

View file

@ -0,0 +1 @@
export const uuidGen = () => '_' + Math.random().toString(36).substr(2, 9);