So many new features and fixes

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2019-02-12 10:02:32 +01:00
parent 1df029fdb9
commit 285eda3a13
6 changed files with 597 additions and 201 deletions

40
package-lock.json generated
View file

@ -9203,6 +9203,14 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
}, },
"moment-timezone": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz",
"integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==",
"requires": {
"moment": ">= 2.9.0"
}
},
"move-concurrently": { "move-concurrently": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -18893,6 +18901,38 @@
"resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.1.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.1.tgz",
"integrity": "sha512-9I2ydhj8Z9veORCw5PRm4u9uebCn0mcCa6scWoNcbZ6dAtoo2618u9UUzxgmsCOreJpqDDuv61LvwofW7hLcBA==" "integrity": "sha512-9I2ydhj8Z9veORCw5PRm4u9uebCn0mcCa6scWoNcbZ6dAtoo2618u9UUzxgmsCOreJpqDDuv61LvwofW7hLcBA=="
}, },
"tempusdominus-bootstrap-4": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tempusdominus-bootstrap-4/-/tempusdominus-bootstrap-4-5.1.2.tgz",
"integrity": "sha512-ksD8qc4wOJeE19wvryXmEpRzMUSZu4wSOdG6zKSn8l4ccad16249KOX1j0CccyZpuuES/n4FLqLAUB+Dd1LTBA==",
"requires": {
"bootstrap": ">=4.1.2",
"jquery": "^3.0",
"moment": "^2.22.2",
"moment-timezone": "^0.5.11",
"popper.js": "^1.14.3"
}
},
"tempusdominus-core": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/tempusdominus-core/-/tempusdominus-core-5.0.3.tgz",
"integrity": "sha512-52lClmU33gb6J6I/S9uGDrgQwccq3Yw9SlZerTgGLOzOB3Sc9pgIVBirfPMsMcx8nPsg6mA5ItFAH/5BZiQThg==",
"requires": {
"jquery": "^3.0",
"moment": "^2.22.2",
"moment-timezone": "^0.4.0"
},
"dependencies": {
"moment-timezone": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.4.1.tgz",
"integrity": "sha1-gfWYw61eIs2teWtn7NjYjQ9bqgY=",
"requires": {
"moment": ">= 2.6.0"
}
}
}
},
"terser": { "terser": {
"version": "3.14.1", "version": "3.14.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-3.14.1.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-3.14.1.tgz",

View file

@ -17,7 +17,9 @@
"react-dom": "^16.7.0", "react-dom": "^16.7.0",
"react-flot": "^1.3.0", "react-flot": "^1.3.0",
"react-scripts": "2.1.3", "react-scripts": "2.1.3",
"reactstrap": "^7.1.0" "reactstrap": "^7.1.0",
"tempusdominus-bootstrap-4": "^5.1.2",
"tempusdominus-core": "^5.0.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View file

@ -22,7 +22,7 @@
work correctly both with client-side routing and a non-root public URL. work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`. Learn how to configure a non-root public URL by running `npm run build`.
--> -->
<title>React App</title> <title>Prometheus Expression Browser</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -6,6 +6,11 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
} }
.expression-input textarea {
/* font-family: 'Courier New', Courier, monospace; */
resize: none;
}
button.execute-btn { button.execute-btn {
width: 84px; width: 84px;
} }
@ -25,12 +30,13 @@ button.execute-btn {
} }
.data-table.table { .data-table.table {
white-space: pre; margin: -1px 0 2px 0;
margin: 2px 0 2px 0;
} }
.data-table > tbody > tr > td { .data-table > tbody > tr > td {
padding: 6px 16px 6px 16px; padding: 5px 0 5px 8px;
font-size: 0.8em;
overflow: hidden;
} }
.autosuggest-dropdown { .autosuggest-dropdown {
@ -43,10 +49,10 @@ button.execute-btn {
z-index: 1000; z-index: 1000;
min-width: 10rem; min-width: 10rem;
top: 100%; top: 100%;
left: 56px;
float: left; float: left;
padding: .5rem 1px .5rem 1px; padding: .5rem 1px .5rem 1px;
margin: .125rem 0 0 0; margin: -5px;
top: 40px;
list-style: none; list-style: none;
} }
@ -61,11 +67,11 @@ button.execute-btn {
} }
.graph-controls { .graph-controls {
padding: 15px 0 5px 25px; padding: 15px 0 10px 10px;
} }
.graph-controls .range-input input { .graph-controls .range-input input {
width: 70px; width: 50px;
} }
.graph-controls input.resolution-input { .graph-controls input.resolution-input {
@ -76,10 +82,18 @@ button.execute-btn {
margin-left: 20px; margin-left: 20px;
} }
.tabpane-alert {
margin: 10px;
}
.graph-legend { .graph-legend {
margin: 15px 0 15px 25px; margin: 15px 0 15px 25px;
} }
.graph {
margin: 0 5px 0 5px;
}
.graph .flot-overlay { .graph .flot-overlay {
cursor: crosshair; cursor: crosshair;
} }
@ -105,3 +119,7 @@ button.execute-btn {
height: 10px; height: 10px;
margin: 0 5px 0 0; margin: 0 5px 0 0;
} }
.add-panel-btn {
margin-bottom: 20px;
}

View file

@ -16,21 +16,61 @@ import {
Row, Row,
TabContent, TabContent,
TabPane, TabPane,
Table Table,
} from 'reactstrap'; } from 'reactstrap';
import ReactFlot from 'react-flot'; import ReactFlot from 'react-flot';
import '../node_modules/react-flot/flot/jquery.flot.time.min'; import '../node_modules/react-flot/flot/jquery.flot.time.min';
import '../node_modules/react-flot/flot/jquery.flot.crosshair.min'; import '../node_modules/react-flot/flot/jquery.flot.crosshair.min';
import '../node_modules/react-flot/flot/jquery.flot.tooltip.min'; import '../node_modules/react-flot/flot/jquery.flot.tooltip.min';
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';
import { library } from '@fortawesome/fontawesome-svg-core' import 'tempusdominus-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import 'tempusdominus-bootstrap-4';
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons' import '../node_modules/tempusdominus-bootstrap-4/build/css/tempusdominus-bootstrap-4.min.css';
library.add(faSearch, faSpinner) import { library, dom } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faSearch,
faSpinner,
faChevronLeft,
faChevronRight,
faPlus,
faMinus,
faChartArea,
faChartLine,
faClock,
faCalendar,
faArrowUp,
faArrowDown,
faCalendarCheck,
faTrash,
faTimes,
} from '@fortawesome/free-solid-svg-icons';
library.add(
faSearch,
faSpinner,
faChevronLeft,
faChevronRight,
faPlus,
faMinus,
faChartArea,
faChartLine,
faClock,
faCalendar,
faArrowUp,
faArrowDown,
faCalendarCheck,
faTrash,
faTimes,
);
dom.watch() // Needed to also replace <i> within the date picker.
class App extends Component { class App extends Component {
render() { render() {
@ -48,6 +88,8 @@ class PanelList extends Component {
this.state = { this.state = {
panels: [], panels: [],
metrics: [], metrics: [],
fetchMetricsError: null,
timeDriftError: null,
}; };
this.key = 0; this.key = 0;
@ -66,12 +108,27 @@ class PanelList extends Component {
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 => .then(json => this.setState({ metrics: json.data }))
this.setState({ metrics: json.data }) .catch(error => this.setState({fetchMetricsError: error.message}));
)
.catch(error => { const browserTime = new Date().getTime() / 1000;
this.setState({error}) fetch("http://demo.robustperception.io:9090/api/v1/query?query=time()")
}); .then(resp => {
if (resp.ok) {
return resp.json();
} else {
throw new Error('Unexpected response status when fetching metric names: ' + resp.statusText); // TODO extract error
}
})
.then(json => {
const serverTime = json.data.result[0];
const delta = Math.abs(browserTime - serverTime);
if (delta >= 30) {
throw new Error('Detected ' + delta + ' seconds time difference between your browser and the server. Prometheus relies on accurate time and time drift might cause unexpected query results.');
}
})
.catch(error => this.setState({timeDriftError: error.message}));
} }
getKey() { getKey() {
@ -95,10 +152,20 @@ class PanelList extends Component {
render() { render() {
return ( return (
<> <>
<Row>
<Col>
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> {this.state.timeDriftError}</Alert>}
</Col>
</Row>
<Row>
<Col>
{this.state.fetchMetricsError && <Alert color="danger"><strong>Warning:</strong> Error fetching metrics list: {this.state.fetchMetricsError}</Alert>}
</Col>
</Row>
{this.state.panels.map(p => {this.state.panels.map(p =>
<Panel key={p.key} removePanel={() => this.removePanel(p.key)} metrics={this.state.metrics}/> <Panel key={p.key} removePanel={() => this.removePanel(p.key)} metrics={this.state.metrics}/>
)} )}
<Button color="primary" onClick={this.addPanel}>Add Panel</Button> <Button color="primary" className="add-panel-btn" onClick={this.addPanel}>Add Panel</Button>
</> </>
); );
} }
@ -121,21 +188,23 @@ class Panel extends Component {
stats: null, stats: null,
}; };
this.execute = this.execute.bind(this);
this.handleExpressionChange = this.handleExpressionChange.bind(this); this.handleExpressionChange = this.handleExpressionChange.bind(this);
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if (prevState.type !== this.state.type) { const needsRefresh = ['type', 'range', 'endTime', 'resolution'].some(v => {
this.execute(); return prevState[v] !== this.state[v];
})
if (needsRefresh) {
this.executeQuery();
} }
} }
componentDidMount() { componentDidMount() {
this.execute(); this.executeQuery();
} }
execute() { executeQuery = ()=> {
// TODO: Abort existing queries. // TODO: Abort existing queries.
if (this.state.expr === "") { if (this.state.expr === "") {
return; return;
@ -144,6 +213,7 @@ class Panel extends Component {
this.setState({loading: true}); this.setState({loading: true});
let endTime = this.getEndTime() / 1000; let endTime = this.getEndTime() / 1000;
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);
let url = new URL('http://demo.robustperception.io:9090/');//window.location.href); let url = new URL('http://demo.robustperception.io:9090/');//window.location.href);
@ -155,7 +225,7 @@ class Panel extends Component {
case 'graph': case 'graph':
url.pathname = '/api/v1/query_range' url.pathname = '/api/v1/query_range'
Object.assign(params, { Object.assign(params, {
start: endTime - this.state.range, start: startTime,
end: endTime, end: endTime,
step: resolution, step: resolution,
}) })
@ -173,24 +243,27 @@ class Panel extends Component {
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)
.then(resp => { .then(resp => resp.json())
if (resp.ok) { .then(json => {
return resp.json(); if (json.status !== 'success') {
} else { throw new Error(json.error || 'invalid response JSON');
throw new Error('Unexpected response status: ' + resp.statusText);
} }
})
.then(json =>
this.setState({ this.setState({
error: null, error: null,
data: json.data, data: json.data,
lastQueryParams: {
startTime: startTime,
endTime: endTime,
resolution: resolution,
},
loading: false, loading: false,
}) })
) })
.catch(error => { .catch(error => {
this.setState({ this.setState({
error, error: 'Error executing query: ' + error.message,
loading: false loading: false,
}) })
}); });
} }
@ -200,79 +273,23 @@ class Panel extends Component {
this.setState({expr: expr}); this.setState({expr: expr});
} }
timeFactors = { handleChangeRange = (range) => {
"y": 60 * 60 * 24 * 365, this.setState({range: range});
"w": 60 * 60 * 24 * 7,
"d": 60 * 60 * 24,
"h": 60 * 60,
"m": 60,
"s": 1
};
rangeSteps = [
"1s", "10s", "1m", "5m", "15m", "30m", "1h", "2h", "6h", "12h", "1d", "2d",
"1w", "2w", "4w", "8w", "1y", "2y"
];
parseDuration(rangeText) {
var rangeRE = new RegExp("^([0-9]+)([ywdhms]+)$");
var matches = rangeText.match(rangeRE);
if (!matches) { return; }
if (matches.length !== 3) {
return 60;
}
var value = parseInt(matches[1]);
var unit = matches[2];
return value * this.timeFactors[unit];
}
increaseRange = (event) => {
event.preventDefault();
for (let range of this.rangeSteps) {
let rangeSeconds = this.parseDuration(range);
if (this.state.range < rangeSeconds) {
this.setState({range: rangeSeconds}, this.execute)
return;
}
}
}
decreaseRange = (event) => {
event.preventDefault();
for (let range of this.rangeSteps.slice().reverse()) {
let rangeSeconds = this.parseDuration(range);
if (this.state.range > rangeSeconds) {
this.setState({range: rangeSeconds}, this.execute)
return;
}
}
}
changeRange = (event) => {
}
increaseEndTime = () => {
}
decreaseEndTime = () => {
} }
getEndTime = () => { getEndTime = () => {
if (this.state.endTime === null) { if (this.state.endTime === null) {
return moment(); return moment();
} }
return this.state.endTime(); return this.state.endTime;
} }
changeEndTime = () => { handleChangeEndTime = (endTime) => {
this.setState({endTime: endTime});
} }
changeResolution = () => { handleChangeResolution = (resolution) => {
this.setState({resolution: resolution});
} }
// getEndDate = () => { // getEndDate = () => {
@ -311,7 +328,7 @@ class Panel extends Component {
// self.submitQuery(); // self.submitQuery();
// }; // };
changeStacking = (stacked) => { handleChangeStacking = (stacked) => {
this.setState({stacked: stacked}); this.setState({stacked: stacked});
} }
@ -323,7 +340,7 @@ class Panel extends Component {
<ExpressionInput <ExpressionInput
value={this.state.expr} value={this.state.expr}
onChange={this.handleExpressionChange} onChange={this.handleExpressionChange}
execute={this.execute} executeQuery={this.executeQuery}
loading={this.state.loading} loading={this.state.loading}
metrics={this.props.metrics} metrics={this.props.metrics}
/> />
@ -339,7 +356,7 @@ class Panel extends Component {
</Row> </Row>
<Row> <Row>
<Col> <Col>
{this.state.error && <Alert color="danger">{this.state.error.toString()}</Alert>} {this.state.error && <Alert color="danger">{this.state.error}</Alert>}
</Col> </Col>
</Row> </Row>
<Row> <Row>
@ -372,16 +389,12 @@ class Panel extends Component {
resolution={this.state.resolution} resolution={this.state.resolution}
stacked={this.state.stacked} stacked={this.state.stacked}
decreaseRange={this.decreaseRange} onChangeRange={this.handleChangeRange}
increaseRange={this.increaseRange} onChangeEndTime={this.handleChangeEndTime}
changeRange={this.changeRange} onChangeResolution={this.handleChangeResolution}
decreaseEndTime={this.decreaseEndTime} onChangeStacking={this.handleChangeStacking}
increaseEndTime={this.increaseEndTime}
changeEndTime={this.changeEndTime}
changeResolution={this.changeResolution}
changeStacking={this.changeStacking}
/> />
<Graph data={this.state.data} /> <Graph data={this.state.data} stacked={this.state.stacked} queryParams={this.state.lastQueryParams} />
</> </>
} }
</TabPane> </TabPane>
@ -404,25 +417,17 @@ class Panel extends Component {
} }
class ExpressionInput extends Component { class ExpressionInput extends Component {
constructor(props) { handleKeyPress = (event) => {
super(props);
this.handleKeyPress = this.handleKeyPress.bind(this);
}
handleKeyPress(event) {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
this.props.execute(); this.props.executeQuery();
event.preventDefault(); event.preventDefault();
} }
} }
numRows() {
// TODO: Not ideal. This doesn't handle long lines.
return this.props.value.split(/\r\n|\r|\n/).length;
}
stateReducer = (state, changes) => { stateReducer = (state, changes) => {
return changes;
// 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.
switch (changes.type) { switch (changes.type) {
case Downshift.stateChangeTypes.keyDownEnter: case Downshift.stateChangeTypes.keyDownEnter:
case Downshift.stateChangeTypes.clickItem: case Downshift.stateChangeTypes.clickItem:
@ -436,6 +441,47 @@ class ExpressionInput extends Component {
} }
} }
renderAutosuggest = (downshift) => {
let matches = this.props.metrics.filter(item => !downshift.inputValue || item.includes(downshift.inputValue));
if (matches.length === 0 || !downshift.isOpen) {
return null;
}
return (
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
{
matches
.slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
.map((item, index) => (
<li
{...downshift.getItemProps({
key: item,
index,
item,
style: {
backgroundColor:
downshift.highlightedIndex === index ? 'lightgray' : 'white',
fontWeight: downshift.selectedItem === item ? 'bold' : 'normal',
},
})}
>
{item}
</li>
))
}
</ul>
);
}
componentDidMount() {
const $exprInput = window.$(this.exprInputRef);
$exprInput.on('input', () => {
const el = $exprInput.get(0);
const offset = el.offsetHeight - el.clientHeight;
$exprInput.css('height', 'auto').css('height', el.scrollHeight + offset);
});
}
render() { render() {
return ( return (
<Downshift <Downshift
@ -455,9 +501,10 @@ class ExpressionInput extends Component {
<Input <Input
autoFocus autoFocus
type="textarea" type="textarea"
rows={this.numRows()} rows={1}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
placeholder="Expression (press Shift+Enter for newlines)" placeholder="Expression (press Shift+Enter for newlines)"
innerRef={ref => this.exprInputRef = ref}
//onChange={selection => alert(`You selected ${selection}`)} //onChange={selection => alert(`You selected ${selection}`)}
{...downshift.getInputProps({ {...downshift.getInputProps({
onKeyDown: event => { onKeyDown: event => {
@ -477,34 +524,10 @@ class ExpressionInput extends Component {
})} })}
/> />
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button className="execute-btn" color="primary" onClick={this.props.execute}>Execute</Button> <Button className="execute-btn" color="primary" onClick={this.props.executeQuery}>Execute</Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
{downshift.isOpen && {this.renderAutosuggest(downshift)}
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
{
this.props.metrics
.filter(item => !downshift.inputValue || item.includes(downshift.inputValue))
.slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
.map((item, index) => (
<li
{...downshift.getItemProps({
key: item,
index,
item,
style: {
backgroundColor:
downshift.highlightedIndex === index ? 'lightgray' : 'white',
fontWeight: downshift.selectedItem === item ? 'bold' : 'normal',
},
})}
>
{item}
</li>
))
}
</ul>
}
</div> </div>
)} )}
</Downshift> </Downshift>
@ -512,33 +535,52 @@ class ExpressionInput extends Component {
} }
} }
function TabPaneAlert(props) {
const { color, message } = props;
return (
<>
{/* Without the following <div> hack, giving the <Alert> any top margin
will make the entire tab pane look detached. */}
<div style={{height: '1px'}}></div>
<Alert className="tabpane-alert" color={color}>{props.children}</Alert>
</>
);
}
function DataTable(props) { function DataTable(props) {
const data = props.data; const data = props.data;
var rows = <tr><td colSpan="2"><i>no data</i></td></tr>
if (data === null) {
return <TabPaneAlert color="light">No data queried yet</TabPaneAlert>;
}
if (data.result === null || data.result.length === 0) {
return <TabPaneAlert color="secondary">Empty query result</TabPaneAlert>;
}
let rows = [];
if (props.data) { if (props.data) {
switch(data.resultType) { switch(data.resultType) {
case 'vector': case 'vector':
if (data.result === null || data.result.length === 0) {
break;
}
rows = props.data.result.map((s, index) => { rows = props.data.result.map((s, index) => {
return <tr key={index}><td>{metricToSeriesName(s.metric)}</td><td>{s.value[1]}</td></tr> return <tr key={index}><td>{metricToSeriesName(s.metric)}</td><td>{s.value[1]}</td></tr>
}); });
break; break;
case 'matrix': case 'matrix':
if (data.result === null || data.result.length === 0) {
break;
}
rows = props.data.result.map((s, index) => { rows = props.data.result.map((s, index) => {
const valueText = s.values.map((v) => { const valueText = s.values.map((v) => {
return [1] + ' @' + v[0]; return [1] + ' @' + v[0];
}).join('\n'); }).join('\n');
return <tr key={index}><td>{metricToSeriesName(s.metric)}</td><td>{valueText}</td></tr> return <tr style={{'white-space': 'pre'}} key={index}><td>{metricToSeriesName(s.metric)}</td><td>{valueText}</td></tr>
}); });
break; 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: default:
// TODO return <TabPaneAlert color="danger">Unsupported result value type '{data.resultType}'</TabPaneAlert>;
} }
} }
@ -550,40 +592,171 @@ function DataTable(props) {
</Table> </Table>
); );
} }
class GraphControls extends Component { class GraphControls extends Component {
constructor(props) {
super(props);
this.state = {
startDate: Date.now(),
};
this.rangeRef = React.createRef();
this.endTimeRef = React.createRef();
}
rangeUnits = {
'y': 60 * 60 * 24 * 365,
'w': 60 * 60 * 24 * 7,
'd': 60 * 60 * 24,
'h': 60 * 60,
'm': 60,
's': 1
};
rangeSteps = [
'1s', '10s', '1m', '5m', '15m', '30m', '1h', '2h', '6h', '12h', '1d', '2d',
'1w', '2w', '4w', '8w', '1y', '2y'
];
parseRange(rangeText) {
var rangeRE = new RegExp('^([0-9]+)([ywdhms]+)$');
var matches = rangeText.match(rangeRE);
if (!matches || matches.length !== 3) {
return null;
}
var value = parseInt(matches[1]);
var unit = matches[2];
return value * this.rangeUnits[unit];
}
formatRange(range) {
for (let unit of Object.keys(this.rangeUnits)) {
if (range % this.rangeUnits[unit] === 0) {
return (range / this.rangeUnits[unit]) + unit;
}
}
return range + 's';
}
onRangeInputChanged = (rangeText) => {
const range = this.parseRange(rangeText);
if (range === null) {
this.changeRangeInput(this.formatRange(this.props.range));
} else {
this.props.onChangeRange(this.parseRange(rangeText));
}
}
changeRangeInput = (rangeText) => {
this.rangeRef.current.value = rangeText;
}
increaseRange = (event) => {
for (let range of this.rangeSteps) {
let rangeSeconds = this.parseRange(range);
if (this.props.range < rangeSeconds) {
this.changeRangeInput(range);
this.props.onChangeRange(rangeSeconds);
return;
}
}
}
decreaseRange = (event) => {
for (let range of this.rangeSteps.slice().reverse()) {
let rangeSeconds = this.parseRange(range);
if (this.props.range > rangeSeconds) {
this.changeRangeInput(range);
this.props.onChangeRange(rangeSeconds);
return;
}
}
}
getBaseEndTime = () => {
return this.props.endTime || moment();
}
increaseEndTime = (event) => {
const endTime = moment(this.getBaseEndTime() + this.props.range*1000/2);
this.props.onChangeEndTime(endTime);
this.$endTime.datetimepicker('date', endTime);
}
decreaseEndTime = (event) => {
const endTime = moment(this.getBaseEndTime() - this.props.range*1000/2);
this.props.onChangeEndTime(endTime);
this.$endTime.datetimepicker('date', endTime);
}
// TODO: Handle manual textual changes to datetime input.
componentDidMount() {
this.$endTime = window.$(this.endTimeRef.current);
this.$endTime.datetimepicker({
icons: {
clear: 'fas fa-trash',
today: 'fas fa-calendar-check',
},
buttons: {
showClear: true,
showClose: true,
showToday: true,
},
sideBySide: true,
format: 'YYYY-MM-DD HH:mm:ss',
locale: 'en',
timeZone: 'UTC',
});
this.$endTime.on('change.datetimepicker', e => {
this.props.onChangeEndTime(e.date);
});
}
render() { render() {
return ( return (
<Form inline className="graph-controls"> <Form inline className="graph-controls" onSubmit={e => e.preventDefault()}>
<InputGroup className="range-input"> <InputGroup className="range-input" size="sm">
<InputGroupAddon addonType="prepend"> <InputGroupAddon addonType="prepend">
<Button onClick={this.props.decreaseRange}>-</Button> <Button title="Decrease range" onClick={this.decreaseRange}><FontAwesomeIcon icon="minus" fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
<Input value={this.props.range} onChange={this.props.changeRange}/> {/* <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)}/>
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button onClick={this.props.increaseRange}>+</Button> <Button title="Increase range" onClick={this.increaseRange}><FontAwesomeIcon icon="plus" fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
<InputGroup className="endtime-input"> <InputGroup className="endtime-input" size="sm">
<InputGroupAddon addonType="prepend"> <InputGroupAddon addonType="prepend">
<Button onClick={this.props.decreaseEndTime}>&lt;&lt;</Button> <Button title="Decrease end time" onClick={this.decreaseEndTime}><FontAwesomeIcon icon="chevron-left" fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
<Input value={this.props.endTime ? this.props.endTime : ''} onChange={this.props.changeEndTime} /> <Input
placeholder="End time"
// value={this.props.endTime ? this.props.endTime : ''}
innerRef={this.endTimeRef}
// onChange={this.props.onChangeEndTime}
onFocus={() => this.$endTime.datetimepicker('show')}
onBlur={() => this.$endTime.datetimepicker('hide')}
onKeyDown={(e) => e.key === 'Escape' && this.$endTime.datetimepicker('hide')}
/>
{/* <input type="text" className="form-control datetimepicker-input" id="foo" data-toggle="datetimepicker" data-target="#foo"/> */}
<InputGroupAddon addonType="append"> <InputGroupAddon addonType="append">
<Button onClick={this.props.increaseEndTime}>&gt;&gt;</Button> <Button title="Increase end time" onClick={this.increaseEndTime}><FontAwesomeIcon icon="chevron-right" fixedWidth/></Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
<Input className="resolution-input" value={this.props.resolution ? this.props.resolution : ''} onChange={this.props.changeResolution} placeholder="Res. (s)"/> {/* 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"/>
<ButtonGroup className="stacked-input"> <ButtonGroup className="stacked-input" size="sm">
<Button onClick={() => this.props.changeStacking(false)} active={this.props.stacked}>stacked</Button> <Button title="Show unstacked line graph" onClick={() => this.props.onChangeStacking(false)} active={!this.props.stacked}><FontAwesomeIcon icon="chart-line" fixedWidth/></Button>
<Button onClick={() => this.props.changeStacking(true)} active={!this.props.stacked}>unstacked</Button> <Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}><FontAwesomeIcon icon="chart-area" fixedWidth/></Button>
</ButtonGroup> </ButtonGroup>
</Form> </Form>
); );
@ -597,14 +770,21 @@ function getGraphID() {
} }
class Graph extends Component { class Graph extends Component {
constructor(props) {
super(props);
this.state = {
legendRef: null,
};
}
escapeHTML(string) { escapeHTML(string) {
var entityMap = { var entityMap = {
"&": "&amp;", '&': '&amp;',
"<": "&lt;", '<': '&lt;',
">": "&gt;", '>': '&gt;',
'"': '&quot;', '"': '&quot;',
"'": '&#39;', "'": '&#39;',
"/": '&#x2F;' '/': '&#x2F;'
}; };
return String(string).replace(/[&<>"'/]/g, function (s) { return String(string).replace(/[&<>"'/]/g, function (s) {
@ -622,8 +802,130 @@ class Graph extends Component {
return labels = '<div class="labels">' + labelStrings.join('<br>') + '</div>'; return labels = '<div class="labels">' + labelStrings.join('<br>') + '</div>';
}; };
// axisUnits = [
// {unit: 'Y', factor: 1e24},
// {unit: 'Z', factor: 1e21},
// {unit: 'E', factor: 1e18},
// {unit: 'P', factor: 1e15},
// {unit: 'T', factor: 1e12},
// {unit: 'G', factor: 1e9},
// {unit: 'M', factor: 1e6},
// {unit: 'K', factor: 1e3},
// {unit: null,factor: 1},
// {unit: 'm', factor: 1e-3},
// {unit: 'µ', factor: 1e-6},
// {unit: 'n', factor: 1e-9},
// {unit: 'p', factor: 1e-12},
// {unit: 'f', factor: 1e-15},
// {unit: 'a', factor: 1e-18},
// {unit: 'z', factor: 1e-21},
// {unit: 'y', factor: 1e-24},
// ]
formatValue = (y) => {
var abs_y = Math.abs(y);
if (abs_y >= 1e24) {
return (y / 1e24).toFixed(2) + "Y";
} else if (abs_y >= 1e21) {
return (y / 1e21).toFixed(2) + "Z";
} else if (abs_y >= 1e18) {
return (y / 1e18).toFixed(2) + "E";
} else if (abs_y >= 1e15) {
return (y / 1e15).toFixed(2) + "P";
} else if (abs_y >= 1e12) {
return (y / 1e12).toFixed(2) + "T";
} else if (abs_y >= 1e9) {
return (y / 1e9).toFixed(2) + "G";
} else if (abs_y >= 1e6) {
return (y / 1e6).toFixed(2) + "M";
} else if (abs_y >= 1e3) {
return (y / 1e3).toFixed(2) + "k";
} else if (abs_y >= 1) {
return y.toFixed(2)
} else if (abs_y === 0) {
return y.toFixed(2)
} else if (abs_y <= 1e-24) {
return (y / 1e-24).toFixed(2) + "y";
} else if (abs_y <= 1e-21) {
return (y / 1e-21).toFixed(2) + "z";
} else if (abs_y <= 1e-18) {
return (y / 1e-18).toFixed(2) + "a";
} else if (abs_y <= 1e-15) {
return (y / 1e-15).toFixed(2) + "f";
} else if (abs_y <= 1e-12) {
return (y / 1e-12).toFixed(2) + "p";
} else if (abs_y <= 1e-9) {
return (y / 1e-9).toFixed(2) + "n";
} else if (abs_y <= 1e-6) {
return (y / 1e-6).toFixed(2) + "µ";
} else if (abs_y <=1e-3) {
return (y / 1e-3).toFixed(2) + "m";
} else if (abs_y <= 1) {
return y.toFixed(2)
}
}
getOptions() { getOptions() {
console.log(this.props);
return { return {
// colors: [
// '#7EB26D', // 0: pale green
// '#EAB839', // 1: mustard
// '#6ED0E0', // 2: light blue
// '#EF843C', // 3: orange
// '#E24D42', // 4: red
// '#1F78C1', // 5: ocean
// '#BA43A9', // 6: purple
// '#705DA0', // 7: violet
// '#508642', // 8: dark green
// '#CCA300', // 9: dark sand
// '#447EBC',
// '#C15C17',
// '#890F02',
// '#0A437C',
// '#6D1F62',
// '#584477',
// '#B7DBAB',
// '#F4D598',
// '#70DBED',
// '#F9BA8F',
// '#F29191',
// '#82B5D8',
// '#E5A8E2',
// '#AEA2E0',
// '#629E51',
// '#E5AC0E',
// '#64B0C8',
// '#E0752D',
// '#BF1B00',
// '#0A50A1',
// '#962D82',
// '#614D93',
// '#9AC48A',
// '#F2C96D',
// '#65C5DB',
// '#F9934E',
// '#EA6460',
// '#5195CE',
// '#D683CE',
// '#806EB7',
// '#3F6833',
// '#967302',
// '#2F575E',
// '#99440A',
// '#58140C',
// '#052B51',
// '#511749',
// '#3F2B5B',
// '#E0F9D7',
// '#FCEACA',
// '#CFFAFF',
// '#F9E2D2',
// '#FCE2DE',
// '#BADFF4',
// '#F9D9F9',
// '#DEDAF7',
// ],
grid: { grid: {
hoverable: true, hoverable: true,
clickable: true, clickable: true,
@ -631,7 +933,7 @@ class Graph extends Component {
mouseActiveRadius: 100, mouseActiveRadius: 100,
}, },
legend: { legend: {
container: this.legend, container: this.state.legendRef,
labelFormatter: (s) => {return '&nbsp;&nbsp;' + s} labelFormatter: (s) => {return '&nbsp;&nbsp;' + s}
}, },
xaxis: { xaxis: {
@ -641,6 +943,9 @@ class Graph extends Component {
// min: (new Date()).getTime(), // min: (new Date()).getTime(),
// max: (new Date(2000, 1, 1)).getTime(), // max: (new Date(2000, 1, 1)).getTime(),
}, },
yaxis: {
tickFormatter: this.formatValue,
},
crosshair: { crosshair: {
mode: 'xy', mode: 'xy',
color: '#bbb', color: '#bbb',
@ -659,9 +964,11 @@ class Graph extends Component {
lines: true, lines: true,
}, },
series: { series: {
stack: this.props.stacked,
lines: { lines: {
lineWidth: 2, lineWidth: this.props.stacked ? 1 : 2,
steps: false, steps: false,
fill: this.props.stacked,
}, },
shadowSize: 0, shadowSize: 0,
} }
@ -675,10 +982,24 @@ class Graph extends Component {
} }
return this.props.data.result.map(ts => { return this.props.data.result.map(ts => {
// Insert nulls for all missing steps.
let data = [];
let pos = 0;
const params = this.props.queryParams;
for (let t = params.startTime; t <= params.endTime; t += params.resolution) {
// Allow for floating point inaccuracy.
if (ts.values.length > pos && ts.values[pos][0] < t + params.resolution / 100) {
data.push([ts.values[pos][0] * 1000, this.parseValue(ts.values[pos][1])]);
pos++;
} else {
data.push([t * 1000, null]);
}
}
return { return {
label: metricToSeriesName(ts.metric), label: ts.metric !== null ? metricToSeriesName(ts.metric, true) : 'scalar',
labels: ts.metric, labels: ts.metric !== null ? ts.metric : {},
data: ts.values.map(v => [v[0] * 1000, this.parseValue(v[1])]), data: data,
}; };
}) })
} }
@ -695,32 +1016,45 @@ class Graph extends Component {
render() { render() {
if (this.props.data === null) { if (this.props.data === null) {
return null; return <TabPaneAlert color="light">No data queried yet</TabPaneAlert>;
} }
if (this.props.data.resultType !== 'matrix') {
return <TabPaneAlert color="danger">Query result is of wrong type '{this.props.data.resultType}', should be 'matrix' (range vector).</TabPaneAlert>;
}
if (this.props.data.result.length === 0) {
return <TabPaneAlert color="secondary">Empty query result</TabPaneAlert>;
}
return ( return (
<div className="graph"> <div className="graph">
<ReactFlot {this.state.legendRef &&
id={getGraphID().toString()} <ReactFlot
data={this.getData()} id={getGraphID().toString()}
options={this.getOptions()} data={this.getData()}
height="500px" options={this.getOptions()}
width="100%" height="500px"
/> width="100%"
<div className="graph-legend" ref={ref => { this.legend = ref; }}></div> />
}
{/* Really nasty hack below with setState to trigger a second render after the legend div starts to exist. */}
<div className="graph-legend" ref={ref => {!this.state.legendRef && this.setState({legendRef: ref})}}></div>
</div> </div>
); );
} }
} }
function metricToSeriesName(labels) { function metricToSeriesName(labels, formatHTML) {
var tsName = (labels.__name__ || '') + "{"; var tsName = (labels.__name__ || '') + "{";
var labelStrings = []; var labelStrings = [];
for (var label in labels) { for (var label in labels) {
if (label !== "__name__") { if (label !== '__name__') {
labelStrings.push('<b>' + label + "</b>=\"" + labels[label] + "\""); labelStrings.push((formatHTML ? '<b>' : '') + label + (formatHTML ? '</b>' : '') + '="' + labels[label] + '"');
} }
} }
tsName += labelStrings.join(", ") + "}"; tsName += labelStrings.join(', ') + '}';
return tsName; return tsName;
}; };

View file

@ -3,3 +3,5 @@ import jquery from 'jquery';
window.jQuery = jquery; window.jQuery = jquery;
window.jquery = jquery; window.jquery = jquery;
window.$ = jquery; window.$ = jquery;
window.moment = require('moment');