Add URL params support and much more

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2019-02-17 01:23:51 +01:00
parent b2b4d3a18d
commit d9cc501dff
13 changed files with 367 additions and 107 deletions

View file

@ -11,7 +11,7 @@ body {
}
.expression-input textarea {
/* font-family: 'Courier New', Courier, monospace; */
font-family: Menlo,Monaco,Consolas,'Courier New',monospace;
resize: none;
}
@ -92,7 +92,7 @@ button.execute-btn {
}
div.endtime-input {
width: 270px !important;
width: 240px !important;
}
.table-controls input {
@ -128,6 +128,14 @@ div.endtime-input {
margin: 2px 8px 2px 0;
}
.legend-metric-name {
margin-right: 1px;
}
.legend-label-name {
font-weight: bold;
}
.graph {
margin: 0 5px 0 5px;
}
@ -168,6 +176,5 @@ div.endtime-input {
}
.add-panel-btn {
margin-top: -20px;
margin-bottom: 20px;
}

View file

@ -2,8 +2,7 @@ import React, { PureComponent, ReactNode } from 'react';
import { Alert, Table } from 'reactstrap';
import metricToSeriesName from './MetricFomat';
import { ReactComponent } from '*.svg';
import SeriesName from './SeriesName';
export interface QueryResult {
data: null | {
@ -48,6 +47,7 @@ class DataTable extends PureComponent<QueryResult> {
}
render() {
console.log("RENDER!");
const data = this.props.data;
if (data === null) {
@ -64,7 +64,7 @@ class DataTable extends PureComponent<QueryResult> {
case 'vector':
rows = (this.limitSeries(data.result) as InstantSample[])
.map((s: InstantSample, index: number): ReactNode => {
return <tr key={index}><td>{metricToSeriesName(s.metric, false)}</td><td>{s.value[1]}</td></tr>
return <tr key={index}><td><SeriesName labels={s.metric} format={false}/></td><td>{s.value[1]}</td></tr>;
});
limited = rows.length != data.result.length;
break;
@ -74,7 +74,7 @@ class DataTable extends PureComponent<QueryResult> {
const valueText = s.values.map((v) => {
return [1] + ' @' + v[0];
}).join('\n');
return <tr style={{whiteSpace: 'pre'}} key={index}><td>{metricToSeriesName(s.metric, false)}</td><td>{valueText}</td></tr>
return <tr style={{whiteSpace: 'pre'}} key={index}><td><SeriesName labels={s.metric} format={false}/></td><td>{valueText}</td></tr>;
});
limited = rows.length != data.result.length;
break;

View file

@ -37,8 +37,15 @@ class ExpressionInput extends Component<ExpressionInputProps> {
}
renderAutosuggest = (downshift: any) => {
if (!downshift.isOpen) {
return null;
}
if (this.prevNoMatchValue && downshift.inputValue.includes(this.prevNoMatchValue)) {
// TODO: Is this still correct with fuzzy?
if (downshift.isOpen) {
downshift.closeMenu();
}
return null;
}
@ -52,10 +59,6 @@ class ExpressionInput extends Component<ExpressionInputProps> {
return null;
}
if (!downshift.isOpen) {
return null; // TODO CHECK NEED FOR THIS
}
return (
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
{
@ -125,9 +128,20 @@ class ExpressionInput extends Component<ExpressionInputProps> {
// 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) {
(event.nativeEvent as any).preventDownshiftDefault = true;
}
break;
case 'Enter':
downshift.closeMenu();
break;
case 'Escape':
if (!downshift.isOpen) {
this.exprInputRef.current!.blur();
}
break;
default:
}
}

View file

@ -58,7 +58,10 @@ class Graph extends PureComponent<GraphProps> {
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
};
formatValue = (y: number): string => {
formatValue = (y: number | null): string => {
if (y === null) {
return 'null';
}
var abs_y = Math.abs(y);
if (abs_y >= 1e24) {
return (y / 1e24).toFixed(2) + "Y";
@ -151,6 +154,7 @@ class Graph extends PureComponent<GraphProps> {
};
}
// This was adapted from Flot's color generation code.
getColors() {
let colors = [];
const colorPool = ["#edc240", "#afd8f8", "#cb4b4b", "#4da74d", "#9440ed"];
@ -191,13 +195,17 @@ class Graph extends PureComponent<GraphProps> {
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]);
// TODO: Flot has problems displaying intermittent "null" values when stacked,
// resort to 0 now. In Grafana this works for some reason, figure out how they
// do it.
data.push([t * 1000, this.props.stacked ? 0 : null]);
}
}
@ -205,6 +213,7 @@ class Graph extends PureComponent<GraphProps> {
labels: ts.metric !== null ? ts.metric : {},
data: data,
color: colors[index],
index: index,
};
})
}
@ -214,7 +223,11 @@ class Graph extends PureComponent<GraphProps> {
if (isNaN(val)) {
// "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They
// can't be graphed, so show them as gaps (null).
return null;
// TODO: Flot has problems displaying intermittent "null" values when stacked,
// resort to 0 now. In Grafana this works for some reason, figure out how they
// do it.
return this.props.stacked ? 0 : null;
}
return val;
};

View file

@ -18,6 +18,7 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import TimeInput from './TimeInput';
import { parseRange, formatRange } from './utils/timeFormat';
library.add(
faPlus,
@ -42,15 +43,6 @@ class GraphControls extends Component<GraphControlsProps> {
private rangeRef = React.createRef<HTMLInputElement>();
private resolutionRef = React.createRef<HTMLInputElement>();
rangeUnits: {[unit: string]: number} = {
'y': 60 * 60 * 24 * 365,
'w': 60 * 60 * 24 * 7,
'd': 60 * 60 * 24,
'h': 60 * 60,
'm': 60,
's': 1
}
rangeSteps = [
1,
10,
@ -72,28 +64,8 @@ class GraphControls extends Component<GraphControlsProps> {
730*24*60*60,
]
parseRange(rangeText: string): number | null {
const rangeRE = new RegExp('^([0-9]+)([ywdhms]+)$');
const matches = rangeText.match(rangeRE);
if (!matches || matches.length !== 3) {
return null;
}
const value = parseInt(matches[1]);
const unit = matches[2];
return value * this.rangeUnits[unit];
}
formatRange(range: number): string {
for (let unit of Object.keys(this.rangeUnits)) {
if (range % this.rangeUnits[unit] === 0) {
return (range / this.rangeUnits[unit]) + unit;
}
}
return range + 's';
}
onChangeRangeInput = (rangeText: string): void => {
const range = this.parseRange(rangeText);
const range = parseRange(rangeText);
if (range === null) {
this.changeRangeInput(this.props.range);
} else {
@ -102,7 +74,7 @@ class GraphControls extends Component<GraphControlsProps> {
}
changeRangeInput = (range: number): void => {
this.rangeRef.current!.value = this.formatRange(range);
this.rangeRef.current!.value = formatRange(range);
}
increaseRange = (): void => {
@ -134,7 +106,7 @@ class GraphControls extends Component<GraphControlsProps> {
</InputGroupAddon>
<Input
defaultValue={this.formatRange(this.props.range)}
defaultValue={formatRange(this.props.range)}
innerRef={this.rangeRef}
onBlur={() => this.onChangeRangeInput(this.rangeRef.current!.value)}
/>

View file

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import metricToSeriesName from './MetricFomat';
import SeriesName from './SeriesName';
interface LegendProps {
series: any; // TODO: Type this.
@ -8,13 +8,14 @@ interface LegendProps {
class Legend extends PureComponent<LegendProps> {
renderLegendItem(s: any) {
const seriesName = metricToSeriesName(s.labels, false);
return (
<tr key={seriesName} className="legend-item">
<tr key={s.index} className="legend-item">
<td>
<div className="legend-swatch" style={{backgroundColor: s.color}}></div>
</td>
<td>{seriesName}</td>
<td>
<SeriesName labels={s.labels} format={true} />
</td>
</tr>
);
}

View file

@ -1,4 +1,4 @@
function metricToSeriesName(labels: {[key: string]: string}, formatHTML: boolean): string {
function metricToSeriesName(labels: {[key: string]: string}): string {
if (labels === null) {
return 'scalar';
}
@ -6,7 +6,7 @@ function metricToSeriesName(labels: {[key: string]: string}, formatHTML: boolean
let labelStrings: string[] = [];
for (let label in labels) {
if (label !== '__name__') {
labelStrings.push((formatHTML ? '<b>' : '') + label + (formatHTML ? '</b>' : '') + '="' + labels[label] + '"');
labelStrings.push(label + '="' + labels[label] + '"');
}
}
tsName += labelStrings.join(', ') + '}';

View file

@ -22,18 +22,14 @@ import TimeInput from './TimeInput';
interface PanelProps {
metricNames: string[];
initialOptions?: PanelOptions | undefined;
removePanel: () => void;
// TODO Put initial panel values here.
}
interface PanelState {
expr: string;
type: 'graph' | 'table';
range: number;
endTime: number | null;
resolution: number | null;
stacked: boolean;
data: any; // TODO: Define data.
options: PanelOptions;
data: any; // TODO: Type data.
lastQueryParams: { // TODO: Share these with Graph.tsx in a file.
startTime: number,
endTime: number,
@ -44,6 +40,29 @@ interface PanelState {
stats: null; // TODO: Stats.
}
export interface PanelOptions {
expr: string;
type: PanelType;
range: number; // Range in seconds.
endTime: number | null; // Timestamp in milliseconds.
resolution: number | null; // Resolution in seconds.
stacked: boolean;
}
export enum PanelType {
Graph = 'graph',
Table = 'table',
}
export const PanelDefaultOptions: PanelOptions = {
type: PanelType.Table,
expr: 'rate(node_cpu_seconds_total[5m])',
range: 3600,
endTime: null,
resolution: null,
stacked: false,
}
class Panel extends Component<PanelProps, PanelState> {
private abortInFlightFetch: (() => void) | null = null;
@ -51,12 +70,14 @@ class Panel extends Component<PanelProps, PanelState> {
super(props);
this.state = {
expr: 'rate(node_cpu_seconds_total[1m])',
type: 'graph',
options: this.props.initialOptions ? this.props.initialOptions : {
expr: '',
type: PanelType.Table,
range: 3600,
endTime: null, // This is in milliseconds.
resolution: null,
stacked: false,
},
data: null,
lastQueryParams: null,
loading: false,
@ -66,12 +87,14 @@ class Panel extends Component<PanelProps, PanelState> {
}
componentDidUpdate(prevProps: PanelProps, prevState: PanelState) {
if (prevState.type !== this.state.type ||
prevState.range !== this.state.range ||
prevState.endTime !== this.state.endTime ||
prevState.resolution !== this.state.resolution) {
const prevOpts = prevState.options;
const opts = this.state.options;
if (prevOpts.type !== opts.type ||
prevOpts.range !== opts.range ||
prevOpts.endTime !== opts.endTime ||
prevOpts.resolution !== opts.resolution) {
if (prevState.type !== this.state.type) {
if (prevOpts.type !== opts.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.
@ -86,7 +109,7 @@ class Panel extends Component<PanelProps, PanelState> {
}
executeQuery = (): void => {
if (this.state.expr === '') {
if (this.state.options.expr === '') {
return;
}
@ -100,15 +123,15 @@ class Panel extends Component<PanelProps, PanelState> {
this.setState({loading: true});
const endTime = this.getEndTime().valueOf() / 1000; // TODO: shouldn'T valueof only work when it's a moment?
const startTime = endTime - this.state.range;
const resolution = this.state.resolution || Math.max(Math.floor(this.state.range / 250), 1);
const startTime = endTime - this.state.options.range;
const resolution = this.state.options.resolution || Math.max(Math.floor(this.state.options.range / 250), 1);
const url = new URL('http://demo.robustperception.io:9090/');//window.location.href);
const params: {[key: string]: string} = {
'query': this.state.expr,
'query': this.state.options.expr,
};
switch (this.state.type) {
switch (this.state.options.type) {
case 'graph':
url.pathname = '/api/v1/query_range'
Object.assign(params, {
@ -125,7 +148,7 @@ class Panel extends Component<PanelProps, PanelState> {
})
break;
default:
throw new Error('Invalid panel type "' + this.state.type + '"');
throw new Error('Invalid panel type "' + this.state.options.type + '"');
}
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]))
@ -160,35 +183,40 @@ class Panel extends Component<PanelProps, PanelState> {
});
}
setOptions(opts: object): void {
const newOpts = Object.assign({}, this.state.options);
this.setState({options: Object.assign(newOpts, opts)});
}
handleExpressionChange = (expr: string): void => {
this.setState({expr: expr});
this.setOptions({expr: expr});
}
handleChangeRange = (range: number): void => {
this.setState({range: range});
this.setOptions({range: range});
}
getEndTime = (): number | moment.Moment => {
if (this.state.endTime === null) {
if (this.state.options.endTime === null) {
return moment();
}
return this.state.endTime;
return this.state.options.endTime;
}
handleChangeEndTime = (endTime: number | null) => {
this.setState({endTime: endTime});
this.setOptions({endTime: endTime});
}
handleChangeResolution = (resolution: number) => {
// 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});
this.setOptions({resolution: resolution});
}
}
handleChangeStacking = (stacked: boolean) => {
this.setState({stacked: stacked});
this.setOptions({stacked: stacked});
}
render() {
@ -197,7 +225,7 @@ class Panel extends Component<PanelProps, PanelState> {
<Row>
<Col>
<ExpressionInput
value={this.state.expr}
value={this.state.options.expr}
onChange={this.handleExpressionChange}
executeQuery={this.executeQuery}
loading={this.state.loading}
@ -215,45 +243,45 @@ class Panel extends Component<PanelProps, PanelState> {
<Nav tabs>
<NavItem>
<NavLink
className={this.state.type === 'graph' ? 'active' : ''}
onClick={() => { this.setState({type: 'graph'}); }}
className={this.state.options.type === 'graph' ? 'active' : ''}
onClick={() => { this.setOptions({type: 'graph'}); }}
>
Graph
</NavLink>
</NavItem>
<NavItem>
<NavLink
className={this.state.type === 'table' ? 'active' : ''}
onClick={() => { this.setState({type: 'table'}); }}
className={this.state.options.type === 'table' ? 'active' : ''}
onClick={() => { this.setOptions({type: 'table'}); }}
>
Table
</NavLink>
</NavItem>
</Nav>
<TabContent activeTab={this.state.type}>
<TabContent activeTab={this.state.options.type}>
<TabPane tabId="graph">
{this.state.type === 'graph' &&
{this.state.options.type === 'graph' &&
<>
<GraphControls
range={this.state.range}
endTime={this.state.endTime}
resolution={this.state.resolution}
stacked={this.state.stacked}
range={this.state.options.range}
endTime={this.state.options.endTime}
resolution={this.state.options.resolution}
stacked={this.state.options.stacked}
onChangeRange={this.handleChangeRange}
onChangeEndTime={this.handleChangeEndTime}
onChangeResolution={this.handleChangeResolution}
onChangeStacking={this.handleChangeStacking}
/>
<Graph data={this.state.data} stacked={this.state.stacked} queryParams={this.state.lastQueryParams} />
<Graph data={this.state.data} stacked={this.state.options.stacked} queryParams={this.state.lastQueryParams} />
</>
}
</TabPane>
<TabPane tabId="table">
{this.state.type === 'table' &&
{this.state.options.type === 'table' &&
<>
<div className="table-controls">
<TimeInput endTime={this.state.endTime} range={this.state.range} onChangeEndTime={this.handleChangeEndTime} />
<TimeInput endTime={this.state.options.endTime} range={this.state.options.range} onChangeEndTime={this.handleChangeEndTime} />
</div>
<DataTable data={this.state.data} />
</>

View file

@ -2,15 +2,17 @@ import React, { Component } from 'react';
import { Alert, Button, Col, Row } from 'reactstrap';
import Panel from './Panel';
import Panel, { PanelOptions, PanelType, PanelDefaultOptions } from './Panel';
import { getPanelOptionsFromQueryString } from './utils/urlParams';
interface PanelListState {
panels: {
key: string,
key: string;
initialOptions?: PanelOptions;
}[],
metricNames: string[],
fetchMetricsError: string | null,
timeDriftError: string | null,
metricNames: string[];
fetchMetricsError: string | null;
timeDriftError: string | null;
}
class PanelList extends Component<any, PanelListState> {
@ -19,8 +21,20 @@ class PanelList extends Component<any, PanelListState> {
constructor(props: any) {
super(props);
const urlPanels = getPanelOptionsFromQueryString(window.location.search).map((opts: PanelOptions) => {
return {
key: this.getKey(),
initialOptions: opts,
};
});
this.state = {
panels: [],
panels: urlPanels.length !== 0 ? urlPanels : [
{
key: this.getKey(),
initialOptions: PanelDefaultOptions,
},
],
metricNames: [],
fetchMetricsError: null,
timeDriftError: null,
@ -30,8 +44,6 @@ class PanelList extends Component<any, PanelListState> {
}
componentDidMount() {
this.addPanel();
fetch("http://demo.robustperception.io:9090/api/v1/label/__name__/values", {cache: "no-store"})
.then(resp => {
if (resp.ok) {
@ -95,7 +107,12 @@ class PanelList extends Component<any, PanelListState> {
</Col>
</Row>
{this.state.panels.map(p =>
<Panel key={p.key} removePanel={() => this.removePanel(p.key)} metricNames={this.state.metricNames}/>
<Panel
key={p.key}
initialOptions={p.initialOptions}
removePanel={() => this.removePanel(p.key)}
metricNames={this.state.metricNames}
/>
)}
<Button color="primary" className="add-panel-btn" onClick={this.addPanel}>Add Panel</Button>
</>

70
src/SeriesName.tsx Normal file
View file

@ -0,0 +1,70 @@
import React, { PureComponent } from "react";
interface SeriesNameProps {
labels: {[key: string]: string} | null;
format: boolean;
}
class SeriesName extends PureComponent<SeriesNameProps> {
renderFormatted(): React.ReactNode {
const labels = this.props.labels!;
let labelNodes: React.ReactNode[] = [];
let first = true;
for (let label in labels) {
if (label === '__name__') {
continue;
}
labelNodes.push(
<span key={label}>
{!first && ', '}
<span className="legend-label-name">{label}</span>=
<span className="legend-label-value">"{labels[label]}"</span>
</span>
);
if (first) {
first = false;
}
}
return (
<>
<span className="legend-metric-name">{labels.__name__ || ''}</span>
<span className="legend-label-brace">{'{'}</span>
{labelNodes}
<span className="legend-label-brace">{'}'}</span>
</>
);
}
renderPlain() {
const labels = this.props.labels!;
let tsName = (labels.__name__ || '') + '{';
let labelStrings: string[] = [];
for (let label in labels) {
if (label !== '__name__') {
labelStrings.push(label + '="' + labels[label] + '"');
}
}
tsName += labelStrings.join(', ') + '}';
return tsName;
}
render() {
if (this.props.labels === null) {
return 'scalar';
}
if (this.props.format) {
return this.renderFormatted();
}
// Return a simple text node. This is much faster to scroll through
// for longer lists (hundreds of items).
return this.renderPlain();
}
}
export default SeriesName;

View file

@ -76,7 +76,7 @@ class TimeInput extends Component<TimeInputProps> {
showToday: true,
},
sideBySide: true,
format: 'YYYY-MM-DD HH:mm:ss',
format: 'YYYY-MM-DD HH:MM',
locale: 'en',
timeZone: 'UTC',
defaultDate: this.props.endTime,

38
src/utils/timeFormat.ts Normal file
View file

@ -0,0 +1,38 @@
import moment from 'moment-timezone';
const rangeUnits: {[unit: string]: number} = {
'y': 60 * 60 * 24 * 365,
'w': 60 * 60 * 24 * 7,
'd': 60 * 60 * 24,
'h': 60 * 60,
'm': 60,
's': 1
}
export function parseRange(rangeText: string): number | null {
const rangeRE = new RegExp('^([0-9]+)([ywdhms]+)$');
const matches = rangeText.match(rangeRE);
if (!matches || matches.length !== 3) {
return null;
}
const value = parseInt(matches[1]);
const unit = matches[2];
return value * rangeUnits[unit];
}
export function formatRange(range: number): string {
for (let unit of Object.keys(rangeUnits)) {
if (range % rangeUnits[unit] === 0) {
return (range / rangeUnits[unit]) + unit;
}
}
return range + 's';
}
export function parseTime(timeText: string): number {
return moment.utc(timeText).valueOf();
}
export function formatTime(time: number): string {
return moment.utc(time).format('YYYY-MM-DD HH:mm');
}

100
src/utils/urlParams.ts Normal file
View file

@ -0,0 +1,100 @@
import { parseRange, parseTime } from './timeFormat';
import { PanelOptions, PanelType, PanelDefaultOptions } from '../Panel';
export function getPanelOptionsFromQueryString(query: string): PanelOptions[] {
if (query === '') {
return [];
}
const params = query.substring(1).split('&');
return parseParams(params);
}
const paramFormat = /^g\d+\..+=.+$/;
interface IncompletePanelOptions {
expr?: string;
type?: PanelType;
range?: number;
endTime?: number | null;
resolution?: number | null;
stacked?: boolean;
}
function parseParams(params: string[]): PanelOptions[] {
const sortedParams = params.filter((p) => {
return paramFormat.test(p);
}).sort();
let panelOpts: PanelOptions[] = [];
let key = 0;
let options: IncompletePanelOptions = {};
for (const p of sortedParams) {
const prefix = 'g' + key + '.';
if (!p.startsWith(prefix)) {
panelOpts.push({
...PanelDefaultOptions,
...options,
});
options = {};
key++;
}
addParam(options, p.substring(prefix.length));
}
panelOpts.push({
...PanelDefaultOptions,
...options,
});
return panelOpts;
}
function addParam(opts: IncompletePanelOptions, param: string): void {
let [ opt, val ] = param.split('=');
val = decodeURIComponent(val.replace(/\+/g, ' '));
console.log(val);
switch(opt) {
case 'expr':
console.log(val);
opts.expr = val;
break;
case 'tab':
if (val === '0') {
opts.type = PanelType.Graph;
} else {
opts.type = PanelType.Table;
}
break;
case 'stacked':
opts.stacked = val === '1';
break;
case 'range_input':
const range = parseRange(val);
if (range !== null) {
opts.range = range;
}
break;
case 'end_input':
opts.endTime = parseTime(val);
break;
case 'step_input':
const res = parseInt(val)
if (res > 0) {
opts.resolution = res;
}
break;
case 'moment_input':
opts.endTime = parseTime(val);
break;
}
}