diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 11d3c3b63..777750d2b 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -11,8 +11,8 @@ "@types/node": "^12.11.1", "@types/react": "^16.8.2", "@types/react-dom": "^16.8.0", - "@types/sanitize-html": "^1.20.2", "@types/react-resize-detector": "^4.0.2", + "@types/sanitize-html": "^1.20.2", "bootstrap": "^4.2.1", "downshift": "^3.2.2", "flot": "^3.2.13", @@ -26,10 +26,10 @@ "popper.js": "^1.14.3", "react": "^16.7.0", "react-dom": "^16.7.0", - "sanitize-html": "^1.20.1", "react-resize-detector": "^4.2.1", "react-scripts": "^3.2.0", "reactstrap": "^8.0.1", + "sanitize-html": "^1.20.1", "tempusdominus-bootstrap-4": "^5.1.2", "tempusdominus-core": "^5.0.3", "typescript": "^3.3.3" diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index a540874f1..da6288a71 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -10,6 +10,16 @@ button.classic-ui-btn { margin-bottom: 20px; } +input[type='checkbox']:checked + label { + color: #286090; +} + +.custom-control-label { + cursor: pointer; + font-size: .875rem; + line-height: 1.8; +} + .expression-input { margin-bottom: 10px; } diff --git a/web/ui/react-app/src/App.tsx b/web/ui/react-app/src/App.tsx index 4c2dfb5d6..471ced047 100755 --- a/web/ui/react-app/src/App.tsx +++ b/web/ui/react-app/src/App.tsx @@ -1,31 +1,16 @@ import React, { Component } from 'react'; import { - Button, Container, - Col, - Row, } from 'reactstrap'; -import PanelList from './PanelList'; - import './App.css'; +import PanelList from './PanelList'; class App extends Component { render() { return ( - - - - - ); diff --git a/web/ui/react-app/src/Checkbox.tsx b/web/ui/react-app/src/Checkbox.tsx new file mode 100644 index 000000000..1d4719e35 --- /dev/null +++ b/web/ui/react-app/src/Checkbox.tsx @@ -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> = ({ children, onChange, style }) => { + const id = uuidGen(); + return ( + + + + + ) +} + +export default memo(Checkbox); diff --git a/web/ui/react-app/src/ExpressionInput.tsx b/web/ui/react-app/src/ExpressionInput.tsx index 23026cad8..2b9b98a56 100644 --- a/web/ui/react-app/src/ExpressionInput.tsx +++ b/web/ui/react-app/src/ExpressionInput.tsx @@ -11,12 +11,9 @@ import Downshift, { ControllerStateAndHelpers } from 'downshift'; import fuzzy from 'fuzzy'; import SanitizeHTML from './components/SanitizeHTML'; -import { library } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; -library.add(faSearch, faSpinner); - interface ExpressionInputProps { value: string; metricNames: string[]; @@ -59,8 +56,10 @@ class ExpressionInput extends Component this.setState({ value }); - + handleDropdownSelection = (value: string) => { + this.setState({ value, height: 'auto' }, this.setHeight) + }; + handleKeyPress = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && !event.shiftKey) { this.props.executeQuery(this.exprInputRef.current!.value); @@ -73,7 +72,9 @@ class ExpressionInput extends Component) => { const { inputValue } = downshift 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; } @@ -84,7 +85,7 @@ class ExpressionInput extends Component - {this.props.loading ? : } + {this.props.loading ? : } {
e.preventDefault()}> - + { /> - + @@ -145,8 +137,8 @@ class GraphControls extends Component { /> - - + + ); diff --git a/web/ui/react-app/src/Panel.tsx b/web/ui/react-app/src/Panel.tsx index 6450eb1ee..51e65e7c7 100644 --- a/web/ui/react-app/src/Panel.tsx +++ b/web/ui/react-app/src/Panel.tsx @@ -26,6 +26,7 @@ interface PanelProps { onOptionsChanged: (opts: PanelOptions) => void; metricNames: string[]; removePanel: () => void; + onExecuteQuery: (query: string) => void; } interface PanelState { @@ -102,6 +103,7 @@ class Panel extends Component { executeQuery = (expr: string): void => { const queryStart = Date.now(); + this.props.onExecuteQuery(expr) if (this.props.options.expr !== expr) { this.setOptions({expr: expr}); } diff --git a/web/ui/react-app/src/PanelList.tsx b/web/ui/react-app/src/PanelList.tsx index 6d1b49428..44768eca2 100644 --- a/web/ui/react-app/src/PanelList.tsx +++ b/web/ui/react-app/src/PanelList.tsx @@ -1,9 +1,10 @@ -import React, { Component } from 'react'; +import React, { Component, ChangeEvent } from 'react'; import { Alert, Button, Col, Row } from 'reactstrap'; import Panel, { PanelOptions, PanelDefaultOptions } from './Panel'; import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams'; +import Checkbox from './Checkbox'; interface PanelListState { panels: { @@ -17,7 +18,7 @@ interface PanelListState { class PanelList extends Component { private key: number = 0; - + private initialMetricNames: string[] = []; constructor(props: any) { super(props); @@ -45,7 +46,10 @@ class PanelList extends Component { 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 })); const browserTime = new Date().getTime() / 1000; @@ -75,6 +79,40 @@ class PanelList extends Component { } } + isHistoryEnabled = () => JSON.parse(localStorage.getItem('enable-query-history') || 'false') as boolean; + + getHistoryItems = () => JSON.parse(localStorage.getItem('history') || '[]') as string[]; + + toggleQueryHistory = (e: ChangeEvent) => { + 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 { return (this.key++).toString(); } @@ -116,6 +154,23 @@ class PanelList extends Component { render() { return ( <> + + + Enable query history + + + + + {this.state.timeDriftError && Warning: Error fetching server time: {this.state.timeDriftError}} @@ -128,6 +183,7 @@ class PanelList extends Component { {this.state.panels.map(p => this.handleOptionsChanged(p.key, opts)} diff --git a/web/ui/react-app/src/TimeInput.tsx b/web/ui/react-app/src/TimeInput.tsx index 35c738831..903471423 100644 --- a/web/ui/react-app/src/TimeInput.tsx +++ b/web/ui/react-app/src/TimeInput.tsx @@ -20,14 +20,11 @@ import { } from '@fortawesome/free-solid-svg-icons'; library.add( - faChevronLeft, - faChevronRight, faCalendarCheck, faArrowUp, faArrowDown, faTimes, ); - // Sadly needed to also replace within the date picker, since it's not a React component. dom.watch(); @@ -99,7 +96,7 @@ class TimeInput extends Component { return ( - + { that functionality is broken, so we create an external solution instead. */} {this.props.time && - + } - + ); diff --git a/web/ui/react-app/src/utils/func.ts b/web/ui/react-app/src/utils/func.ts new file mode 100644 index 000000000..9ad243e66 --- /dev/null +++ b/web/ui/react-app/src/utils/func.ts @@ -0,0 +1 @@ +export const uuidGen = () => '_' + Math.random().toString(36).substr(2, 9);