Merge pull request #11942 from grafana/davkal/design-integration

Explore: Time selector, split view, design integration
This commit is contained in:
David 2018-05-30 12:27:24 +02:00 committed by GitHub
commit 7a3c1e162c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 727 additions and 349 deletions

View File

@ -41,6 +41,6 @@ export default class ElapsedTime extends PureComponent<any, any> {
const { elapsed } = this.state;
const { className, time } = this.props;
const value = (time || elapsed) / 1000;
return <span className={className}>{value.toFixed(1)}s</span>;
return <span className={`elapsed-time ${className}`}>{value.toFixed(1)}s</span>;
}
}

View File

@ -4,10 +4,10 @@ import colors from 'app/core/utils/colors';
import TimeSeries from 'app/core/time_series2';
import ElapsedTime from './ElapsedTime';
import Legend from './Legend';
import QueryRows from './QueryRows';
import Graph from './Graph';
import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query';
import { decodePathComponent } from 'app/core/utils/location_util';
@ -16,39 +16,30 @@ function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => {
const datapoints = seriesData.datapoints || [];
const alias = seriesData.target;
const colorIndex = index % colors.length;
const color = colors[colorIndex];
const series = new TimeSeries({
datapoints: datapoints,
alias: alias,
color: color,
datapoints,
alias,
color,
unit: seriesData.unit,
});
if (datapoints && datapoints.length > 0) {
const last = datapoints[datapoints.length - 1][1];
const from = options.range.from;
if (last - from < -10000) {
series.isOutsideRange = true;
}
}
return series;
});
}
function parseInitialQueries(initial) {
if (!initial) {
return [];
}
function parseInitialState(initial) {
try {
const parsed = JSON.parse(decodePathComponent(initial));
return parsed.queries.map(q => q.query);
return {
queries: parsed.queries.map(q => q.query),
range: parsed.range,
};
} catch (e) {
console.error(e);
return [];
return { queries: [], range: DEFAULT_RANGE };
}
}
@ -60,6 +51,8 @@ interface IExploreState {
latency: number;
loading: any;
queries: any;
queryError: any;
range: any;
requestOptions: any;
showingGraph: boolean;
showingTable: boolean;
@ -72,7 +65,7 @@ export class Explore extends React.Component<any, IExploreState> {
constructor(props) {
super(props);
const initialQueries = parseInitialQueries(props.routeParams.initial);
const { range, queries } = parseInitialState(props.routeParams.initial);
this.state = {
datasource: null,
datasourceError: null,
@ -80,11 +73,14 @@ export class Explore extends React.Component<any, IExploreState> {
graphResult: null,
latency: 0,
loading: false,
queries: ensureQueries(initialQueries),
queries: ensureQueries(queries),
queryError: null,
range: range || { ...DEFAULT_RANGE },
requestOptions: null,
showingGraph: true,
showingTable: true,
tableResult: null,
...props.initialState,
};
}
@ -98,6 +94,10 @@ export class Explore extends React.Component<any, IExploreState> {
}
}
componentDidCatch(error) {
console.error(error);
}
handleAddQueryRow = index => {
const { queries } = this.state;
const nextQueries = [
@ -119,10 +119,32 @@ export class Explore extends React.Component<any, IExploreState> {
this.setState({ queries: nextQueries });
};
handleChangeTime = nextRange => {
const range = {
from: nextRange.from,
to: nextRange.to,
};
this.setState({ range }, () => this.handleSubmit());
};
handleClickCloseSplit = () => {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
onChangeSplit(false);
}
};
handleClickGraphButton = () => {
this.setState(state => ({ showingGraph: !state.showingGraph }));
};
handleClickSplit = () => {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
onChangeSplit(true, this.state);
}
};
handleClickTableButton = () => {
this.setState(state => ({ showingTable: !state.showingTable }));
};
@ -147,17 +169,17 @@ export class Explore extends React.Component<any, IExploreState> {
};
async runGraphQuery() {
const { datasource, queries } = this.state;
const { datasource, queries, range } = this.state;
if (!hasQuery(queries)) {
return;
}
this.setState({ latency: 0, loading: true, graphResult: null });
this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
const now = Date.now();
const options = buildQueryOptions({
format: 'time_series',
interval: datasource.interval,
instant: false,
now,
range,
queries: queries.map(q => q.query),
});
try {
@ -165,24 +187,25 @@ export class Explore extends React.Component<any, IExploreState> {
const result = makeTimeSeriesList(res.data, options);
const latency = Date.now() - now;
this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
} catch (error) {
console.error(error);
this.setState({ loading: false, graphResult: error });
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.setState({ loading: false, queryError });
}
}
async runTableQuery() {
const { datasource, queries } = this.state;
const { datasource, queries, range } = this.state;
if (!hasQuery(queries)) {
return;
}
this.setState({ latency: 0, loading: true, tableResult: null });
this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
const now = Date.now();
const options = buildQueryOptions({
format: 'table',
interval: datasource.interval,
instant: true,
now,
range,
queries: queries.map(q => q.query),
});
try {
@ -190,9 +213,10 @@ export class Explore extends React.Component<any, IExploreState> {
const tableModel = res.data[0];
const latency = Date.now() - now;
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
} catch (error) {
console.error(error);
this.setState({ loading: false, tableResult: null });
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.setState({ loading: false, queryError });
}
}
@ -202,6 +226,7 @@ export class Explore extends React.Component<any, IExploreState> {
};
render() {
const { position, split } = this.props;
const {
datasource,
datasourceError,
@ -210,59 +235,93 @@ export class Explore extends React.Component<any, IExploreState> {
latency,
loading,
queries,
queryError,
range,
requestOptions,
showingGraph,
showingTable,
tableResult,
} = this.state;
const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : null;
const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
const graphHeight = showingBoth ? '200px' : '400px';
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore';
return (
<div className="explore">
<div className="page-body page-full">
<h2 className="page-sub-heading">Explore</h2>
{datasourceLoading ? <div>Loading datasource...</div> : null}
{datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null}
{datasource ? (
<div className="m-r-3">
<div className="nav m-b-1">
<div className="pull-right">
{loading || latency ? <ElapsedTime time={latency} className="" /> : null}
<button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}>
<i className="fa fa-return" /> Run Query
</button>
</div>
<div>
<button className={graphButtonClassName} onClick={this.handleClickGraphButton}>
Graph
</button>
<button className={tableButtonClassName} onClick={this.handleClickTableButton}>
Table
</button>
</div>
</div>
<QueryRows
queries={queries}
request={this.request}
onAddQueryRow={this.handleAddQueryRow}
onChangeQuery={this.handleChangeQuery}
onExecuteQuery={this.handleSubmit}
onRemoveQueryRow={this.handleRemoveQueryRow}
/>
<main className="m-t-2">
{showingGraph ? (
<Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} />
) : null}
{showingGraph ? <Legend data={graphResult} /> : null}
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
</main>
<div className={exploreClass}>
<div className="navbar">
{position === 'left' ? (
<div>
<a className="navbar-page-btn">
<i className="fa fa-rocket" />
Explore
</a>
</div>
) : (
<div className="navbar-buttons explore-first-button">
<button className="btn navbar-button" onClick={this.handleClickCloseSplit}>
Close Split
</button>
</div>
)}
<div className="navbar__spacer" />
{position === 'left' && !split ? (
<div className="navbar-buttons">
<button className="btn navbar-button" onClick={this.handleClickSplit}>
Split
</button>
</div>
) : null}
<div className="navbar-buttons">
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.handleClickGraphButton}>
Graph
</button>
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.handleClickTableButton}>
Table
</button>
</div>
<TimePicker range={range} onChangeTime={this.handleChangeTime} />
<div className="navbar-buttons relative">
<button className="btn navbar-button--primary" onClick={this.handleSubmit}>
Run Query <i className="fa fa-level-down run-icon" />
</button>
{loading || latency ? <ElapsedTime time={latency} className="text-info" /> : null}
</div>
</div>
{datasourceLoading ? <div className="explore-container">Loading datasource...</div> : null}
{datasourceError ? (
<div className="explore-container" title={datasourceError}>
Error connecting to datasource.
</div>
) : null}
{datasource ? (
<div className="explore-container">
<QueryRows
queries={queries}
request={this.request}
onAddQueryRow={this.handleAddQueryRow}
onChangeQuery={this.handleChangeQuery}
onExecuteQuery={this.handleSubmit}
onRemoveQueryRow={this.handleRemoveQueryRow}
/>
{queryError ? <div className="text-warning m-a-2">{queryError}</div> : null}
<main className="m-t-2">
{showingGraph ? (
<Graph
data={graphResult}
id={`explore-graph-${position}`}
options={requestOptions}
height={graphHeight}
split={split}
/>
) : null}
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
</main>
</div>
) : null}
</div>
);
}

View File

@ -1,10 +1,13 @@
import $ from 'jquery';
import React, { Component } from 'react';
import TimeSeries from 'app/core/time_series2';
import moment from 'moment';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
import * as dateMath from 'app/core/utils/datemath';
import TimeSeries from 'app/core/time_series2';
import Legend from './Legend';
// Copied from graph.ts
function time_format(ticks, min, max) {
@ -72,6 +75,7 @@ class Graph extends Component<any, any> {
if (
prevProps.data !== this.props.data ||
prevProps.options !== this.props.options ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height
) {
this.draw();
@ -84,14 +88,22 @@ class Graph extends Component<any, any> {
return;
}
const series = data.map((ts: TimeSeries) => ({
color: ts.color,
label: ts.label,
data: ts.getFlotPairs('null'),
}));
const $el = $(`#${this.props.id}`);
const ticks = $el.width() / 100;
const min = userOptions.range.from.valueOf();
const max = userOptions.range.to.valueOf();
let { from, to } = userOptions.range;
if (!moment.isMoment(from)) {
from = dateMath.parse(from, false);
}
if (!moment.isMoment(to)) {
to = dateMath.parse(to, true);
}
const min = from.valueOf();
const max = to.valueOf();
const dynamicOptions = {
xaxis: {
mode: 'time',
@ -111,12 +123,13 @@ class Graph extends Component<any, any> {
}
render() {
const style = {
height: this.props.height || '400px',
width: this.props.width || '100%',
};
return <div id={this.props.id} style={style} />;
const { data, height } = this.props;
return (
<div className="panel-container">
<div id={this.props.id} className="explore-graph" style={{ height }} />
<Legend data={data} />
</div>
);
}
}

View File

@ -50,7 +50,7 @@ class Portal extends React.Component {
constructor(props) {
super(props);
this.node = document.createElement('div');
this.node.classList.add(`query-field-portal-${props.index}`);
this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`);
document.body.appendChild(this.node);
}

View File

@ -48,10 +48,10 @@ class QueryRow extends PureComponent<any, any> {
return (
<div className="query-row">
<div className="query-row-tools">
<button className="btn btn-small btn-inverse" onClick={this.handleClickAddButton}>
<button className="btn navbar-button navbar-button--tight" onClick={this.handleClickAddButton}>
<i className="fa fa-plus" />
</button>
<button className="btn btn-small btn-inverse" onClick={this.handleClickRemoveButton}>
<button className="btn navbar-button navbar-button--tight" onClick={this.handleClickRemoveButton}>
<i className="fa fa-minus" />
</button>
</div>
@ -60,6 +60,7 @@ class QueryRow extends PureComponent<any, any> {
initialQuery={edited ? null : query}
onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery}
placeholder="Enter a PromQL query"
request={request}
/>
</div>

View File

@ -0,0 +1,74 @@
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import * as rangeUtil from 'app/core/utils/rangeutil';
import TimePicker, { DEFAULT_RANGE, parseTime } from './TimePicker';
describe('<TimePicker />', () => {
it('renders closed with default values', () => {
const rangeString = rangeUtil.describeTimeRange(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.find('.gf-timepicker-dropdown').exists()).toBe(false);
});
it('renders with relative range', () => {
const range = {
from: 'now-7h',
to: 'now',
};
const rangeString = rangeUtil.describeTimeRange(range);
const wrapper = shallow(<TimePicker range={range} isOpen />);
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.state('fromRaw')).toBe(range.from);
expect(wrapper.state('toRaw')).toBe(range.to);
expect(wrapper.find('.timepicker-from').props().value).toBe(range.from);
expect(wrapper.find('.timepicker-to').props().value).toBe(range.to);
});
it('renders with epoch (millies) range converted to ISO-ish', () => {
const range = {
from: '1',
to: '1000',
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from),
to: parseTime(range.to),
});
const wrapper = shallow(<TimePicker range={range} isUtc isOpen />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
});
it('moves ranges forward and backward by half the range on arrow click', () => {
const range = {
from: '2000',
to: '4000',
};
const rangeString = rangeUtil.describeTimeRange({
from: parseTime(range.from),
to: parseTime(range.to),
});
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
expect(wrapper.find('.timepicker-rangestring').text()).toBe(rangeString);
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:02');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:04');
wrapper.find('.timepicker-left').simulate('click');
expect(onChangeTime.calledOnce).toBe(true);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
wrapper.find('.timepicker-right').simulate('click');
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
});
});

View File

@ -0,0 +1,245 @@
import React, { PureComponent } from 'react';
import moment from 'moment';
import * as dateMath from 'app/core/utils/datemath';
import * as rangeUtil from 'app/core/utils/rangeutil';
const DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export const DEFAULT_RANGE = {
from: 'now-6h',
to: 'now',
};
export function parseTime(value, isUtc = false, asString = false) {
if (value.indexOf('now') !== -1) {
return value;
}
if (!isNaN(value)) {
const epoch = parseInt(value);
const m = isUtc ? moment.utc(epoch) : moment(epoch);
return asString ? m.format(DATE_FORMAT) : m;
}
return undefined;
}
export default class TimePicker extends PureComponent<any, any> {
dropdownEl: any;
constructor(props) {
super(props);
const fromRaw = props.range ? props.range.from : DEFAULT_RANGE.from;
const toRaw = props.range ? props.range.to : DEFAULT_RANGE.to;
const range = {
from: parseTime(fromRaw),
to: parseTime(toRaw),
};
this.state = {
fromRaw: parseTime(fromRaw, props.isUtc, true),
isOpen: props.isOpen,
isUtc: props.isUtc,
rangeString: rangeUtil.describeTimeRange(range),
refreshInterval: '',
toRaw: parseTime(toRaw, props.isUtc, true),
};
}
move(direction) {
const { onChangeTime } = this.props;
const { fromRaw, toRaw } = this.state;
const range = {
from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true),
};
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to, from;
if (direction === -1) {
to = range.to.valueOf() - timespan;
from = range.from.valueOf() - timespan;
} else if (direction === 1) {
to = range.to.valueOf() + timespan;
from = range.from.valueOf() + timespan;
if (to > Date.now() && range.to < Date.now()) {
to = Date.now();
from = range.from.valueOf();
}
} else {
to = range.to.valueOf();
from = range.from.valueOf();
}
const rangeString = rangeUtil.describeTimeRange(range);
// No need to convert to UTC again
to = moment(to);
from = moment(from);
this.setState(
{
rangeString,
fromRaw: from.format(DATE_FORMAT),
toRaw: to.format(DATE_FORMAT),
},
() => {
onChangeTime({ to, from });
}
);
}
handleChangeFrom = e => {
this.setState({
fromRaw: e.target.value,
});
};
handleChangeTo = e => {
this.setState({
toRaw: e.target.value,
});
};
handleClickApply = () => {
const { onChangeTime } = this.props;
const { toRaw, fromRaw } = this.state;
const range = {
from: dateMath.parse(fromRaw, false),
to: dateMath.parse(toRaw, true),
};
const rangeString = rangeUtil.describeTimeRange(range);
this.setState(
{
isOpen: false,
rangeString,
},
() => {
if (onChangeTime) {
onChangeTime(range);
}
}
);
};
handleClickLeft = () => this.move(-1);
handleClickPicker = () => {
this.setState(state => ({
isOpen: !state.isOpen,
}));
};
handleClickRight = () => this.move(1);
handleClickRefresh = () => {};
handleClickRelativeOption = range => {
const { onChangeTime } = this.props;
const rangeString = rangeUtil.describeTimeRange(range);
this.setState(
{
toRaw: range.to,
fromRaw: range.from,
isOpen: false,
rangeString,
},
() => {
if (onChangeTime) {
onChangeTime(range);
}
}
);
};
getTimeOptions() {
return rangeUtil.getRelativeTimesList({}, this.state.rangeString);
}
dropdownRef = el => {
this.dropdownEl = el;
};
renderDropdown() {
const { fromRaw, isOpen, toRaw } = this.state;
if (!isOpen) {
return null;
}
const timeOptions = this.getTimeOptions();
return (
<div ref={this.dropdownRef} className="gf-timepicker-dropdown">
<div className="gf-timepicker-absolute-section">
<h3 className="section-heading">Custom range</h3>
<label className="small">From:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-from"
value={fromRaw}
onChange={this.handleChangeFrom}
/>
</div>
</div>
<label className="small">To:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-to"
value={toRaw}
onChange={this.handleChangeTo}
/>
</div>
</div>
{/* <label className="small">Refreshing every:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<select className="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
</div> */}
<div className="gf-form">
<button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
Apply
</button>
</div>
</div>
<div className="gf-timepicker-relative-section">
<h3 className="section-heading">Quick ranges</h3>
{Object.keys(timeOptions).map(section => {
const group = timeOptions[section];
return (
<ul key={section}>
{group.map(option => (
<li className={option.active ? 'active' : ''} key={option.display}>
<a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
</li>
))}
</ul>
);
})}
</div>
</div>
);
}
render() {
const { isUtc, rangeString, refreshInterval } = this.state;
return (
<div className="timepicker">
<div className="navbar-buttons">
<button className="btn navbar-button navbar-button--tight timepicker-left" onClick={this.handleClickLeft}>
<i className="fa fa-chevron-left" />
</button>
<button className="btn navbar-button gf-timepicker-nav-btn" onClick={this.handleClickPicker}>
<i className="fa fa-clock-o" />
<span className="timepicker-rangestring">{rangeString}</span>
{isUtc ? <span className="gf-timepicker-utc">UTC</span> : null}
{refreshInterval ? <span className="text-warning">&nbsp; Refresh every {refreshInterval}</span> : null}
</button>
<button className="btn navbar-button navbar-button--tight timepicker-right" onClick={this.handleClickRight}>
<i className="fa fa-chevron-right" />
</button>
</div>
{this.renderDropdown()}
</div>
);
}
}

View File

@ -0,0 +1,33 @@
import React, { PureComponent } from 'react';
import Explore from './Explore';
export default class Wrapper extends PureComponent<any, any> {
state = {
initialState: null,
split: false,
};
handleChangeSplit = (split, initialState) => {
this.setState({ split, initialState });
};
render() {
// State overrides for props from first Explore
const { initialState, split } = this.state;
return (
<div className="explore-wrapper">
<Explore {...this.props} position="left" onChangeSplit={this.handleChangeSplit} split={split} />
{split ? (
<Explore
{...this.props}
initialState={initialState}
onChangeSplit={this.handleChangeSplit}
position="right"
split={split}
/>
) : null}
</div>
);
}
}

View File

@ -1,12 +1,7 @@
export function buildQueryOptions({ format, interval, instant, now, queries }) {
const to = now;
const from = to - 1000 * 60 * 60 * 3;
export function buildQueryOptions({ format, interval, instant, range, queries }) {
return {
interval,
range: {
from,
to,
},
range,
targets: queries.map(expr => ({
expr,
format,

View File

@ -14,7 +14,7 @@ export class KeybindingSrv {
timepickerOpen = false;
/** @ngInject */
constructor(private $rootScope, private $location, private datasourceSrv) {
constructor(private $rootScope, private $location, private datasourceSrv, private timeSrv) {
// clear out all shortcuts on route change
$rootScope.$on('$routeChangeSuccess', () => {
Mousetrap.reset();
@ -182,7 +182,12 @@ export class KeybindingSrv {
const panel = dashboard.getPanelById(dashboard.meta.focusPanelId);
const datasource = await this.datasourceSrv.get(panel.datasource);
if (datasource && datasource.supportsExplore) {
const exploreState = encodePathComponent(JSON.stringify(datasource.getExploreState(panel)));
const range = this.timeSrv.timeRangeForUrl();
const state = {
...datasource.getExploreState(panel),
range,
};
const exploreState = encodePathComponent(JSON.stringify(state));
this.$location.url(`/explore/${exploreState}`);
}
}

View File

@ -324,7 +324,12 @@ class MetricsPanelCtrl extends PanelCtrl {
}
explore() {
const exploreState = encodePathComponent(JSON.stringify(this.datasource.getExploreState(this.panel)));
const range = this.timeSrv.timeRangeForUrl();
const state = {
...this.datasource.getExploreState(this.panel),
range,
};
const exploreState = encodePathComponent(JSON.stringify(state));
this.$location.url(`/explore/${exploreState}`);
}

View File

@ -164,6 +164,7 @@ export class PrometheusDatasource {
legendFormat: activeTargets[index].legendFormat,
start: start,
end: end,
query: queries[index].expr,
responseListLength: responseList.length,
responseIndex: index,
refId: activeTargets[index].refId,

View File

@ -123,11 +123,16 @@ export class ResultTransformer {
}
createMetricLabel(labelData, options) {
let label = '';
if (_.isUndefined(options) || _.isEmpty(options.legendFormat)) {
return this.getOriginalMetricName(labelData);
label = this.getOriginalMetricName(labelData);
} else {
label = this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData);
}
return this.renderTemplate(this.templateSrv.replace(options.legendFormat), labelData) || '{}';
if (!label || label === '{}') {
label = options.query;
}
return label;
}
renderTemplate(aliasPattern, aliasData) {

View File

@ -3,7 +3,6 @@ import './ReactContainer';
import ServerStats from 'app/containers/ServerStats/ServerStats';
import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
// import Explore from 'app/containers/Explore/Explore';
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
@ -114,7 +113,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
.when('/explore/:initial?', {
template: '<react-container />',
resolve: {
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Wrapper'),
},
})
.when('/org', {

View File

@ -45,6 +45,10 @@ $brand-warning: $brand-primary;
$brand-danger: $red;
$query-blue: $blue;
$query-red: $red;
$query-green: $green;
$query-purple: $purple;
$query-orange: $orange;
// Status colors
// -------------------------
@ -176,6 +180,9 @@ $btn-inverse-bg-hl: lighten($dark-3, 4%);
$btn-inverse-text-color: $link-color;
$btn-inverse-text-shadow: 0px 1px 0 rgba(0, 0, 0, 0.1);
$btn-active-bg: $gray-4;
$btn-active-text-color: $blue-dark;
$btn-link-color: $gray-3;
$iconContainerBackground: $black;
@ -204,6 +211,11 @@ $input-invalid-border-color: lighten($red, 5%);
$search-shadow: 0 0 30px 0 $black;
$search-filter-box-bg: $gray-blue;
// Typeahead
$typeahead-shadow: 0 5px 10px 0 $black;
$typeahead-selected-bg: $dark-4;
$typeahead-selected-color: $blue;
// Dropdowns
// -------------------------
$dropdownBackground: $dark-3;

View File

@ -46,6 +46,10 @@ $brand-warning: $orange;
$brand-danger: $red;
$query-blue: $blue-dark;
$query-red: $red;
$query-green: $green;
$query-purple: $purple;
$query-orange: $orange;
// Status colors
// -------------------------
@ -173,6 +177,9 @@ $btn-inverse-bg-hl: darken($gray-6, 5%);
$btn-inverse-text-color: $gray-1;
$btn-inverse-text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
$btn-active-bg: $white;
$btn-active-text-color: $blue-dark;
$btn-link-color: $gray-1;
$btn-divider-left: $gray-4;
@ -226,6 +233,11 @@ $tab-border-color: $gray-5;
$search-shadow: 0 5px 30px 0 $gray-4;
$search-filter-box-bg: $gray-7;
// Typeahead
$typeahead-shadow: 0 5px 10px 0 $gray-5;
$typeahead-selected-bg: lighten($blue, 25%);
$typeahead-selected-color: $blue-dark;
// Dropdowns
// -------------------------
$dropdownBackground: $white;

View File

@ -1,11 +1,89 @@
.explore {
width: 100%;
&-container {
padding: 2rem;
}
&-wrapper {
display: flex;
> .explore-split {
width: 50%;
}
}
// Push split button a bit
.explore-first-button {
margin-left: 15px;
}
// Graph panel needs a bit extra padding at top
.panel-container {
padding: $panel-padding;
padding-top: 10px;
}
// Make sure wrap buttons around on small screens
.navbar {
flex-wrap: wrap;
height: auto;
}
.navbar-page-btn {
margin-right: 1rem;
// Explore icon in header
.fa {
font-size: 100%;
opacity: 0.75;
margin-right: 0.5em;
}
}
// Toggle mode
.navbar-button.active {
color: $btn-active-text-color;
background-color: $btn-active-bg;
}
.elapsed-time {
position: absolute;
left: 0;
right: 0;
top: 3.5rem;
text-align: center;
font-size: 0.8rem;
}
.graph-legend {
flex-wrap: wrap;
}
.timepicker {
display: flex;
&-rangestring {
margin-left: 0.5em;
}
}
.run-icon {
margin-left: 0.5em;
transform: rotate(90deg);
}
.relative {
position: relative;
}
}
.explore + .explore {
border-left: 1px dotted $table-border;
}
.query-row {
position: relative;
display: flex;
& + & {
margin-top: 0.5rem;
@ -13,17 +91,12 @@
}
.query-row-tools {
position: absolute;
left: -4rem;
top: 0.33rem;
> * {
margin-right: 0.25rem;
}
width: 4rem;
}
.query-field {
font-size: 14px;
font-family: Consolas, Menlo, Courier, monospace;
font-size: $font-size-root;
font-family: $font-family-monospace;
height: auto;
}
@ -33,54 +106,52 @@
padding: 6px 7px 4px;
width: 100%;
cursor: text;
line-height: 1.5;
color: rgba(0, 0, 0, 0.65);
background-color: #fff;
line-height: $line-height-base;
color: $text-color-weak;
background-color: $panel-bg;
background-image: none;
border: 1px solid lightgray;
border-radius: 3px;
border: $panel-border;
border-radius: $border-radius;
transition: all 0.3s;
}
.explore {
.explore-typeahead {
.typeahead {
position: absolute;
z-index: auto;
top: -10000px;
left: -10000px;
opacity: 0;
border-radius: 4px;
border-radius: $border-radius;
transition: opacity 0.75s;
border: 1px solid #e4e4e4;
border: $panel-border;
max-height: calc(66vh);
overflow-y: scroll;
max-width: calc(66%);
overflow-x: hidden;
outline: none;
list-style: none;
background: #fff;
color: rgba(0, 0, 0, 0.65);
background: $panel-bg;
color: $text-color;
transition: opacity 0.4s ease-out;
box-shadow: $typeahead-shadow;
}
.typeahead-group__title {
color: rgba(0, 0, 0, 0.43);
font-size: 12px;
line-height: 1.5;
padding: 8px 16px;
color: $text-color-weak;
font-size: $font-size-sm;
line-height: $line-height-base;
padding: $input-padding-y $input-padding-x;
}
.typeahead-item {
line-height: 200%;
height: auto;
font-family: Consolas, Menlo, Courier, monospace;
padding: 0 16px 0 28px;
font-size: 12px;
font-family: $font-family-monospace;
padding: $input-padding-y $input-padding-x;
padding-left: $input-padding-x-lg;
font-size: $font-size-sm;
text-overflow: ellipsis;
overflow: hidden;
margin-left: -1px;
left: 1px;
position: relative;
z-index: 1;
display: block;
white-space: nowrap;
@ -90,234 +161,82 @@
}
.typeahead-item__selected {
background-color: #ecf6fd;
color: #108ee9;
background-color: $typeahead-selected-bg;
color: $typeahead-selected-color;
}
}
/* SYNTAX */
/**
* prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML
* Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics);
* @author Tim Shedor
*/
.explore {
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: $text-color-weak;
}
code[class*='language-'],
pre[class*='language-'] {
color: black;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
.token.punctuation {
color: $text-color-weak;
}
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.function-name,
.token.constant,
.token.symbol,
.token.deleted {
color: $query-red;
}
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.function,
.token.builtin,
.token.inserted {
color: $query-green;
}
/* Code blocks */
pre[class*='language-'] {
position: relative;
margin: 0.5em 0;
overflow: visible;
padding: 0;
}
pre[class*='language-'] > code {
position: relative;
border-left: 10px solid #358ccb;
box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf;
background-color: #fdfdfd;
background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%);
background-size: 3em 3em;
background-origin: content-box;
background-attachment: local;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: $query-purple;
}
code[class*='language'] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
.token.atrule,
.token.attr-value,
.token.keyword,
.token.class-name {
color: $query-blue;
}
/* Margin bottom to accomodate shadow */
:not(pre) > code[class*='language-'],
pre[class*='language-'] {
background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
.token.regex,
.token.important {
color: $query-orange;
}
/* Inline code */
:not(pre) > code[class*='language-'] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.important {
font-weight: normal;
}
pre[class*='language-']:before,
pre[class*='language-']:after {
content: '';
z-index: -2;
display: block;
position: absolute;
bottom: 0.75em;
left: 0.18em;
width: 40%;
height: 20%;
max-height: 13em;
box-shadow: 0px 13px 8px #979797;
-webkit-transform: rotate(-2deg);
-moz-transform: rotate(-2deg);
-ms-transform: rotate(-2deg);
-o-transform: rotate(-2deg);
transform: rotate(-2deg);
}
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
:not(pre) > code[class*='language-']:after,
pre[class*='language-']:after {
right: 0.75em;
left: auto;
-webkit-transform: rotate(2deg);
-moz-transform: rotate(2deg);
-ms-transform: rotate(2deg);
-o-transform: rotate(2deg);
transform: rotate(2deg);
}
.token.entity {
cursor: help;
}
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.function-name,
.token.constant,
.token.symbol,
.token.deleted {
color: #c92c2c;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.function,
.token.builtin,
.token.inserted {
color: #2f9c0a;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.keyword,
.token.class-name {
color: #1990b8;
}
.token.regex,
.token.important {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: normal;
}
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*='language-']:before,
pre[class*='language-']:after {
bottom: 14px;
box-shadow: none;
.namespace {
opacity: 0.7;
}
}
/* Plugin styles */
.token.tab:not(:empty):before,
.token.cr:before,
.token.lf:before {
color: #e0d7d1;
}
/* Plugin styles: Line Numbers */
pre[class*='language-'].line-numbers {
padding-left: 0;
}
pre[class*='language-'].line-numbers code {
padding-left: 3.8em;
}
pre[class*='language-'].line-numbers .line-numbers-rows {
left: 0;
}
/* Plugin styles: Line Highlight */
pre[class*='language-'][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}