Fixed and built more features

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2019-02-12 20:59:27 +01:00
parent 285eda3a13
commit 1a3df0b78c
4 changed files with 171 additions and 96 deletions

5
package-lock.json generated
View file

@ -6180,6 +6180,11 @@
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=" "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc="
}, },
"fuzzy": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz",
"integrity": "sha1-THbsL/CsGjap3M+aAN+GIweNTtg="
},
"get-caller-file": { "get-caller-file": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz",

View file

@ -8,10 +8,12 @@
"@fortawesome/react-fontawesome": "^0.1.4", "@fortawesome/react-fontawesome": "^0.1.4",
"bootstrap": "^4.2.1", "bootstrap": "^4.2.1",
"downshift": "^3.2.2", "downshift": "^3.2.2",
"fuzzy": "^0.1.3",
"i": "^0.3.6", "i": "^0.3.6",
"jquery": "^3.3.1", "jquery": "^3.3.1",
"jsdom": "^9.6.0", "jsdom": "^9.6.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"moment-timezone": "^0.5.23",
"npm": "^6.7.0", "npm": "^6.7.0",
"react": "^16.7.0", "react": "^16.7.0",
"react-dom": "^16.7.0", "react-dom": "^16.7.0",

View file

@ -70,10 +70,18 @@ button.execute-btn {
padding: 15px 0 10px 10px; padding: 15px 0 10px 10px;
} }
.graph-controls input {
text-align: center;
}
.graph-controls .range-input input { .graph-controls .range-input input {
width: 50px; width: 50px;
} }
.graph-controls .endtime-input input {
width: 160px;
}
.graph-controls input.resolution-input { .graph-controls input.resolution-input {
width: 90px; width: 90px;
} }

