mirror of https://github.com/grafana/grafana.git
Merge pull request #11742 from grafana/davkal/explore
Explore UI skeleton
This commit is contained in:
commit
d8abfe6a7d
|
|
@ -442,6 +442,11 @@ enabled = true
|
||||||
# Makes it possible to turn off alert rule execution but alerting UI is visible
|
# Makes it possible to turn off alert rule execution but alerting UI is visible
|
||||||
execute_alerts = true
|
execute_alerts = true
|
||||||
|
|
||||||
|
#################################### Explore #############################
|
||||||
|
[explore]
|
||||||
|
# Enable the Explore section
|
||||||
|
enabled = false
|
||||||
|
|
||||||
#################################### Internal Grafana Metrics ############
|
#################################### Internal Grafana Metrics ############
|
||||||
# Metrics available at HTTP API Url /metrics
|
# Metrics available at HTTP API Url /metrics
|
||||||
[metrics]
|
[metrics]
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,11 @@ log_queries =
|
||||||
# Makes it possible to turn off alert rule execution but alerting UI is visible
|
# Makes it possible to turn off alert rule execution but alerting UI is visible
|
||||||
;execute_alerts = true
|
;execute_alerts = true
|
||||||
|
|
||||||
|
#################################### Explore #############################
|
||||||
|
[explore]
|
||||||
|
# Enable the Explore section
|
||||||
|
;enabled = false
|
||||||
|
|
||||||
#################################### Internal Grafana Metrics ##########################
|
#################################### Internal Grafana Metrics ##########################
|
||||||
# Metrics available at HTTP API Url /metrics
|
# Metrics available at HTTP API Url /metrics
|
||||||
[metrics]
|
[metrics]
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"axios": "^0.17.1",
|
"axios": "^0.17.1",
|
||||||
"babel-core": "^6.26.0",
|
"babel-core": "^6.26.0",
|
||||||
"babel-loader": "^7.1.2",
|
"babel-loader": "^7.1.2",
|
||||||
|
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||||
"babel-preset-es2015": "^6.24.1",
|
"babel-preset-es2015": "^6.24.1",
|
||||||
"clean-webpack-plugin": "^0.1.19",
|
"clean-webpack-plugin": "^0.1.19",
|
||||||
"css-loader": "^0.28.7",
|
"css-loader": "^0.28.7",
|
||||||
|
|
@ -150,6 +151,7 @@
|
||||||
"d3-scale-chromatic": "^1.1.1",
|
"d3-scale-chromatic": "^1.1.1",
|
||||||
"eventemitter3": "^2.0.3",
|
"eventemitter3": "^2.0.3",
|
||||||
"file-saver": "^1.3.3",
|
"file-saver": "^1.3.3",
|
||||||
|
"immutable": "^3.8.2",
|
||||||
"jquery": "^3.2.1",
|
"jquery": "^3.2.1",
|
||||||
"lodash": "^4.17.4",
|
"lodash": "^4.17.4",
|
||||||
"mobx": "^3.4.1",
|
"mobx": "^3.4.1",
|
||||||
|
|
@ -158,6 +160,7 @@
|
||||||
"moment": "^2.18.1",
|
"moment": "^2.18.1",
|
||||||
"mousetrap": "^1.6.0",
|
"mousetrap": "^1.6.0",
|
||||||
"mousetrap-global-bind": "^1.1.0",
|
"mousetrap-global-bind": "^1.1.0",
|
||||||
|
"prismjs": "^1.6.0",
|
||||||
"prop-types": "^15.6.0",
|
"prop-types": "^15.6.0",
|
||||||
"react": "^16.2.0",
|
"react": "^16.2.0",
|
||||||
"react-dom": "^16.2.0",
|
"react-dom": "^16.2.0",
|
||||||
|
|
@ -170,6 +173,9 @@
|
||||||
"remarkable": "^1.7.1",
|
"remarkable": "^1.7.1",
|
||||||
"rst2html": "github:thoward/rst2html#990cb89",
|
"rst2html": "github:thoward/rst2html#990cb89",
|
||||||
"rxjs": "^5.4.3",
|
"rxjs": "^5.4.3",
|
||||||
|
"slate": "^0.33.4",
|
||||||
|
"slate-plain-serializer": "^0.5.10",
|
||||||
|
"slate-react": "^0.12.4",
|
||||||
"tether": "^1.4.0",
|
"tether": "^1.4.0",
|
||||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||||
"tinycolor2": "^1.4.1"
|
"tinycolor2": "^1.4.1"
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,19 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
||||||
Children: dashboardChildNavs,
|
Children: dashboardChildNavs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if setting.ExploreEnabled {
|
||||||
|
data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||||
|
Text: "Explore",
|
||||||
|
Id: "explore",
|
||||||
|
SubTitle: "Explore your data",
|
||||||
|
Icon: "fa fa-rocket",
|
||||||
|
Url: setting.AppSubUrl + "/explore",
|
||||||
|
Children: []*dtos.NavLink{
|
||||||
|
{Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if c.IsSignedIn {
|
if c.IsSignedIn {
|
||||||
// Only set login if it's different from the name
|
// Only set login if it's different from the name
|
||||||
var login string
|
var login string
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,9 @@ var (
|
||||||
AlertingEnabled bool
|
AlertingEnabled bool
|
||||||
ExecuteAlerts bool
|
ExecuteAlerts bool
|
||||||
|
|
||||||
|
// Explore UI
|
||||||
|
ExploreEnabled bool
|
||||||
|
|
||||||
// logger
|
// logger
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
|
|
||||||
|
|
@ -616,6 +619,9 @@ func NewConfigContext(args *CommandLineArgs) error {
|
||||||
AlertingEnabled = alerting.Key("enabled").MustBool(true)
|
AlertingEnabled = alerting.Key("enabled").MustBool(true)
|
||||||
ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true)
|
ExecuteAlerts = alerting.Key("execute_alerts").MustBool(true)
|
||||||
|
|
||||||
|
explore := Cfg.Section("explore")
|
||||||
|
ExploreEnabled = explore.Key("enabled").MustBool(false)
|
||||||
|
|
||||||
readSessionConfig()
|
readSessionConfig()
|
||||||
readSmtpSettings()
|
readSmtpSettings()
|
||||||
readQuotaSettings()
|
readQuotaSettings()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
|
const INTERVAL = 150;
|
||||||
|
|
||||||
|
export default class ElapsedTime extends PureComponent<any, any> {
|
||||||
|
offset: number;
|
||||||
|
timer: number;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
elapsed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.offset = Date.now();
|
||||||
|
this.timer = window.setInterval(this.tick, INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick = () => {
|
||||||
|
const jetzt = Date.now();
|
||||||
|
const elapsed = jetzt - this.offset;
|
||||||
|
this.setState({ elapsed });
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (nextProps.time) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
} else if (this.props.time) {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { elapsed } = this.state;
|
||||||
|
const { className, time } = this.props;
|
||||||
|
const value = (time || elapsed) / 1000;
|
||||||
|
return <span className={className}>{value.toFixed(1)}s</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { hot } from 'react-hot-loader';
|
||||||
|
import colors from 'app/core/utils/colors';
|
||||||
|
import TimeSeries from 'app/core/time_series2';
|
||||||
|
|
||||||
|
import ElapsedTime from './ElapsedTime';
|
||||||
|
import Legend from './Legend';
|
||||||
|
import QueryField from './QueryField';
|
||||||
|
import Graph from './Graph';
|
||||||
|
import Table from './Table';
|
||||||
|
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
|
||||||
|
function buildQueryOptions({ format, interval, instant, now, query }) {
|
||||||
|
const to = now;
|
||||||
|
const from = to - 1000 * 60 * 60 * 3;
|
||||||
|
return {
|
||||||
|
interval,
|
||||||
|
range: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
expr: query,
|
||||||
|
format,
|
||||||
|
instant,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IExploreState {
|
||||||
|
datasource: any;
|
||||||
|
datasourceError: any;
|
||||||
|
datasourceLoading: any;
|
||||||
|
graphResult: any;
|
||||||
|
latency: number;
|
||||||
|
loading: any;
|
||||||
|
requestOptions: any;
|
||||||
|
showingGraph: boolean;
|
||||||
|
showingTable: boolean;
|
||||||
|
tableResult: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @observer
|
||||||
|
export class Explore extends React.Component<any, IExploreState> {
|
||||||
|
datasourceSrv: DatasourceSrv;
|
||||||
|
query: string;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
datasource: null,
|
||||||
|
datasourceError: null,
|
||||||
|
datasourceLoading: true,
|
||||||
|
graphResult: null,
|
||||||
|
latency: 0,
|
||||||
|
loading: false,
|
||||||
|
requestOptions: null,
|
||||||
|
showingGraph: true,
|
||||||
|
showingTable: true,
|
||||||
|
tableResult: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidMount() {
|
||||||
|
const datasource = await this.props.datasourceSrv.get();
|
||||||
|
const testResult = await datasource.testDatasource();
|
||||||
|
if (testResult.status === 'success') {
|
||||||
|
this.setState({ datasource, datasourceError: null, datasourceLoading: false });
|
||||||
|
} else {
|
||||||
|
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickGraphButton = () => {
|
||||||
|
this.setState(state => ({ showingGraph: !state.showingGraph }));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClickTableButton = () => {
|
||||||
|
this.setState(state => ({ showingTable: !state.showingTable }));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRequestError({ error }) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleQueryChange = query => {
|
||||||
|
this.query = query;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = () => {
|
||||||
|
const { showingGraph, showingTable } = this.state;
|
||||||
|
if (showingTable) {
|
||||||
|
this.runTableQuery();
|
||||||
|
}
|
||||||
|
if (showingGraph) {
|
||||||
|
this.runGraphQuery();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async runGraphQuery() {
|
||||||
|
const { query } = this;
|
||||||
|
const { datasource } = this.state;
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ latency: 0, loading: true, graphResult: null });
|
||||||
|
const now = Date.now();
|
||||||
|
const options = buildQueryOptions({
|
||||||
|
format: 'time_series',
|
||||||
|
interval: datasource.interval,
|
||||||
|
instant: false,
|
||||||
|
now,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await datasource.query(options);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTableQuery() {
|
||||||
|
const { query } = this;
|
||||||
|
const { datasource } = this.state;
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ latency: 0, loading: true, tableResult: null });
|
||||||
|
const now = Date.now();
|
||||||
|
const options = buildQueryOptions({ format: 'table', interval: datasource.interval, instant: true, now, query });
|
||||||
|
try {
|
||||||
|
const res = await datasource.query(options);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request = url => {
|
||||||
|
const { datasource } = this.state;
|
||||||
|
return datasource.metadataRequest(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
datasource,
|
||||||
|
datasourceError,
|
||||||
|
datasourceLoading,
|
||||||
|
latency,
|
||||||
|
loading,
|
||||||
|
requestOptions,
|
||||||
|
graphResult,
|
||||||
|
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';
|
||||||
|
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" style={{ paddingRight: '6rem' }}>
|
||||||
|
<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>
|
||||||
|
<div className="query-field-wrapper">
|
||||||
|
<QueryField
|
||||||
|
request={this.request}
|
||||||
|
onPressEnter={this.handleSubmit}
|
||||||
|
onQueryChange={this.handleQueryChange}
|
||||||
|
onRequestError={this.handleRequestError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loading || latency ? <ElapsedTime time={latency} className="m-l-1" /> : null}
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hot(module)(Explore);
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import $ from 'jquery';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
import TimeSeries from 'app/core/time_series2';
|
||||||
|
|
||||||
|
import 'vendor/flot/jquery.flot';
|
||||||
|
import 'vendor/flot/jquery.flot.time';
|
||||||
|
|
||||||
|
// Copied from graph.ts
|
||||||
|
function time_format(ticks, min, max) {
|
||||||
|
if (min && max && ticks) {
|
||||||
|
var range = max - min;
|
||||||
|
var secPerTick = range / ticks / 1000;
|
||||||
|
var oneDay = 86400000;
|
||||||
|
var oneYear = 31536000000;
|
||||||
|
|
||||||
|
if (secPerTick <= 45) {
|
||||||
|
return '%H:%M:%S';
|
||||||
|
}
|
||||||
|
if (secPerTick <= 7200 || range <= oneDay) {
|
||||||
|
return '%H:%M';
|
||||||
|
}
|
||||||
|
if (secPerTick <= 80000) {
|
||||||
|
return '%m/%d %H:%M';
|
||||||
|
}
|
||||||
|
if (secPerTick <= 2419200 || range <= oneYear) {
|
||||||
|
return '%m/%d';
|
||||||
|
}
|
||||||
|
return '%Y-%m';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '%H:%M';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FLOT_OPTIONS = {
|
||||||
|
legend: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
lines: {
|
||||||
|
linewidth: 1,
|
||||||
|
zero: false,
|
||||||
|
},
|
||||||
|
shadowSize: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
minBorderMargin: 0,
|
||||||
|
markings: [],
|
||||||
|
backgroundColor: null,
|
||||||
|
borderWidth: 0,
|
||||||
|
// hoverable: true,
|
||||||
|
clickable: true,
|
||||||
|
color: '#a1a1a1',
|
||||||
|
margin: { left: 0, right: 0 },
|
||||||
|
labelMarginX: 0,
|
||||||
|
},
|
||||||
|
// selection: {
|
||||||
|
// mode: 'x',
|
||||||
|
// color: '#666',
|
||||||
|
// },
|
||||||
|
// crosshair: {
|
||||||
|
// mode: 'x',
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
class Graph extends Component<any, any> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (
|
||||||
|
prevProps.data !== this.props.data ||
|
||||||
|
prevProps.options !== this.props.options ||
|
||||||
|
prevProps.height !== this.props.height
|
||||||
|
) {
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const { data, options: userOptions } = this.props;
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const series = data.map((ts: TimeSeries) => ({
|
||||||
|
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();
|
||||||
|
const dynamicOptions = {
|
||||||
|
xaxis: {
|
||||||
|
mode: 'time',
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
label: 'Datetime',
|
||||||
|
ticks: ticks,
|
||||||
|
timeformat: time_format(ticks, min, max),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
...FLOT_OPTIONS,
|
||||||
|
...dynamicOptions,
|
||||||
|
...userOptions,
|
||||||
|
};
|
||||||
|
$.plot($el, series, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const style = {
|
||||||
|
height: this.props.height || '400px',
|
||||||
|
width: this.props.width || '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div id={this.props.id} style={style} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Graph;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
|
||||||
|
const LegendItem = ({ series }) => (
|
||||||
|
<div className="graph-legend-series">
|
||||||
|
<div className="graph-legend-icon">
|
||||||
|
<i className="fa fa-minus pointer" style={{ color: series.color }} />
|
||||||
|
</div>
|
||||||
|
<a className="graph-legend-alias pointer">{series.alias}</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default class Legend extends PureComponent<any, any> {
|
||||||
|
render() {
|
||||||
|
const { className = '', data } = this.props;
|
||||||
|
const items = data || [];
|
||||||
|
return (
|
||||||
|
<div className={`${className} graph-legend ps`}>
|
||||||
|
{items.map(series => <LegendItem key={series.id} series={series} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,562 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Value } from 'slate';
|
||||||
|
import { Editor } from 'slate-react';
|
||||||
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
|
// dom also includes Element polyfills
|
||||||
|
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
||||||
|
import BracesPlugin from './slate-plugins/braces';
|
||||||
|
import ClearPlugin from './slate-plugins/clear';
|
||||||
|
import NewlinePlugin from './slate-plugins/newline';
|
||||||
|
import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index';
|
||||||
|
import RunnerPlugin from './slate-plugins/runner';
|
||||||
|
import debounce from './utils/debounce';
|
||||||
|
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
|
||||||
|
|
||||||
|
import Typeahead from './Typeahead';
|
||||||
|
|
||||||
|
const EMPTY_METRIC = '';
|
||||||
|
const TYPEAHEAD_DEBOUNCE = 300;
|
||||||
|
|
||||||
|
function flattenSuggestions(s) {
|
||||||
|
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitialValue = query =>
|
||||||
|
Value.fromJSON({
|
||||||
|
document: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
object: 'block',
|
||||||
|
type: 'paragraph',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
object: 'text',
|
||||||
|
leaves: [
|
||||||
|
{
|
||||||
|
text: query,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
class Portal extends React.Component {
|
||||||
|
node: any;
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.node = document.createElement('div');
|
||||||
|
this.node.classList.add(`query-field-portal-${props.index}`);
|
||||||
|
document.body.appendChild(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
document.body.removeChild(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return ReactDOM.createPortal(this.props.children, this.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QueryField extends React.Component<any, any> {
|
||||||
|
menuEl: any;
|
||||||
|
plugins: any;
|
||||||
|
resetTimer: any;
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.plugins = [
|
||||||
|
BracesPlugin(),
|
||||||
|
ClearPlugin(),
|
||||||
|
RunnerPlugin({ handler: props.onPressEnter }),
|
||||||
|
NewlinePlugin(),
|
||||||
|
PluginPrism(),
|
||||||
|
];
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
labelKeys: {},
|
||||||
|
labelValues: {},
|
||||||
|
metrics: props.metrics || [],
|
||||||
|
suggestions: [],
|
||||||
|
typeaheadIndex: 0,
|
||||||
|
typeaheadPrefix: '',
|
||||||
|
value: getInitialValue(props.initialQuery || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.updateMenu();
|
||||||
|
|
||||||
|
if (this.props.metrics === undefined) {
|
||||||
|
this.fetchMetricNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearTimeout(this.resetTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
this.updateMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
|
||||||
|
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
|
||||||
|
}
|
||||||
|
// initialQuery is null in case the user typed
|
||||||
|
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
|
||||||
|
this.setState({ value: getInitialValue(nextProps.initialQuery) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = ({ value }) => {
|
||||||
|
const changed = value.document !== this.state.value.document;
|
||||||
|
this.setState({ value }, () => {
|
||||||
|
if (changed) {
|
||||||
|
this.handleChangeQuery();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.requestAnimationFrame(this.handleTypeahead);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMetricsReceived = () => {
|
||||||
|
if (!this.state.metrics) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configurePrismMetricsTokens(this.state.metrics);
|
||||||
|
// Trigger re-render
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
// Bogus edit to trigger highlighting
|
||||||
|
const change = this.state.value
|
||||||
|
.change()
|
||||||
|
.insertText(' ')
|
||||||
|
.deleteBackward(1);
|
||||||
|
this.onChange(change);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
request = url => {
|
||||||
|
if (this.props.request) {
|
||||||
|
return this.props.request(url);
|
||||||
|
}
|
||||||
|
return fetch(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleChangeQuery = () => {
|
||||||
|
// Send text change to parent
|
||||||
|
const { onQueryChange } = this.props;
|
||||||
|
if (onQueryChange) {
|
||||||
|
onQueryChange(Plain.serialize(this.state.value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleTypeahead = debounce(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection.anchorNode) {
|
||||||
|
const wrapperNode = selection.anchorNode.parentElement;
|
||||||
|
const editorNode = wrapperNode.closest('.query-field');
|
||||||
|
if (!editorNode || this.state.value.isBlurred) {
|
||||||
|
// Not inside this editor
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const text = selection.anchorNode.textContent;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
const prefix = cleanText(text.substr(0, offset));
|
||||||
|
|
||||||
|
// Determine candidates by context
|
||||||
|
const suggestionGroups = [];
|
||||||
|
const wrapperClasses = wrapperNode.classList;
|
||||||
|
let typeaheadContext = null;
|
||||||
|
|
||||||
|
// Take first metric as lucky guess
|
||||||
|
const metricNode = editorNode.querySelector('.metric');
|
||||||
|
|
||||||
|
if (wrapperClasses.contains('context-range')) {
|
||||||
|
// Rate ranges
|
||||||
|
typeaheadContext = 'context-range';
|
||||||
|
suggestionGroups.push({
|
||||||
|
label: 'Range vector',
|
||||||
|
items: [...RATE_RANGES],
|
||||||
|
});
|
||||||
|
} else if (wrapperClasses.contains('context-labels') && metricNode) {
|
||||||
|
const metric = metricNode.textContent;
|
||||||
|
const labelKeys = this.state.labelKeys[metric];
|
||||||
|
if (labelKeys) {
|
||||||
|
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||||
|
// Label values
|
||||||
|
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||||
|
if (labelKeyNode) {
|
||||||
|
const labelKey = labelKeyNode.textContent;
|
||||||
|
const labelValues = this.state.labelValues[metric][labelKey];
|
||||||
|
typeaheadContext = 'context-label-values';
|
||||||
|
suggestionGroups.push({
|
||||||
|
label: 'Label values',
|
||||||
|
items: labelValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Label keys
|
||||||
|
typeaheadContext = 'context-labels';
|
||||||
|
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.fetchMetricLabels(metric);
|
||||||
|
}
|
||||||
|
} else if (wrapperClasses.contains('context-labels') && !metricNode) {
|
||||||
|
// Empty name queries
|
||||||
|
const defaultKeys = ['job', 'instance'];
|
||||||
|
// Munge all keys that we have seen together
|
||||||
|
const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
|
||||||
|
return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
|
||||||
|
}, defaultKeys);
|
||||||
|
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||||
|
// Label values
|
||||||
|
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||||
|
if (labelKeyNode) {
|
||||||
|
const labelKey = labelKeyNode.textContent;
|
||||||
|
if (this.state.labelValues[EMPTY_METRIC]) {
|
||||||
|
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
|
||||||
|
typeaheadContext = 'context-label-values';
|
||||||
|
suggestionGroups.push({
|
||||||
|
label: 'Label values',
|
||||||
|
items: labelValues,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Can only query label values for now (API to query keys is under development)
|
||||||
|
this.fetchLabelValues(labelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Label keys
|
||||||
|
typeaheadContext = 'context-labels';
|
||||||
|
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||||
|
}
|
||||||
|
} else if (metricNode && wrapperClasses.contains('context-aggregation')) {
|
||||||
|
typeaheadContext = 'context-aggregation';
|
||||||
|
const metric = metricNode.textContent;
|
||||||
|
const labelKeys = this.state.labelKeys[metric];
|
||||||
|
if (labelKeys) {
|
||||||
|
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||||
|
} else {
|
||||||
|
this.fetchMetricLabels(metric);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
(this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
|
||||||
|
wrapperClasses.contains('context-function')
|
||||||
|
) {
|
||||||
|
// Need prefix for metrics
|
||||||
|
typeaheadContext = 'context-metrics';
|
||||||
|
suggestionGroups.push({
|
||||||
|
label: 'Metrics',
|
||||||
|
items: this.state.metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = 0;
|
||||||
|
const filteredSuggestions = suggestionGroups.map(group => {
|
||||||
|
if (group.items) {
|
||||||
|
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
|
||||||
|
results += group.items.length;
|
||||||
|
}
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
typeaheadPrefix: prefix,
|
||||||
|
typeaheadContext,
|
||||||
|
typeaheadText: text,
|
||||||
|
suggestions: results > 0 ? filteredSuggestions : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, TYPEAHEAD_DEBOUNCE);
|
||||||
|
|
||||||
|
applyTypeahead(change, suggestion) {
|
||||||
|
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
||||||
|
|
||||||
|
// Modify suggestion based on context
|
||||||
|
switch (typeaheadContext) {
|
||||||
|
case 'context-labels': {
|
||||||
|
const nextChar = getNextCharacter();
|
||||||
|
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||||
|
suggestion += '=';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'context-label-values': {
|
||||||
|
// Always add quotes and remove existing ones instead
|
||||||
|
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
||||||
|
suggestion = `"${suggestion}`;
|
||||||
|
}
|
||||||
|
if (getNextCharacter() !== '"') {
|
||||||
|
suggestion = `${suggestion}"`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetTypeahead();
|
||||||
|
|
||||||
|
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||||
|
let backward = typeaheadPrefix.length;
|
||||||
|
const text = cleanText(typeaheadText);
|
||||||
|
const suffixLength = text.length - typeaheadPrefix.length;
|
||||||
|
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
||||||
|
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
|
||||||
|
const forward = midWord ? suffixLength + offset : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
change
|
||||||
|
// TODO this line breaks if cursor was moved left and length is longer than whole prefix
|
||||||
|
.deleteBackward(backward)
|
||||||
|
.deleteForward(forward)
|
||||||
|
.insertText(suggestion)
|
||||||
|
.focus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown = (event, change) => {
|
||||||
|
if (this.menuEl) {
|
||||||
|
const { typeaheadIndex, suggestions } = this.state;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Escape': {
|
||||||
|
if (this.menuEl) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.resetTypeahead();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Tab': {
|
||||||
|
// Dont blur input
|
||||||
|
event.preventDefault();
|
||||||
|
if (!suggestions || suggestions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the currently selected suggestion
|
||||||
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||||
|
const selected = Math.abs(typeaheadIndex);
|
||||||
|
const selectedIndex = selected % flattenedSuggestions.length || 0;
|
||||||
|
const suggestion = flattenedSuggestions[selectedIndex];
|
||||||
|
|
||||||
|
this.applyTypeahead(change, suggestion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
// Select next suggestion
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowUp': {
|
||||||
|
// Select previous suggestion
|
||||||
|
event.preventDefault();
|
||||||
|
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
resetTypeahead = () => {
|
||||||
|
this.setState({
|
||||||
|
suggestions: [],
|
||||||
|
typeaheadIndex: 0,
|
||||||
|
typeaheadPrefix: '',
|
||||||
|
typeaheadContext: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async fetchLabelValues(key) {
|
||||||
|
const url = `/api/v1/label/${key}/values`;
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const pairs = this.state.labelValues[EMPTY_METRIC];
|
||||||
|
const values = {
|
||||||
|
...pairs,
|
||||||
|
[key]: body.data,
|
||||||
|
};
|
||||||
|
// const labelKeys = {
|
||||||
|
// ...this.state.labelKeys,
|
||||||
|
// [EMPTY_METRIC]: keys,
|
||||||
|
// };
|
||||||
|
const labelValues = {
|
||||||
|
...this.state.labelValues,
|
||||||
|
[EMPTY_METRIC]: values,
|
||||||
|
};
|
||||||
|
this.setState({ labelValues }, this.handleTypeahead);
|
||||||
|
} catch (e) {
|
||||||
|
if (this.props.onRequestError) {
|
||||||
|
this.props.onRequestError(e);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetricLabels(name) {
|
||||||
|
const url = `/api/v1/series?match[]=${name}`;
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
const { keys, values } = processLabels(body.data);
|
||||||
|
const labelKeys = {
|
||||||
|
...this.state.labelKeys,
|
||||||
|
[name]: keys,
|
||||||
|
};
|
||||||
|
const labelValues = {
|
||||||
|
...this.state.labelValues,
|
||||||
|
[name]: values,
|
||||||
|
};
|
||||||
|
this.setState({ labelKeys, labelValues }, this.handleTypeahead);
|
||||||
|
} catch (e) {
|
||||||
|
if (this.props.onRequestError) {
|
||||||
|
this.props.onRequestError(e);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMetricNames() {
|
||||||
|
const url = '/api/v1/label/__name__/values';
|
||||||
|
try {
|
||||||
|
const res = await this.request(url);
|
||||||
|
const body = await (res.data || res.json());
|
||||||
|
this.setState({ metrics: body.data }, this.onMetricsReceived);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.props.onRequestError) {
|
||||||
|
this.props.onRequestError(error);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur = () => {
|
||||||
|
const { onBlur } = this.props;
|
||||||
|
// If we dont wait here, menu clicks wont work because the menu
|
||||||
|
// will be gone.
|
||||||
|
this.resetTimer = setTimeout(this.resetTypeahead, 100);
|
||||||
|
if (onBlur) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFocus = () => {
|
||||||
|
const { onFocus } = this.props;
|
||||||
|
if (onFocus) {
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClickMenu = item => {
|
||||||
|
// Manually triggering change
|
||||||
|
const change = this.applyTypeahead(this.state.value.change(), item);
|
||||||
|
this.onChange(change);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMenu = () => {
|
||||||
|
const { suggestions } = this.state;
|
||||||
|
const menu = this.menuEl;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const node = selection.anchorNode;
|
||||||
|
|
||||||
|
// No menu, nothing to do
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No suggestions or blur, remove menu
|
||||||
|
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||||
|
if (!hasSuggesstions) {
|
||||||
|
menu.removeAttribute('style');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Align menu overlay to editor node
|
||||||
|
if (node) {
|
||||||
|
const rect = node.parentElement.getBoundingClientRect();
|
||||||
|
menu.style.opacity = 1;
|
||||||
|
menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`;
|
||||||
|
menu.style.left = `${rect.left + window.scrollX - 2}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
menuRef = el => {
|
||||||
|
this.menuEl = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderMenu = () => {
|
||||||
|
const { suggestions } = this.state;
|
||||||
|
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||||
|
if (!hasSuggesstions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard selectedIndex to be within the length of the suggestions
|
||||||
|
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
||||||
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||||
|
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
||||||
|
const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : [];
|
||||||
|
|
||||||
|
// Create typeahead in DOM root so we can later position it absolutely
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<Typeahead
|
||||||
|
menuRef={this.menuRef}
|
||||||
|
selectedItems={selectedKeys}
|
||||||
|
onClickItem={this.handleClickMenu}
|
||||||
|
groupedItems={suggestions}
|
||||||
|
/>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="query-field">
|
||||||
|
{this.renderMenu()}
|
||||||
|
<Editor
|
||||||
|
autoCorrect={false}
|
||||||
|
onBlur={this.handleBlur}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
onChange={this.onChange}
|
||||||
|
onFocus={this.handleFocus}
|
||||||
|
placeholder={this.props.placeholder}
|
||||||
|
plugins={this.plugins}
|
||||||
|
spellCheck={false}
|
||||||
|
value={this.state.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QueryField;
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
// import TableModel from 'app/core/table_model';
|
||||||
|
|
||||||
|
const EMPTY_TABLE = {
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Table extends PureComponent<any, any> {
|
||||||
|
render() {
|
||||||
|
const { className = '', data } = this.props;
|
||||||
|
const tableModel = data || EMPTY_TABLE;
|
||||||
|
return (
|
||||||
|
<table className={`${className} filter-table`}>
|
||||||
|
<thead>
|
||||||
|
<tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableModel.rows.map((row, i) => <tr key={i}>{row.map((content, j) => <td key={j}>{content}</td>)}</tr>)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function scrollIntoView(el) {
|
||||||
|
if (!el || !el.offsetParent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = el.offsetParent;
|
||||||
|
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
|
||||||
|
container.scrollTop = el.offsetTop - container.offsetTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TypeaheadItem extends React.PureComponent<any, any> {
|
||||||
|
el: any;
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (this.props.isSelected && !prevProps.isSelected) {
|
||||||
|
scrollIntoView(this.el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRef = el => {
|
||||||
|
this.el = el;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { isSelected, label, onClickItem } = this.props;
|
||||||
|
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||||
|
const onClick = () => onClickItem(label);
|
||||||
|
return (
|
||||||
|
<li ref={this.getRef} className={className} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||||
|
render() {
|
||||||
|
const { items, label, selected, onClickItem } = this.props;
|
||||||
|
return (
|
||||||
|
<li className="typeahead-group">
|
||||||
|
<div className="typeahead-group__title">{label}</div>
|
||||||
|
<ul className="typeahead-group__list">
|
||||||
|
{items.map(item => (
|
||||||
|
<TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Typeahead extends React.PureComponent<any, any> {
|
||||||
|
render() {
|
||||||
|
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
|
||||||
|
return (
|
||||||
|
<ul className="typeahead" ref={menuRef}>
|
||||||
|
{groupedItems.map(g => (
|
||||||
|
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Typeahead;
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
|
import BracesPlugin from './braces';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
KeyboardEvent: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('braces', () => {
|
||||||
|
const handler = BracesPlugin().onKeyDown;
|
||||||
|
|
||||||
|
it('adds closing braces around empty value', () => {
|
||||||
|
const change = Plain.deserialize('').change();
|
||||||
|
const event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds closing braces around a value', () => {
|
||||||
|
const change = Plain.deserialize('foo').change();
|
||||||
|
const event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('(foo)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds closing braces around the following value only', () => {
|
||||||
|
const change = Plain.deserialize('foo bar ugh').change();
|
||||||
|
let event;
|
||||||
|
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('(foo) bar ugh');
|
||||||
|
|
||||||
|
// Wrap bar
|
||||||
|
change.move(5);
|
||||||
|
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('(foo) (bar) ugh');
|
||||||
|
|
||||||
|
// Create empty parens after (bar)
|
||||||
|
change.move(4);
|
||||||
|
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('(foo) (bar)() ugh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
const BRACES = {
|
||||||
|
'[': ']',
|
||||||
|
'{': '}',
|
||||||
|
'(': ')',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BracesPlugin() {
|
||||||
|
return {
|
||||||
|
onKeyDown(event, change) {
|
||||||
|
const { value } = change;
|
||||||
|
if (!value.isCollapsed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case '{':
|
||||||
|
case '[': {
|
||||||
|
event.preventDefault();
|
||||||
|
// Insert matching braces
|
||||||
|
change
|
||||||
|
.insertText(`${event.key}${BRACES[event.key]}`)
|
||||||
|
.move(-1)
|
||||||
|
.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '(': {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = value.anchorText.text;
|
||||||
|
const offset = value.anchorOffset;
|
||||||
|
const space = text.indexOf(' ', offset);
|
||||||
|
const length = space > 0 ? space : text.length;
|
||||||
|
const forward = length - offset;
|
||||||
|
// Insert matching braces
|
||||||
|
change
|
||||||
|
.insertText(event.key)
|
||||||
|
.move(forward)
|
||||||
|
.insertText(BRACES[event.key])
|
||||||
|
.move(-1 - forward)
|
||||||
|
.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
|
import ClearPlugin from './clear';
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
const handler = ClearPlugin().onKeyDown;
|
||||||
|
|
||||||
|
it('does not change the empty value', () => {
|
||||||
|
const change = Plain.deserialize('').change();
|
||||||
|
const event = new window.KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears to the end of the line', () => {
|
||||||
|
const change = Plain.deserialize('foo').change();
|
||||||
|
const event = new window.KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears from the middle to the end of the line', () => {
|
||||||
|
const change = Plain.deserialize('foo bar').change();
|
||||||
|
change.move(4);
|
||||||
|
const event = new window.KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
handler(event, change);
|
||||||
|
expect(Plain.serialize(change.value)).toEqual('foo ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Clears the rest of the line after the caret
|
||||||
|
export default function ClearPlugin() {
|
||||||
|
return {
|
||||||
|
onKeyDown(event, change) {
|
||||||
|
const { value } = change;
|
||||||
|
if (!value.isCollapsed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'k' && event.ctrlKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = value.anchorText.text;
|
||||||
|
const offset = value.anchorOffset;
|
||||||
|
const length = text.length;
|
||||||
|
const forward = length - offset;
|
||||||
|
change.deleteForward(forward);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
function getIndent(text) {
|
||||||
|
let offset = text.length - text.trimLeft().length;
|
||||||
|
if (offset) {
|
||||||
|
let indent = text[0];
|
||||||
|
while (--offset) {
|
||||||
|
indent += text[0];
|
||||||
|
}
|
||||||
|
return indent;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewlinePlugin() {
|
||||||
|
return {
|
||||||
|
onKeyDown(event, change) {
|
||||||
|
const { value } = change;
|
||||||
|
if (!value.isCollapsed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const { startBlock } = value;
|
||||||
|
const currentLineText = startBlock.text;
|
||||||
|
const indent = getIndent(currentLineText);
|
||||||
|
|
||||||
|
return change
|
||||||
|
.splitBlock()
|
||||||
|
.insertText(indent)
|
||||||
|
.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Prism from 'prismjs';
|
||||||
|
|
||||||
|
import Promql from './promql';
|
||||||
|
|
||||||
|
Prism.languages.promql = Promql;
|
||||||
|
|
||||||
|
const TOKEN_MARK = 'prism-token';
|
||||||
|
|
||||||
|
export function configurePrismMetricsTokens(metrics) {
|
||||||
|
Prism.languages.promql.metric = {
|
||||||
|
alias: 'variable',
|
||||||
|
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code-highlighting plugin based on Prism and
|
||||||
|
* https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
|
||||||
|
*
|
||||||
|
* (Adapted to handle nested grammar definitions.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function PrismPlugin() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Render a Slate mark with appropiate CSS class names
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @return {Element}
|
||||||
|
*/
|
||||||
|
|
||||||
|
renderMark(props) {
|
||||||
|
const { children, mark } = props;
|
||||||
|
// Only apply spans to marks identified by this plugin
|
||||||
|
if (mark.type !== TOKEN_MARK) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const className = `token ${mark.data.get('types')}`;
|
||||||
|
return <span className={className}>{children}</span>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorate code blocks with Prism.js highlighting.
|
||||||
|
*
|
||||||
|
* @param {Node} node
|
||||||
|
* @return {Array}
|
||||||
|
*/
|
||||||
|
|
||||||
|
decorateNode(node) {
|
||||||
|
if (node.type !== 'paragraph') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const texts = node.getTexts().toArray();
|
||||||
|
const tstring = texts.map(t => t.text).join('\n');
|
||||||
|
const grammar = Prism.languages.promql;
|
||||||
|
const tokens = Prism.tokenize(tstring, grammar);
|
||||||
|
const decorations = [];
|
||||||
|
let startText = texts.shift();
|
||||||
|
let endText = startText;
|
||||||
|
let startOffset = 0;
|
||||||
|
let endOffset = 0;
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
function processToken(token, acc?) {
|
||||||
|
// Accumulate token types down the tree
|
||||||
|
const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`;
|
||||||
|
|
||||||
|
// Add mark for token node
|
||||||
|
if (typeof token === 'string' || typeof token.content === 'string') {
|
||||||
|
startText = endText;
|
||||||
|
startOffset = endOffset;
|
||||||
|
|
||||||
|
const content = typeof token === 'string' ? token : token.content;
|
||||||
|
const newlines = content.split('\n').length - 1;
|
||||||
|
const length = content.length - newlines;
|
||||||
|
const end = start + length;
|
||||||
|
|
||||||
|
let available = startText.text.length - startOffset;
|
||||||
|
let remaining = length;
|
||||||
|
|
||||||
|
endOffset = startOffset + remaining;
|
||||||
|
|
||||||
|
while (available < remaining) {
|
||||||
|
endText = texts.shift();
|
||||||
|
remaining = length - available;
|
||||||
|
available = endText.text.length;
|
||||||
|
endOffset = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject marks from up the tree (acc) as well
|
||||||
|
if (typeof token !== 'string' || acc) {
|
||||||
|
const range = {
|
||||||
|
anchorKey: startText.key,
|
||||||
|
anchorOffset: startOffset,
|
||||||
|
focusKey: endText.key,
|
||||||
|
focusOffset: endOffset,
|
||||||
|
marks: [{ type: TOKEN_MARK, data: { types } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
decorations.push(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
start = end;
|
||||||
|
} else if (token.content && token.content.length) {
|
||||||
|
// Tokens can be nested
|
||||||
|
for (const subToken of token.content) {
|
||||||
|
processToken(subToken, types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process top-level tokens
|
||||||
|
for (const token of tokens) {
|
||||||
|
processToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return decorations;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
|
||||||
|
|
||||||
|
const AGGREGATION_OPERATORS = [
|
||||||
|
'sum',
|
||||||
|
'min',
|
||||||
|
'max',
|
||||||
|
'avg',
|
||||||
|
'stddev',
|
||||||
|
'stdvar',
|
||||||
|
'count',
|
||||||
|
'count_values',
|
||||||
|
'bottomk',
|
||||||
|
'topk',
|
||||||
|
'quantile',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FUNCTIONS = [
|
||||||
|
...AGGREGATION_OPERATORS,
|
||||||
|
'abs',
|
||||||
|
'absent',
|
||||||
|
'ceil',
|
||||||
|
'changes',
|
||||||
|
'clamp_max',
|
||||||
|
'clamp_min',
|
||||||
|
'count_scalar',
|
||||||
|
'day_of_month',
|
||||||
|
'day_of_week',
|
||||||
|
'days_in_month',
|
||||||
|
'delta',
|
||||||
|
'deriv',
|
||||||
|
'drop_common_labels',
|
||||||
|
'exp',
|
||||||
|
'floor',
|
||||||
|
'histogram_quantile',
|
||||||
|
'holt_winters',
|
||||||
|
'hour',
|
||||||
|
'idelta',
|
||||||
|
'increase',
|
||||||
|
'irate',
|
||||||
|
'label_replace',
|
||||||
|
'ln',
|
||||||
|
'log2',
|
||||||
|
'log10',
|
||||||
|
'minute',
|
||||||
|
'month',
|
||||||
|
'predict_linear',
|
||||||
|
'rate',
|
||||||
|
'resets',
|
||||||
|
'round',
|
||||||
|
'scalar',
|
||||||
|
'sort',
|
||||||
|
'sort_desc',
|
||||||
|
'sqrt',
|
||||||
|
'time',
|
||||||
|
'vector',
|
||||||
|
'year',
|
||||||
|
'avg_over_time',
|
||||||
|
'min_over_time',
|
||||||
|
'max_over_time',
|
||||||
|
'sum_over_time',
|
||||||
|
'count_over_time',
|
||||||
|
'quantile_over_time',
|
||||||
|
'stddev_over_time',
|
||||||
|
'stdvar_over_time',
|
||||||
|
];
|
||||||
|
|
||||||
|
const tokenizer = {
|
||||||
|
comment: {
|
||||||
|
pattern: /(^|[^\n])#.*/,
|
||||||
|
lookbehind: true,
|
||||||
|
},
|
||||||
|
'context-aggregation': {
|
||||||
|
pattern: /((by|without)\s*)\([^)]*\)/, // by ()
|
||||||
|
lookbehind: true,
|
||||||
|
inside: {
|
||||||
|
'label-key': {
|
||||||
|
pattern: /[^,\s][^,]*[^,\s]*/,
|
||||||
|
alias: 'attr-name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'context-labels': {
|
||||||
|
pattern: /\{[^}]*(?=})/,
|
||||||
|
inside: {
|
||||||
|
'label-key': {
|
||||||
|
pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/,
|
||||||
|
alias: 'attr-name',
|
||||||
|
},
|
||||||
|
'label-value': {
|
||||||
|
pattern: /"(?:\\.|[^\\"])*"/,
|
||||||
|
greedy: true,
|
||||||
|
alias: 'attr-value',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
|
||||||
|
'context-range': [
|
||||||
|
{
|
||||||
|
pattern: /\[[^\]]*(?=])/, // [1m]
|
||||||
|
inside: {
|
||||||
|
'range-duration': {
|
||||||
|
pattern: /\b\d+[smhdwy]\b/i,
|
||||||
|
alias: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /(offset\s+)\w+/, // offset 1m
|
||||||
|
lookbehind: true,
|
||||||
|
inside: {
|
||||||
|
'range-duration': {
|
||||||
|
pattern: /\b\d+[smhdwy]\b/i,
|
||||||
|
alias: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
|
||||||
|
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'),
|
||||||
|
punctuation: /[{};()`,.]/,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default tokenizer;
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
export default function RunnerPlugin({ handler }) {
|
||||||
|
return {
|
||||||
|
onKeyDown(event) {
|
||||||
|
// Handle enter
|
||||||
|
if (handler && event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
// Submit on Enter
|
||||||
|
event.preventDefault();
|
||||||
|
handler(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Based on underscore.js debounce()
|
||||||
|
export default function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function() {
|
||||||
|
const context = this;
|
||||||
|
const args = arguments;
|
||||||
|
const later = function() {
|
||||||
|
timeout = null;
|
||||||
|
func.apply(context, args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Node.closest() polyfill
|
||||||
|
if ('Element' in window && !Element.prototype.closest) {
|
||||||
|
Element.prototype.closest = function(s) {
|
||||||
|
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
|
||||||
|
let el = this;
|
||||||
|
let i;
|
||||||
|
// eslint-disable-next-line
|
||||||
|
do {
|
||||||
|
i = matches.length;
|
||||||
|
// eslint-disable-next-line
|
||||||
|
while (--i >= 0 && matches.item(i) !== el) {}
|
||||||
|
} while (i < 0 && (el = el.parentElement));
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousCousin(node, selector) {
|
||||||
|
let sibling = node.parentElement.previousSibling;
|
||||||
|
let el;
|
||||||
|
while (sibling) {
|
||||||
|
el = sibling.querySelector(selector);
|
||||||
|
if (el) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
sibling = sibling.previousSibling;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextCharacter(global = window) {
|
||||||
|
const selection = global.getSelection();
|
||||||
|
if (!selection.anchorNode) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const text = selection.anchorNode.textContent;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
return text.substr(offset, 1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
|
||||||
|
|
||||||
|
export function processLabels(labels) {
|
||||||
|
const values = {};
|
||||||
|
labels.forEach(l => {
|
||||||
|
const { __name__, ...rest } = l;
|
||||||
|
Object.keys(rest).forEach(key => {
|
||||||
|
if (!values[key]) {
|
||||||
|
values[key] = [];
|
||||||
|
}
|
||||||
|
if (values[key].indexOf(rest[key]) === -1) {
|
||||||
|
values[key].push(rest[key]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { values, keys: Object.keys(values) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip syntax chars
|
||||||
|
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
||||||
|
|
@ -8,11 +8,23 @@ import appEvents from 'app/core/app_events';
|
||||||
import Drop from 'tether-drop';
|
import Drop from 'tether-drop';
|
||||||
import { createStore } from 'app/stores/store';
|
import { createStore } from 'app/stores/store';
|
||||||
import colors from 'app/core/utils/colors';
|
import colors from 'app/core/utils/colors';
|
||||||
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
|
||||||
export class GrafanaCtrl {
|
export class GrafanaCtrl {
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, bridgeSrv, backendSrv) {
|
constructor(
|
||||||
createStore(backendSrv);
|
$scope,
|
||||||
|
alertSrv,
|
||||||
|
utilSrv,
|
||||||
|
$rootScope,
|
||||||
|
$controller,
|
||||||
|
contextSrv,
|
||||||
|
bridgeSrv,
|
||||||
|
backendSrv: BackendSrv,
|
||||||
|
datasourceSrv: DatasourceSrv
|
||||||
|
) {
|
||||||
|
createStore({ backendSrv, datasourceSrv });
|
||||||
|
|
||||||
$scope.init = function() {
|
$scope.init = function() {
|
||||||
$scope.contextSrv = contextSrv;
|
$scope.contextSrv = contextSrv;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class DatasourceSrv {
|
||||||
this.datasources = {};
|
this.datasources = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
get(name) {
|
get(name?) {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return this.get(config.defaultDatasource);
|
return this.get(config.defaultDatasource);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
|
import { Provider } from 'mobx-react';
|
||||||
|
|
||||||
import coreModule from 'app/core/core_module';
|
import coreModule from 'app/core/core_module';
|
||||||
import { store } from 'app/stores/store';
|
import { store } from 'app/stores/store';
|
||||||
import { Provider } from 'mobx-react';
|
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
|
||||||
function WrapInProvider(store, Component, props) {
|
function WrapInProvider(store, Component, props) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -13,14 +16,19 @@ function WrapInProvider(store, Component, props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @ngInject */
|
/** @ngInject */
|
||||||
export function reactContainer($route, $location, backendSrv) {
|
export function reactContainer($route, $location, backendSrv: BackendSrv, datasourceSrv: DatasourceSrv) {
|
||||||
return {
|
return {
|
||||||
restrict: 'E',
|
restrict: 'E',
|
||||||
template: '',
|
template: '',
|
||||||
link(scope, elem) {
|
link(scope, elem) {
|
||||||
let component = $route.current.locals.component;
|
let component = $route.current.locals.component;
|
||||||
let props = {
|
// Dynamic imports return whole module, need to extract default export
|
||||||
|
if (component.default) {
|
||||||
|
component = component.default;
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
backendSrv: backendSrv,
|
backendSrv: backendSrv,
|
||||||
|
datasourceSrv: datasourceSrv,
|
||||||
};
|
};
|
||||||
|
|
||||||
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
|
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import './dashboard_loaders';
|
import './dashboard_loaders';
|
||||||
import './ReactContainer';
|
import './ReactContainer';
|
||||||
|
|
||||||
import ServerStats from 'app/containers/ServerStats/ServerStats';
|
import ServerStats from 'app/containers/ServerStats/ServerStats';
|
||||||
import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
|
import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
|
||||||
|
// import Explore from 'app/containers/Explore/Explore';
|
||||||
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
|
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
|
||||||
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
|
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
|
||||||
|
|
||||||
|
|
@ -109,6 +111,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||||
controller: 'FolderDashboardsCtrl',
|
controller: 'FolderDashboardsCtrl',
|
||||||
controllerAs: 'ctrl',
|
controllerAs: 'ctrl',
|
||||||
})
|
})
|
||||||
|
.when('/explore', {
|
||||||
|
template: '<react-container />',
|
||||||
|
resolve: {
|
||||||
|
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
|
||||||
|
},
|
||||||
|
})
|
||||||
.when('/org', {
|
.when('/org', {
|
||||||
templateUrl: 'public/app/features/org/partials/orgDetails.html',
|
templateUrl: 'public/app/features/org/partials/orgDetails.html',
|
||||||
controller: 'OrgDetailsCtrl',
|
controller: 'OrgDetailsCtrl',
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import config from 'app/core/config';
|
||||||
|
|
||||||
export let store: IRootStore;
|
export let store: IRootStore;
|
||||||
|
|
||||||
export function createStore(backendSrv) {
|
export function createStore(services) {
|
||||||
store = RootStore.create(
|
store = RootStore.create(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
backendSrv: backendSrv,
|
...services,
|
||||||
navTree: config.bootData.navTree,
|
navTree: config.bootData.navTree,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -104,5 +104,6 @@
|
||||||
@import 'pages/signup';
|
@import 'pages/signup';
|
||||||
@import 'pages/styleguide';
|
@import 'pages/styleguide';
|
||||||
@import 'pages/errorpage';
|
@import 'pages/errorpage';
|
||||||
|
@import 'pages/explore';
|
||||||
@import 'old_responsive';
|
@import 'old_responsive';
|
||||||
@import 'components/view_states.scss';
|
@import 'components/view_states.scss';
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,13 @@
|
||||||
@include clearfix();
|
@include clearfix();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-full {
|
||||||
|
margin-left: $page-sidebar-margin;
|
||||||
|
padding-left: $spacer;
|
||||||
|
padding-right: $spacer;
|
||||||
|
@include clearfix();
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-canvas {
|
.scroll-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
.explore {
|
||||||
|
.graph-legend {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-field {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: Consolas, Menlo, Courier, monospace;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-field-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 7px 4px;
|
||||||
|
width: calc(100% - 6rem);
|
||||||
|
cursor: text;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: none;
|
||||||
|
border: 1px solid lightgray;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead {
|
||||||
|
position: absolute;
|
||||||
|
z-index: auto;
|
||||||
|
top: -10000px;
|
||||||
|
left: -10000px;
|
||||||
|
opacity: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: opacity 0.75s;
|
||||||
|
border: 1px solid #e4e4e4;
|
||||||
|
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);
|
||||||
|
transition: opacity 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-group__title {
|
||||||
|
color: rgba(0, 0, 0, 0.43);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-item {
|
||||||
|
line-height: 200%;
|
||||||
|
height: auto;
|
||||||
|
font-family: Consolas, Menlo, Courier, monospace;
|
||||||
|
padding: 0 16px 0 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-left: -1px;
|
||||||
|
left: 1px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||||
|
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.typeahead-item__selected {
|
||||||
|
background-color: #ecf6fd;
|
||||||
|
color: #108ee9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
|
||||||
|
-webkit-hyphens: none;
|
||||||
|
-moz-hyphens: none;
|
||||||
|
-ms-hyphens: none;
|
||||||
|
hyphens: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
code[class*='language'] {
|
||||||
|
max-height: inherit;
|
||||||
|
height: inherit;
|
||||||
|
padding: 0 1em;
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
: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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,7 @@ module.exports = merge(common, {
|
||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
options: {
|
options: {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
'syntax-dynamic-import',
|
||||||
'react-hot-loader/babel',
|
'react-hot-loader/babel',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,12 @@ module.exports = merge(common, {
|
||||||
test: /\.tsx?$/,
|
test: /\.tsx?$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
use: [
|
use: [
|
||||||
{ loader: "awesome-typescript-loader" }
|
{
|
||||||
|
loader: 'awesome-typescript-loader',
|
||||||
|
options: {
|
||||||
|
errorsAsWarnings: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
require('./sass.rule.js')({
|
require('./sass.rule.js')({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue