From d9cc501dff22242246cec7bb4514f6e9744f0e2d Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Sun, 17 Feb 2019 01:23:51 +0100 Subject: [PATCH] Add URL params support and much more Signed-off-by: Julius Volz --- src/App.css | 13 ++++- src/DataTable.tsx | 8 +-- src/ExpressionInput.tsx | 22 ++++++-- src/Graph.tsx | 19 ++++++- src/GraphControls.tsx | 36 ++---------- src/Legend.tsx | 9 +-- src/MetricFomat.ts | 4 +- src/Panel.tsx | 118 +++++++++++++++++++++++++--------------- src/PanelList.tsx | 35 +++++++++--- src/SeriesName.tsx | 70 ++++++++++++++++++++++++ src/TimeInput.tsx | 2 +- src/utils/timeFormat.ts | 38 +++++++++++++ src/utils/urlParams.ts | 100 ++++++++++++++++++++++++++++++++++ 13 files changed, 367 insertions(+), 107 deletions(-) create mode 100644 src/SeriesName.tsx create mode 100644 src/utils/timeFormat.ts create mode 100644 src/utils/urlParams.ts diff --git a/src/App.css b/src/App.css index a5ca3c0558..9a4fe38365 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/DataTable.tsx b/src/DataTable.tsx index 21bd171b43..64526c9e59 100644 --- a/src/DataTable.tsx +++ b/src/DataTable.tsx @@ -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 { } render() { + console.log("RENDER!"); const data = this.props.data; if (data === null) { @@ -64,7 +64,7 @@ class DataTable extends PureComponent { case 'vector': rows = (this.limitSeries(data.result) as InstantSample[]) .map((s: InstantSample, index: number): ReactNode => { - return {metricToSeriesName(s.metric, false)}{s.value[1]} + return {s.value[1]}; }); limited = rows.length != data.result.length; break; @@ -74,7 +74,7 @@ class DataTable extends PureComponent { const valueText = s.values.map((v) => { return [1] + ' @' + v[0]; }).join('\n'); - return {metricToSeriesName(s.metric, false)}{valueText} + return {valueText}; }); limited = rows.length != data.result.length; break; diff --git a/src/ExpressionInput.tsx b/src/ExpressionInput.tsx index b17bf2678d..c1c0adc6fd 100644 --- a/src/ExpressionInput.tsx +++ b/src/ExpressionInput.tsx @@ -37,8 +37,15 @@ class ExpressionInput extends Component { } 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 { return null; } - if (!downshift.isOpen) { - return null; // TODO CHECK NEED FOR THIS - } - return (
    { @@ -125,9 +128,20 @@ class ExpressionInput extends Component { // 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: } } diff --git a/src/Graph.tsx b/src/Graph.tsx index 93982a326c..984850153e 100644 --- a/src/Graph.tsx +++ b/src/Graph.tsx @@ -58,7 +58,10 @@ class Graph extends PureComponent { return '
    ' + labelStrings.join('
    ') + '
    '; }; - 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 { }; } + // 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 { 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 { labels: ts.metric !== null ? ts.metric : {}, data: data, color: colors[index], + index: index, }; }) } @@ -214,7 +223,11 @@ class Graph extends PureComponent { 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; }; diff --git a/src/GraphControls.tsx b/src/GraphControls.tsx index 21035fa485..d1453872db 100644 --- a/src/GraphControls.tsx +++ b/src/GraphControls.tsx @@ -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 { private rangeRef = React.createRef(); private resolutionRef = React.createRef(); - 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 { 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 { } 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 { this.onChangeRangeInput(this.rangeRef.current!.value)} /> diff --git a/src/Legend.tsx b/src/Legend.tsx index 272b96e834..a113b569aa 100644 --- a/src/Legend.tsx +++ b/src/Legend.tsx @@ -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 { renderLegendItem(s: any) { - const seriesName = metricToSeriesName(s.labels, false); return ( - +
    - {seriesName} + + + ); } diff --git a/src/MetricFomat.ts b/src/MetricFomat.ts index b10f4d83c0..7558b3005c 100644 --- a/src/MetricFomat.ts +++ b/src/MetricFomat.ts @@ -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 ? '' : '') + label + (formatHTML ? '' : '') + '="' + labels[label] + '"'); + labelStrings.push(label + '="' + labels[label] + '"'); } } tsName += labelStrings.join(', ') + '}'; diff --git a/src/Panel.tsx b/src/Panel.tsx index d4dec49c70..eddfbddff9 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -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 { private abortInFlightFetch: (() => void) | null = null; @@ -51,12 +70,14 @@ class Panel extends Component { super(props); this.state = { - expr: 'rate(node_cpu_seconds_total[1m])', - type: 'graph', - range: 3600, - endTime: null, // This is in milliseconds. - resolution: null, - stacked: false, + 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 { } 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 { } executeQuery = (): void => { - if (this.state.expr === '') { + if (this.state.options.expr === '') { return; } @@ -100,15 +123,15 @@ class Panel extends Component { 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 { }) 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 { }); } + 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 { { - + - {this.state.type === 'graph' && + {this.state.options.type === 'graph' && <> - + } - {this.state.type === 'table' && + {this.state.options.type === 'table' && <>
    - +
    diff --git a/src/PanelList.tsx b/src/PanelList.tsx index 0f60b6ad3d..0fe789fd13 100644 --- a/src/PanelList.tsx +++ b/src/PanelList.tsx @@ -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 { @@ -19,8 +21,20 @@ class PanelList extends Component { 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 { } 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 {
    {this.state.panels.map(p => - this.removePanel(p.key)} metricNames={this.state.metricNames}/> + this.removePanel(p.key)} + metricNames={this.state.metricNames} + /> )} diff --git a/src/SeriesName.tsx b/src/SeriesName.tsx new file mode 100644 index 0000000000..459aa84cf8 --- /dev/null +++ b/src/SeriesName.tsx @@ -0,0 +1,70 @@ +import React, { PureComponent } from "react"; + +interface SeriesNameProps { + labels: {[key: string]: string} | null; + format: boolean; +} + +class SeriesName extends PureComponent { + 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( + + {!first && ', '} + {label}= + "{labels[label]}" + + ); + + if (first) { + first = false; + } + } + + return ( + <> + {labels.__name__ || ''} + {'{'} + {labelNodes} + {'}'} + + ); + } + + 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; diff --git a/src/TimeInput.tsx b/src/TimeInput.tsx index 068a784cc7..afb7f5f682 100644 --- a/src/TimeInput.tsx +++ b/src/TimeInput.tsx @@ -76,7 +76,7 @@ class TimeInput extends Component { 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, diff --git a/src/utils/timeFormat.ts b/src/utils/timeFormat.ts new file mode 100644 index 0000000000..22cf9b3fe6 --- /dev/null +++ b/src/utils/timeFormat.ts @@ -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'); +} diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts new file mode 100644 index 0000000000..8d6c14e599 --- /dev/null +++ b/src/utils/urlParams.ts @@ -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; + } +}