View file

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component, PureComponent } from 'react';
import { import {
Alert, Alert,
Button, Button,
@ -28,7 +28,9 @@ import '../node_modules/react-flot/flot/jquery.flot.stack.min';
import './App.css'; import './App.css';
import Downshift from 'downshift'; import Downshift from 'downshift';
import moment from 'moment'; import moment from 'moment-timezone';
import fuzzy from 'fuzzy';
import 'tempusdominus-core'; import 'tempusdominus-core';
import 'tempusdominus-bootstrap-4'; import 'tempusdominus-bootstrap-4';
@ -100,7 +102,7 @@ class PanelList extends Component {
componentDidMount() { componentDidMount() {
this.addPanel(); this.addPanel();
fetch("http://demo.robustperception.io:9090/api/v1/label/__name__/values") fetch("http://demo.robustperception.io:9090/api/v1/label/__name__/values", {cache: "no-store"})
.then(resp => { .then(resp => {
if (resp.ok) { if (resp.ok) {
return resp.json(); return resp.json();
@ -112,7 +114,7 @@ class PanelList extends Component {
.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;
fetch("http://demo.robustperception.io:9090/api/v1/query?query=time()") fetch("http://demo.robustperception.io:9090/api/v1/query?query=time()", {cache: "no-store"})
.then(resp => { .then(resp => {
if (resp.ok) { if (resp.ok) {
return resp.json(); return resp.json();
@ -179,11 +181,10 @@ class Panel extends Component {
expr: 'rate(node_cpu_seconds_total[1m])', expr: 'rate(node_cpu_seconds_total[1m])',
type: 'graph', // TODO enum? type: 'graph', // TODO enum?
range: 3600, range: 3600,
endTime: null, endTime: null, // This is in milliseconds.
resolution: null, resolution: null,
stacked: false, stacked: false,
data: null, data: null,
loading: false,
error: null, error: null,
stats: null, stats: null,
}; };
@ -196,6 +197,12 @@ class Panel extends Component {
return prevState[v] !== this.state[v]; return prevState[v] !== this.state[v];
}) })
if (needsRefresh) { if (needsRefresh) {
if (prevState.type !== this.state.type) {
// If the other options change, we still want to show the old data until the new
// query completes, but this is not a good idea when we actually change between
// table and graph view, since not all queries work well in both.
this.setState({data: null});
}
this.executeQuery(); this.executeQuery();
} }
} }
@ -205,13 +212,19 @@ class Panel extends Component {
} }
executeQuery = ()=> { executeQuery = ()=> {
// TODO: Abort existing queries. if (this.abortInFlightFetch) {
if (this.state.expr === "") { this.abortInFlightFetch();
return; this.abortInFlightFetch = null;
} }
const abortController = new AbortController();
this.abortInFlightFetch = () => abortController.abort();
this.setState({loading: true}); this.setState({loading: true});
if (this.state.expr === '') {
return;
}
let endTime = this.getEndTime() / 1000; let endTime = this.getEndTime() / 1000;
let startTime = endTime - this.state.range; let startTime = endTime - this.state.range;
let resolution = this.state.resolution || Math.max(Math.floor(this.state.range / 250), 1); let resolution = this.state.resolution || Math.max(Math.floor(this.state.range / 250), 1);
@ -238,11 +251,11 @@ class Panel extends Component {
}) })
break; break;
default: default:
// TODO throw new Error('Invalid panel type "' + this.state.type + '"');
} }
Object.keys(params).forEach(key => url.searchParams.append(key, params[key])) Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
fetch(url) fetch(url, {cache: 'no-store', signal: abortController.signal})
.then(resp => resp.json()) .then(resp => resp.json())
.then(json => { .then(json => {
if (json.status !== 'success') { if (json.status !== 'success') {
@ -258,9 +271,14 @@ class Panel extends Component {
resolution: resolution, resolution: resolution,
}, },
loading: false, loading: false,
}) });
this.abortInFlightFetch = null;
}) })
.catch(error => { .catch(error => {
if (error.name === 'AbortError') {
// Aborts are expected, don't show an error for them.
return
}
this.setState({ this.setState({
error: 'Error executing query: ' + error.message, error: 'Error executing query: ' + error.message,
loading: false, loading: false,
@ -289,7 +307,11 @@ class Panel extends Component {
} }
handleChangeResolution = (resolution) => { handleChangeResolution = (resolution) => {
this.setState({resolution: resolution}); // TODO: Where should we validate domain model constraints? In the parent's
// change handler like here, or in the calling component?
if (resolution > 0) {
this.setState({resolution: resolution});
}
} }
// getEndDate = () => { // getEndDate = () => {
@ -344,14 +366,6 @@ class Panel extends Component {
loading={this.state.loading} loading={this.state.loading}
metrics={this.props.metrics} metrics={this.props.metrics}
/> />
{/*<Input type="select" name="selectMetric">
{this.props.metrics.map(m => <option key={m}>{m}</option>)}
</Input>*/}
</Col>
</Row>
<Row>
<Col>
{/* {this.state.loading && "Loading..."} */}
</Col> </Col>
</Row> </Row>
<Row> <Row>
@ -426,38 +440,52 @@ class ExpressionInput extends Component {
stateReducer = (state, changes) => { stateReducer = (state, changes) => {
return changes; return changes;
// TODO: Remove this whole function if I don't notice any odd behavior without it. // // TODO: Remove this whole function if I don't notice any odd behavior without it.
// I don't remember why I had to add this and currently things seem fine without it. // // I don't remember why I had to add this and currently things seem fine without it.
switch (changes.type) { // switch (changes.type) {
case Downshift.stateChangeTypes.keyDownEnter: // case Downshift.stateChangeTypes.keyDownEnter:
case Downshift.stateChangeTypes.clickItem: // case Downshift.stateChangeTypes.clickItem:
case Downshift.stateChangeTypes.changeInput: // case Downshift.stateChangeTypes.changeInput:
return { // return {
...changes, // ...changes,
selectedItem: changes.inputValue, // selectedItem: changes.inputValue,
}; // };
default: // default:
return changes; // return changes;
} // }
} }
renderAutosuggest = (downshift) => { renderAutosuggest = (downshift) => {
let matches = this.props.metrics.filter(item => !downshift.inputValue || item.includes(downshift.inputValue)); if (this.prevNoMatchValue && downshift.inputValue.includes(this.prevNoMatchValue)) {
if (matches.length === 0 || !downshift.isOpen) { // TODO: Is this still correct with fuzzy?
return null; return null;
} }
let matches = fuzzy.filter(downshift.inputValue.replace(/ /g, ''), this.props.metrics, {
pre: "<strong>",
post: "</strong>",
});
if (matches.length === 0) {
this.prevNoMatchValue = downshift.inputValue;
return null;
}
if (!downshift.isOpen) {
return null; // TODO CHECK NEED FOR THIS
}
return ( return (
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}> <ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
{ {
matches matches
.slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow. .slice(0, 200) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
.map((item, index) => ( .map((item, index) => (
<li <li
{...downshift.getItemProps({ {...downshift.getItemProps({
key: item, key: item.original,
index, index,
item, item: item.original,
style: { style: {
backgroundColor: backgroundColor:
downshift.highlightedIndex === index ? 'lightgray' : 'white', downshift.highlightedIndex === index ? 'lightgray' : 'white',
@ -465,7 +493,9 @@ class ExpressionInput extends Component {
}, },
})} })}
> >
{item} {/* TODO: Find better way than setting inner HTML dangerously. We just want the <strong> to not be escaped.
This will be a problem when we save history and the user enters HTML into a query. q*/}
<span dangerouslySetInnerHTML={{__html: item.string}}></span>
</li> </li>
)) ))
} }
@ -536,61 +566,80 @@ class ExpressionInput extends Component {
} }
function TabPaneAlert(props) { function TabPaneAlert(props) {
const { color, message } = props;
return ( return (
<> <>
{/* Without the following <div> hack, giving the <Alert> any top margin {/* Without the following <div> hack, giving the <Alert> any top margin
will make the entire tab pane look detached. */} makes the entire tab pane look detached by that margin. */}
<div style={{height: '1px'}}></div> <div style={{height: '1px'}}></div>
<Alert className="tabpane-alert" color={color}>{props.children}</Alert> <Alert className="tabpane-alert" color={props.color}>{props.children}</Alert>
</> </>
); );
} }
function DataTable(props) { class DataTable extends PureComponent {
const data = props.data; limitSeries(series) {
const maxSeries = 10000;
if (data === null) { if (series.length > maxSeries) {
return <TabPaneAlert color="light">No data queried yet</TabPaneAlert>; return series.slice(0, maxSeries);
}
if (data.result === null || data.result.length === 0) {
return <TabPaneAlert color="secondary">Empty query result</TabPaneAlert>;
}
let rows = [];
if (props.data) {
switch(data.resultType) {
case 'vector':
rows = props.data.result.map((s, index) => {
return <tr key={index}><td>{metricToSeriesName(s.metric)}</td><td>{s.value[1]}</td></tr>
});
break;
case 'matrix':
rows = props.data.result.map((s, index) => {
const valueText = s.values.map((v) => {
return [1] + ' @' + v[0];
}).join('\n');
return <tr style={{'white-space': 'pre'}} key={index}><td>{metricToSeriesName(s.metric)}</td><td>{valueText}</td></tr>
});
break;
case 'scalar':
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
case 'string':
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
default:
return <TabPaneAlert color="danger">Unsupported result value type '{data.resultType}'</TabPaneAlert>;
} }
return series;
} }
return ( render() {
<Table hover size="sm" className="data-table"> const data = this.props.data;
<tbody>
{rows} if (data === null) {
</tbody> return <TabPaneAlert color="light">No data queried yet</TabPaneAlert>;
</Table> }
);
if (data.result === null || data.result.length === 0) {
return <TabPaneAlert color="secondary">Empty query result</TabPaneAlert>;
}
let rows = [];
let limitedSeries = this.limitSeries(data.result);
if (data) {
switch(data.resultType) {
case 'vector':
rows = limitedSeries.map((s, index) => {
return <tr key={index}><td>{metricToSeriesName(s.metric)}</td><td>{s.value[1]}</td></tr>
});
break;
case 'matrix':
rows = limitedSeries.map((s, index) => {
const valueText = s.values.map((v) => {
return [1] + ' @' + v[0];
}).join('\n');
return <tr style={{whiteSpace: 'pre'}} key={index}><td>{metricToSeriesName(s.metric)}</td><td>{valueText}</td></tr>
});
break;
case 'scalar':
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
break;
case 'string':
rows.push(<tr><td>scalar</td><td>{data.result[1]}</td></tr>);
break;
default:
return <TabPaneAlert color="danger">Unsupported result value type '{data.resultType}'</TabPaneAlert>;
}
}
return (
<>
{data.result.length !== limitedSeries.length &&
<TabPaneAlert color="danger">
<strong>Warning:</strong> Fetched {data.result.length} metrics, only displaying first {limitedSeries.length}.
</TabPaneAlert>
}
<Table hover size="sm" className="data-table">
<tbody>
{rows}
</tbody>
</Table>
</>
);
}
} }
class GraphControls extends Component { class GraphControls extends Component {
constructor(props) { constructor(props) {
@ -602,6 +651,7 @@ class GraphControls extends Component {
this.rangeRef = React.createRef(); this.rangeRef = React.createRef();
this.endTimeRef = React.createRef(); this.endTimeRef = React.createRef();
this.resolutionRef = React.createRef();
} }
rangeUnits = { rangeUnits = {
@ -638,7 +688,7 @@ class GraphControls extends Component {
return range + 's'; return range + 's';
} }
onRangeInputChanged = (rangeText) => { onChangeRangeInput = (rangeText) => {
const range = this.parseRange(rangeText); const range = this.parseRange(rangeText);
if (range === null) { if (range === null) {
this.changeRangeInput(this.formatRange(this.props.range)); this.changeRangeInput(this.formatRange(this.props.range));
@ -689,7 +739,6 @@ class GraphControls extends Component {
this.$endTime.datetimepicker('date', endTime); this.$endTime.datetimepicker('date', endTime);
} }
// TODO: Handle manual textual changes to datetime input.
componentDidMount() { componentDidMount() {
this.$endTime = window.$(this.endTimeRef.current); this.$endTime = window.$(this.endTimeRef.current);
@ -707,10 +756,15 @@ class GraphControls extends Component {
format: 'YYYY-MM-DD HH:mm:ss', format: 'YYYY-MM-DD HH:mm:ss',
locale: 'en', locale: 'en',
timeZone: 'UTC', timeZone: 'UTC',
defaultDate: this.props.endTime,
}); });
this.$endTime.on('change.datetimepicker', e => { this.$endTime.on('change.datetimepicker', e => {
this.props.onChangeEndTime(e.date); if (e.date) {
this.props.onChangeEndTime(e.date);
} else {
this.$endTime.datetimepicker('date', e.target.value);
}
}); });
} }
@ -723,7 +777,11 @@ class GraphControls extends Component {
</InputGroupAddon> </InputGroupAddon>
{/* <Input value={this.state.rangeInput} onChange={(e) => this.changeRangeInput(e.target.value)}/> */} {/* <Input value={this.state.rangeInput} onChange={(e) => this.changeRangeInput(e.target.value)}/> */}
<Input defaultValue={this.formatRange(this.props.range)} innerRef={this.rangeRef} onBlur={() => this.onRangeInputChanged(this.rangeRef.current.value)}/> <Input
defaultValue={this.formatRange(this.props.range)}
innerRef={this.rangeRef}
onBlur={() => this.onChangeRangeInput(this.rangeRef.current.value)}
/>
<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="plus" fixedWidth/></Button>
@ -752,7 +810,14 @@ class GraphControls extends Component {
</InputGroup> </InputGroup>
{/* TODO: validate resolution and only update when valid */} {/* TODO: validate resolution and only update when valid */}
<Input className="resolution-input" value={this.props.resolution ? this.props.resolution : ''} onChange={(e) => this.props.onChangeResolution(e.target.value)} placeholder="Res. (s)" bsSize="sm"/> <Input
placeholder="Res. (s)"
className="resolution-input"
defaultValue={this.props.resolution !== null ? this.props.resolution : ''}
innerRef={this.resolutionRef}
onBlur={() => this.props.onChangeResolution(parseInt(this.resolutionRef.current.value))}
bsSize="sm"
/>
<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="chart-line" fixedWidth/></Button>
@ -769,12 +834,13 @@ function getGraphID() {
return graphID++; return graphID++;
} }
class Graph extends Component { class Graph extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
legendRef: null, legendRef: null,
}; };
this.id = getGraphID();
} }
escapeHTML(string) { escapeHTML(string) {
@ -866,7 +932,6 @@ class Graph extends Component {
} }
getOptions() { getOptions() {
console.log(this.props);
return { return {
// colors: [ // colors: [
// '#7EB26D', // 0: pale green // '#7EB26D', // 0: pale green
@ -976,11 +1041,6 @@ class Graph extends Component {
} }
getData() { getData() {
if (this.props.data.resultType !== 'matrix') {
// TODO self.showError("Result is not of matrix type! Please enter a correct expression.");
return [];
}
return this.props.data.result.map(ts => { return this.props.data.result.map(ts => {
// Insert nulls for all missing steps. // Insert nulls for all missing steps.
let data = []; let data = [];
@ -1031,7 +1091,7 @@ class Graph extends Component {
<div className="graph"> <div className="graph">
{this.state.legendRef && {this.state.legendRef &&
<ReactFlot <ReactFlot
id={getGraphID().toString()} id={this.id.toString()}
data={this.getData()} data={this.getData()}
options={this.getOptions()} options={this.getOptions()}
height="500px" height="500px"