Merge remote-tracking branch 'origin/master' into stackdriver-plugin

This commit is contained in:
Daniel Lee
2018-09-26 17:59:56 +02:00
92 changed files with 547 additions and 193 deletions

View File

@@ -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={`elapsed-time ${className}`}>{value.toFixed(1)}s</span>;
}
}

View File

@@ -0,0 +1,628 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import Select from 'react-select';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors';
import store from 'app/core/store';
import TimeSeries from 'app/core/time_series2';
import { decodePathComponent } from 'app/core/utils/location_util';
import { parse as parseDate } from 'app/core/utils/datemath';
import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows';
import Graph from './Graph';
import Logs from './Logs';
import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
const MAX_HISTORY_ITEMS = 100;
function makeHints(hints) {
const hintsByIndex = [];
hints.forEach(hint => {
if (hint) {
hintsByIndex[hint.index] = hint;
}
});
return hintsByIndex;
}
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,
alias,
color,
unit: seriesData.unit,
});
return series;
});
}
function parseUrlState(initial: string | undefined) {
if (initial) {
try {
const parsed = JSON.parse(decodePathComponent(initial));
return {
datasource: parsed.datasource,
queries: parsed.queries.map(q => q.query),
range: parsed.range,
};
} catch (e) {
console.error(e);
}
}
return { datasource: null, queries: [], range: DEFAULT_RANGE };
}
interface ExploreState {
datasource: any;
datasourceError: any;
datasourceLoading: boolean | null;
datasourceMissing: boolean;
graphResult: any;
history: any[];
initialDatasource?: string;
latency: number;
loading: any;
logsResult: any;
queries: any[];
queryErrors: any[];
queryHints: any[];
range: any;
requestOptions: any;
showingGraph: boolean;
showingLogs: boolean;
showingTable: boolean;
supportsGraph: boolean | null;
supportsLogs: boolean | null;
supportsTable: boolean | null;
tableResult: any;
}
export class Explore extends React.Component<any, ExploreState> {
el: any;
constructor(props) {
super(props);
const initialState: ExploreState = props.initialState;
const { datasource, queries, range } = parseUrlState(props.routeParams.state);
this.state = {
datasource: null,
datasourceError: null,
datasourceLoading: null,
datasourceMissing: false,
graphResult: null,
initialDatasource: datasource,
history: [],
latency: 0,
loading: false,
logsResult: null,
queries: ensureQueries(queries),
queryErrors: [],
queryHints: [],
range: range || { ...DEFAULT_RANGE },
requestOptions: null,
showingGraph: true,
showingLogs: true,
showingTable: true,
supportsGraph: null,
supportsLogs: null,
supportsTable: null,
tableResult: null,
...initialState,
};
}
async componentDidMount() {
const { datasourceSrv } = this.props;
const { initialDatasource } = this.state;
if (!datasourceSrv) {
throw new Error('No datasource service passed as props.');
}
const datasources = datasourceSrv.getExploreSources();
if (datasources.length > 0) {
this.setState({ datasourceLoading: true });
// Priority: datasource in url, default datasource, first explore datasource
let datasource;
if (initialDatasource) {
datasource = await datasourceSrv.get(initialDatasource);
} else {
datasource = await datasourceSrv.get();
}
if (!datasource.meta.explore) {
datasource = await datasourceSrv.get(datasources[0].name);
}
this.setDatasource(datasource);
} else {
this.setState({ datasourceMissing: true });
}
}
componentDidCatch(error) {
this.setState({ datasourceError: error });
console.error(error);
}
async setDatasource(datasource) {
const supportsGraph = datasource.meta.metrics;
const supportsLogs = datasource.meta.logs;
const supportsTable = datasource.meta.metrics;
const datasourceId = datasource.meta.id;
let datasourceError = null;
try {
const testResult = await datasource.testDatasource();
datasourceError = testResult.status === 'success' ? null : testResult.message;
} catch (error) {
datasourceError = (error && error.statusText) || error;
}
const historyKey = `grafana.explore.history.${datasourceId}`;
const history = store.getObject(historyKey, []);
if (datasource.init) {
datasource.init();
}
// Keep queries but reset edit state
const nextQueries = this.state.queries.map(q => ({
...q,
edited: false,
}));
this.setState(
{
datasource,
datasourceError,
history,
supportsGraph,
supportsLogs,
supportsTable,
datasourceLoading: false,
queries: nextQueries,
},
() => datasourceError === null && this.onSubmit()
);
}
getRef = el => {
this.el = el;
};
onAddQueryRow = index => {
const { queries } = this.state;
const nextQueries = [
...queries.slice(0, index + 1),
{ query: '', key: generateQueryKey() },
...queries.slice(index + 1),
];
this.setState({ queries: nextQueries });
};
onChangeDatasource = async option => {
this.setState({
datasource: null,
datasourceError: null,
datasourceLoading: true,
graphResult: null,
latency: 0,
logsResult: null,
queryErrors: [],
queryHints: [],
tableResult: null,
});
const datasource = await this.props.datasourceSrv.get(option.value);
this.setDatasource(datasource);
};
onChangeQuery = (value: string, index: number, override?: boolean) => {
const { queries } = this.state;
let { queryErrors, queryHints } = this.state;
const prevQuery = queries[index];
const edited = override ? false : prevQuery.query !== value;
const nextQuery = {
...queries[index],
edited,
query: value,
};
const nextQueries = [...queries];
nextQueries[index] = nextQuery;
if (override) {
queryErrors = [];
queryHints = [];
}
this.setState(
{
queryErrors,
queryHints,
queries: nextQueries,
},
override ? () => this.onSubmit() : undefined
);
};
onChangeTime = nextRange => {
const range = {
from: nextRange.from,
to: nextRange.to,
};
this.setState({ range }, () => this.onSubmit());
};
onClickClear = () => {
this.setState({
graphResult: null,
logsResult: null,
latency: 0,
queries: ensureQueries(),
queryErrors: [],
queryHints: [],
tableResult: null,
});
};
onClickCloseSplit = () => {
const { onChangeSplit } = this.props;
if (onChangeSplit) {
onChangeSplit(false);
}
};
onClickGraphButton = () => {
this.setState(state => ({ showingGraph: !state.showingGraph }));
};
onClickLogsButton = () => {
this.setState(state => ({ showingLogs: !state.showingLogs }));
};
onClickSplit = () => {
const { onChangeSplit } = this.props;
const state = { ...this.state };
state.queries = state.queries.map(({ edited, ...rest }) => rest);
if (onChangeSplit) {
onChangeSplit(true, state);
}
};
onClickTableButton = () => {
this.setState(state => ({ showingTable: !state.showingTable }));
};
onClickTableCell = (columnKey: string, rowValue: string) => {
this.onModifyQueries({ type: 'ADD_FILTER', key: columnKey, value: rowValue });
};
onModifyQueries = (action: object, index?: number) => {
const { datasource, queries } = this.state;
if (datasource && datasource.modifyQuery) {
let nextQueries;
if (index === undefined) {
// Modify all queries
nextQueries = queries.map(q => ({
...q,
edited: false,
query: datasource.modifyQuery(q.query, action),
}));
} else {
// Modify query only at index
nextQueries = [
...queries.slice(0, index),
{
...queries[index],
edited: false,
query: datasource.modifyQuery(queries[index].query, action),
},
...queries.slice(index + 1),
];
}
this.setState({ queries: nextQueries }, () => this.onSubmit());
}
};
onRemoveQueryRow = index => {
const { queries } = this.state;
if (queries.length <= 1) {
return;
}
const nextQueries = [...queries.slice(0, index), ...queries.slice(index + 1)];
this.setState({ queries: nextQueries }, () => this.onSubmit());
};
onSubmit = () => {
const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state;
if (showingTable && supportsTable) {
this.runTableQuery();
}
if (showingGraph && supportsGraph) {
this.runGraphQuery();
}
if (showingLogs && supportsLogs) {
this.runLogsQuery();
}
};
onQuerySuccess(datasourceId: string, queries: any[]): void {
// save queries to history
let { history } = this.state;
const { datasource } = this.state;
if (datasource.meta.id !== datasourceId) {
// Navigated away, queries did not matter
return;
}
const ts = Date.now();
queries.forEach(q => {
const { query } = q;
history = [{ query, ts }, ...history];
});
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
// Combine all queries of a datasource type into one history
const historyKey = `grafana.explore.history.${datasourceId}`;
store.setObject(historyKey, history);
this.setState({ history });
}
buildQueryOptions(targetOptions: { format: string; hinting?: boolean; instant?: boolean }) {
const { datasource, queries, range } = this.state;
const resolution = this.el.offsetWidth;
const absoluteRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
const targets = queries.map(q => ({
...targetOptions,
expr: q.query,
}));
return {
interval,
range,
targets,
};
}
async runGraphQuery() {
const { datasource, queries } = this.state;
if (!hasQuery(queries)) {
return;
}
this.setState({ latency: 0, loading: true, graphResult: null, queryErrors: [], queryHints: [] });
const now = Date.now();
const options = this.buildQueryOptions({ format: 'time_series', instant: false, hinting: true });
try {
const res = await datasource.query(options);
const result = makeTimeSeriesList(res.data, options);
const queryHints = res.hints ? makeHints(res.hints) : [];
const latency = Date.now() - now;
this.setState({ latency, loading: false, graphResult: result, queryHints, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.setState({ loading: false, queryErrors: [queryError] });
}
}
async runTableQuery() {
const { datasource, queries } = this.state;
if (!hasQuery(queries)) {
return;
}
this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], tableResult: null });
const now = Date.now();
const options = this.buildQueryOptions({
format: 'table',
instant: true,
});
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 });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.setState({ loading: false, queryErrors: [queryError] });
}
}
async runLogsQuery() {
const { datasource, queries } = this.state;
if (!hasQuery(queries)) {
return;
}
this.setState({ latency: 0, loading: true, queryErrors: [], queryHints: [], logsResult: null });
const now = Date.now();
const options = this.buildQueryOptions({
format: 'logs',
});
try {
const res = await datasource.query(options);
const logsData = res.data;
const latency = Date.now() - now;
this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options });
this.onQuerySuccess(datasource.meta.id, queries);
} catch (response) {
console.error(response);
const queryError = response.data ? response.data.error : response;
this.setState({ loading: false, queryErrors: [queryError] });
}
}
request = url => {
const { datasource } = this.state;
return datasource.metadataRequest(url);
};
render() {
const { datasourceSrv, position, split } = this.props;
const {
datasource,
datasourceError,
datasourceLoading,
datasourceMissing,
graphResult,
history,
latency,
loading,
logsResult,
queries,
queryErrors,
queryHints,
range,
requestOptions,
showingGraph,
showingLogs,
showingTable,
supportsGraph,
supportsLogs,
supportsTable,
tableResult,
} = this.state;
const showingBoth = showingGraph && showingTable;
const graphHeight = showingBoth ? '200px' : '400px';
const graphButtonActive = showingBoth || showingGraph ? 'active' : '';
const logsButtonActive = showingLogs ? 'active' : '';
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
const exploreClass = split ? 'explore explore-split' : 'explore';
const datasources = datasourceSrv.getExploreSources().map(ds => ({
value: ds.name,
label: ds.name,
}));
const selectedDatasource = datasource ? datasource.name : undefined;
return (
<div className={exploreClass} ref={this.getRef}>
<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.onClickCloseSplit}>
Close Split
</button>
</div>
)}
{!datasourceMissing ? (
<div className="navbar-buttons">
<Select
className="datasource-picker"
clearable={false}
onChange={this.onChangeDatasource}
options={datasources}
placeholder="Loading datasources..."
value={selectedDatasource}
/>
</div>
) : null}
<div className="navbar__spacer" />
{position === 'left' && !split ? (
<div className="navbar-buttons">
<button className="btn navbar-button" onClick={this.onClickSplit}>
Split
</button>
</div>
) : null}
<TimePicker range={range} onChangeTime={this.onChangeTime} />
<div className="navbar-buttons">
<button className="btn navbar-button navbar-button--no-icon" onClick={this.onClickClear}>
Clear All
</button>
</div>
<div className="navbar-buttons relative">
<button className="btn navbar-button--primary" onClick={this.onSubmit}>
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}
{datasourceMissing ? (
<div className="explore-container">Please add a datasource that supports Explore (e.g., Prometheus).</div>
) : null}
{datasourceError ? (
<div className="explore-container">Error connecting to datasource. [{datasourceError}]</div>
) : null}
{datasource && !datasourceError ? (
<div className="explore-container">
<QueryRows
history={history}
queries={queries}
queryErrors={queryErrors}
queryHints={queryHints}
request={this.request}
onAddQueryRow={this.onAddQueryRow}
onChangeQuery={this.onChangeQuery}
onClickHintFix={this.onModifyQueries}
onExecuteQuery={this.onSubmit}
onRemoveQueryRow={this.onRemoveQueryRow}
supportsLogs={supportsLogs}
/>
<div className="result-options">
{supportsGraph ? (
<button className={`btn navbar-button ${graphButtonActive}`} onClick={this.onClickGraphButton}>
Graph
</button>
) : null}
{supportsTable ? (
<button className={`btn navbar-button ${tableButtonActive}`} onClick={this.onClickTableButton}>
Table
</button>
) : null}
{supportsLogs ? (
<button className={`btn navbar-button ${logsButtonActive}`} onClick={this.onClickLogsButton}>
Logs
</button>
) : null}
</div>
<main className="m-t-2">
{supportsGraph && showingGraph ? (
<Graph
data={graphResult}
height={graphHeight}
loading={loading}
id={`explore-graph-${position}`}
options={requestOptions}
split={split}
/>
) : null}
{supportsTable && showingTable ? (
<Table className="m-t-3" data={tableResult} loading={loading} onClickCell={this.onClickTableCell} />
) : null}
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={loading} /> : null}
</main>
</div>
) : null}
</div>
);
}
}
export default hot(module)(Explore);

View File

@@ -0,0 +1,144 @@
import $ from 'jquery';
import React, { Component } from 'react';
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) {
if (min && max && ticks) {
const range = max - min;
const secPerTick = range / ticks / 1000;
const oneDay = 86400000;
const 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.split !== this.props.split ||
prevProps.height !== this.props.height
) {
this.draw();
}
}
draw() {
const { data, options: userOptions } = this.props;
const $el = $(`#${this.props.id}`);
if (!data) {
$el.empty();
return;
}
const series = data.map((ts: TimeSeries) => ({
color: ts.color,
label: ts.label,
data: ts.getFlotPairs('null'),
}));
const ticks = $el.width() / 100;
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',
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 { data, height, loading } = this.props;
if (!loading && data && data.length === 0) {
return (
<div className="panel-container">
<div className="muted m-a-1">The queries returned no time series to graph.</div>
</div>
);
}
return (
<div className="panel-container">
<div id={this.props.id} className="explore-graph" style={{ height }} />
<Legend data={data} />
</div>
);
}
}
export default Graph;

View File

@@ -0,0 +1,9 @@
import React from 'react';
export default function({ value }) {
return (
<div>
<pre>{JSON.stringify(value, undefined, 2)}</pre>
</div>
);
}

View File

@@ -0,0 +1,24 @@
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" title={series.alias}>
{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>
);
}
}

View File

@@ -0,0 +1,67 @@
import React, { Fragment, PureComponent } from 'react';
import { LogsModel, LogRow } from 'app/core/logs_model';
interface LogsProps {
className?: string;
data: LogsModel;
loading: boolean;
}
const EXAMPLE_QUERY = '{job="default/prometheus"}';
const Entry: React.SFC<LogRow> = props => {
const { entry, searchMatches } = props;
if (searchMatches && searchMatches.length > 0) {
let lastMatchEnd = 0;
const spans = searchMatches.reduce((acc, match, i) => {
// Insert non-match
if (match.start !== lastMatchEnd) {
acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
}
// Match
acc.push(
<span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
{entry.substr(match.start, match.length)}
</span>
);
lastMatchEnd = match.start + match.length;
// Non-matching end
if (i === searchMatches.length - 1) {
acc.push(<>{entry.slice(lastMatchEnd)}</>);
}
return acc;
}, []);
return <>{spans}</>;
}
return <>{props.entry}</>;
};
export default class Logs extends PureComponent<LogsProps, any> {
render() {
const { className = '', data } = this.props;
const hasData = data && data.rows && data.rows.length > 0;
return (
<div className={`${className} logs`}>
{hasData ? (
<div className="logs-entries panel-container">
{data.rows.map(row => (
<Fragment key={row.key}>
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
<div>
<Entry {...row} />
</div>
</Fragment>
))}
</div>
) : null}
{!hasData ? (
<div className="panel-container">
Enter a query like <code>{EXAMPLE_QUERY}</code>
</div>
) : null}
</div>
);
}
}

View File

@@ -0,0 +1,238 @@
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import Plain from 'slate-plain-serializer';
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
Enzyme.configure({ adapter: new Adapter() });
describe('PromQueryField typeahead handling', () => {
const defaultProps = {
request: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const value = Plain.deserialize('{}');
const range = value.selection.merge({
anchorOffset: 1,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on label context but leaves out labels that already exist', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{job="foo"}': ['bar', 'job'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('{job="foo",}');
const range = value.selection.merge({
anchorOffset: 11,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="foo"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('metric{}');
const range = value.selection.merge({
anchorOffset: 7,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-labels'],
value: valueWithSelection,
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = shallow(
<PromQueryField
{...defaultProps}
labelKeys={{ '{__name__="metric"}': ['bar'] }}
labelValues={{ '{__name__="metric"}': { bar: ['baz'] } }}
/>
).instance() as PromQueryField;
const value = Plain.deserialize('metric{bar=ba}');
const range = value.selection.merge({
anchorOffset: 13,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
labelKey: 'bar',
value: valueWithSelection,
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
});
it('returns label suggestions on aggregation context and metric w/ selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric",foo="xx"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
const range = value.selection.merge({
anchorOffset: 26,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns label suggestions on aggregation context and metric w/o selector', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ '{__name__="metric"}': ['bar'] }} />
).instance() as PromQueryField;
const value = Plain.deserialize('sum(metric) by ()');
const range = value.selection.merge({
anchorOffset: 16,
});
const valueWithSelection = value.change().select(range).value;
const result = instance.getTypeahead({
text: '',
prefix: '',
wrapperClasses: ['context-aggregation'],
value: valueWithSelection,
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
});
});
describe('groupMetricsByPrefix()', () => {
it('returns an empty group for no metrics', () => {
expect(groupMetricsByPrefix([])).toEqual([]);
});
it('returns options grouped by prefix', () => {
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
{
value: 'foo',
children: [
{
value: 'foo_metric',
},
],
},
]);
});
it('returns options without prefix as toplevel option', () => {
expect(groupMetricsByPrefix(['metric'])).toMatchObject([
{
value: 'metric',
},
]);
});
it('returns recording rules grouped separately', () => {
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
{
value: RECORDING_RULES_GROUP,
children: [
{
value: ':foo_metric:',
},
],
},
]);
});
});

View File

@@ -0,0 +1,597 @@
import _ from 'lodash';
import moment from 'moment';
import React from 'react';
import { Value } from 'slate';
import Cascader from 'rc-cascader';
import PluginPrism from 'slate-prism';
import Prism from 'prismjs';
// dom also includes Element polyfills
import { getNextCharacter, getPreviousCousin } from './utils/dom';
import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
import BracesPlugin from './slate-plugins/braces';
import RunnerPlugin from './slate-plugins/runner';
import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
import TypeaheadField, {
Suggestion,
SuggestionGroup,
TypeaheadInput,
TypeaheadFieldState,
TypeaheadOutput,
} from './QueryField';
const DEFAULT_KEYS = ['job', 'instance'];
const EMPTY_SELECTOR = '{}';
const HISTOGRAM_GROUP = '__histograms__';
const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
const HISTORY_ITEM_COUNT = 5;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
const METRIC_MARK = 'metric';
const PRISM_SYNTAX = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__';
export const wrapLabel = (label: string) => ({ label });
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
suggestion.move = -1;
return suggestion;
};
// Syntax highlighting
Prism.languages[PRISM_SYNTAX] = PrismPromql;
function setPrismTokens(language, field, values, alias = 'variable') {
Prism.languages[language][field] = {
alias,
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
};
}
export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
const count = historyForItem.length;
const recent = historyForItem[0];
let hint = `Queried ${count} times in the last 24h.`;
if (recent) {
const lastQueried = moment(recent.ts).fromNow();
hint = `${hint} Last queried ${lastQueried}.`;
}
return {
...item,
documentation: hint,
};
}
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
// Filter out recording rules and insert as first option
const ruleRegex = /:\w+:/;
const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
const rulesOption = {
label: 'Recording rules',
value: RECORDING_RULES_GROUP,
children: ruleNames
.slice()
.sort()
.map(name => ({ label: name, value: name })),
};
const options = ruleNames.length > 0 ? [rulesOption] : [];
const metricsOptions = _.chain(metrics)
.filter(metric => !ruleRegex.test(metric))
.groupBy(metric => metric.split(delimiter)[0])
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
return {
children,
label: prefix,
value: prefix,
};
})
.sortBy('label')
.value();
return [...options, ...metricsOptions];
}
export function willApplySuggestion(
suggestion: string,
{ typeaheadContext, typeaheadText }: TypeaheadFieldState
): string {
// 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:
}
return suggestion;
}
interface CascaderOption {
label: string;
value: string;
children?: CascaderOption[];
disabled?: boolean;
}
interface PromQueryFieldProps {
error?: string;
hint?: any;
histogramMetrics?: string[];
history?: any[];
initialQuery?: string | null;
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
metricsByPrefix?: CascaderOption[];
onClickHintFix?: (action: any) => void;
onPressEnter?: () => void;
onQueryChange?: (value: string, override?: boolean) => void;
portalPrefix?: string;
request?: (url: string) => any;
supportsLogs?: boolean; // To be removed after Logging gets its own query field
}
interface PromQueryFieldState {
histogramMetrics: string[];
labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[];
metrics: string[];
metricsByPrefix: CascaderOption[];
}
interface PromTypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
}
class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
constructor(props: PromQueryFieldProps, context) {
super(props, context);
this.plugins = [
BracesPlugin(),
RunnerPlugin({ handler: props.onPressEnter }),
PluginPrism({
onlyIn: node => node.type === 'code_block',
getSyntax: node => 'promql',
}),
];
this.state = {
histogramMetrics: props.histogramMetrics || [],
labelKeys: props.labelKeys || {},
labelValues: props.labelValues || {},
logLabelOptions: [],
metrics: props.metrics || [],
metricsByPrefix: props.metricsByPrefix || [],
};
}
componentDidMount() {
// Temporarily reused by logging
const { supportsLogs } = this.props;
if (supportsLogs) {
this.fetchLogLabels();
} else {
// Usual actions
this.fetchMetricNames();
this.fetchHistogramMetrics();
}
}
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const key = selectedOptions[0].value;
const value = selectedOptions[1].value;
query = `{${key}="${value}"}`;
}
this.onChangeQuery(query, true);
};
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
return;
}
} else {
const prefix = selectedOptions[0].value;
const metric = selectedOptions[1].value;
if (prefix === HISTOGRAM_GROUP) {
query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
} else {
query = metric;
}
}
this.onChangeQuery(query, true);
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { onQueryChange } = this.props;
if (onQueryChange) {
onQueryChange(value, override);
}
};
onClickHintFix = () => {
const { hint, onClickHintFix } = this.props;
if (onClickHintFix && hint && hint.fix) {
onClickHintFix(hint.fix.action);
}
};
onReceiveMetrics = () => {
if (!this.state.metrics) {
return;
}
setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
};
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
const { prefix, text, value, wrapperNode } = typeahead;
// Get DOM-dependent context
const wrapperClasses = Array.from(wrapperNode.classList);
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
const labelKey = labelKeyNode && labelKeyNode.textContent;
const nextChar = getNextCharacter();
const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
return result;
};
// Keep this DOM-free for testing
getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
// Syntax spans have 3 classes by default. More indicate a recognized token
const tokenRecognized = wrapperClasses.length > 3;
// Determine candidates by CSS context
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeTypeahead();
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelTypeahead.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
return this.getAggregationTypeahead.apply(this, arguments);
} else if (
// Non-empty but not inside known token
(prefix && !tokenRecognized) ||
(prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
text.match(/[+\-*/^%]/) // After binary operator
) {
return this.getEmptyTypeahead();
}
return {
suggestions: [],
};
}
getEmptyTypeahead(): TypeaheadOutput {
const { history } = this.props;
const { metrics } = this.state;
const suggestions: SuggestionGroup[] = [];
if (history && history.length > 0) {
const historyItems = _.chain(history)
.uniqBy('query')
.take(HISTORY_ITEM_COUNT)
.map(h => h.query)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.value();
suggestions.push({
prefixMatch: true,
skipSort: true,
label: 'History',
items: historyItems,
});
}
suggestions.push({
prefixMatch: true,
label: 'Functions',
items: FUNCTIONS.map(setFunctionMove),
});
if (metrics) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
}
getRangeTypeahead(): TypeaheadOutput {
return {
context: 'context-range',
suggestions: [
{
label: 'Range vector',
items: [...RATE_RANGES].map(wrapLabel),
},
],
};
}
getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
// sum(foo{bar="1"}) by (|)
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// sum(foo{bar="1"}) by (
const leftSide = line.slice(0, cursorOffset);
const openParensAggregationIndex = leftSide.lastIndexOf('(');
const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
// foo{bar="1"}
const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelKeys = this.state.labelKeys[selector];
if (labelKeys) {
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
} else {
refresher = this.fetchSeriesLabels(selector);
}
return {
refresher,
suggestions,
context: 'context-aggregation',
};
}
getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: SuggestionGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
let parsedSelector;
try {
parsedSelector = parseSelector(line, cursorOffset);
selector = parsedSelector.selector;
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
const labelValues = this.state.labelValues[selector][labelKey];
context = 'context-label-values';
suggestions.push({
label: `Label values for "${labelKey}"`,
items: labelValues.map(wrapLabel),
});
}
} else {
// Label keys
const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
if (labelKeys) {
const possibleKeys = _.difference(labelKeys, existingKeys);
if (possibleKeys.length > 0) {
context = 'context-labels';
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
// Query labels for selector
// Temporarily add skip for logging
if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
if (selector === EMPTY_SELECTOR) {
// Query label values for default labels
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
} else {
refresher = this.fetchSeriesLabels(selector, !containsMetric);
}
}
return { context, refresher, suggestions };
}
request = url => {
if (this.props.request) {
return this.props.request(url);
}
return fetch(url);
};
fetchHistogramMetrics() {
this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
if (histogramSeries && histogramSeries['__name__']) {
const histogramMetrics = histogramSeries['__name__'].slice().sort();
this.setState({ histogramMetrics });
}
});
}
// Temporarily here while reusing this field for logging
async fetchLogLabels() {
const url = '/api/prom/label';
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort();
const labelKeysBySelector = {
...this.state.labelKeys,
[EMPTY_SELECTOR]: labelKeys,
};
const labelValuesByKey = {};
const logLabelOptions = [];
for (const key of labelKeys) {
const valuesUrl = `/api/prom/label/${key}/values`;
const res = await this.request(valuesUrl);
const body = await (res.data || res.json());
const values = body.data.slice().sort();
labelValuesByKey[key] = values;
logLabelOptions.push({
label: key,
value: key,
children: values.map(value => ({ label: value, value })),
});
}
const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
} catch (e) {
console.error(e);
}
}
async fetchLabelValues(key: string) {
const url = `/api/v1/label/${key}/values`;
try {
const res = await this.request(url);
const body = await (res.data || res.json());
const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
const values = {
...exisingValues,
[key]: body.data,
};
const labelValues = {
...this.state.labelValues,
[EMPTY_SELECTOR]: values,
};
this.setState({ labelValues });
} catch (e) {
console.error(e);
}
}
async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
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, withName);
const labelKeys = {
...this.state.labelKeys,
[name]: keys,
};
const labelValues = {
...this.state.labelValues,
[name]: values,
};
this.setState({ labelKeys, labelValues }, callback);
} catch (e) {
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());
const metrics = body.data;
const metricsByPrefix = groupMetricsByPrefix(metrics);
this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
} catch (error) {
console.error(error);
}
}
render() {
const { error, hint, supportsLogs } = this.props;
const { histogramMetrics, logLabelOptions, metricsByPrefix } = this.state;
const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
const metricsOptions = [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
...metricsByPrefix,
];
return (
<div className="prom-query-field">
<div className="prom-query-field-tools">
{supportsLogs ? (
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
<button className="btn navbar-button navbar-button--tight">Log labels</button>
</Cascader>
) : (
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
<button className="btn navbar-button navbar-button--tight">Metrics</button>
</Cascader>
)}
</div>
<div className="prom-query-field-wrapper">
<div className="slate-query-field-wrapper">
<TypeaheadField
additionalPlugins={this.plugins}
cleanText={cleanText}
initialValue={this.props.initialQuery}
onTypeahead={this.onTypeahead}
onWillApplySuggestion={willApplySuggestion}
onValueChanged={this.onChangeQuery}
placeholder="Enter a PromQL query"
/>
</div>
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
{hint ? (
<div className="prom-query-field-info text-warning">
{hint.label}{' '}
{hint.fix ? (
<a className="text-link muted" onClick={this.onClickHintFix}>
{hint.fix.label}
</a>
) : null}
</div>
) : null}
</div>
</div>
);
}
}
export default PromQueryField;

View File

@@ -0,0 +1,505 @@
import _ from 'lodash';
import React from 'react';
import ReactDOM from 'react-dom';
import { Change, Value } from 'slate';
import { Editor } from 'slate-react';
import Plain from 'slate-plain-serializer';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import Typeahead from './Typeahead';
import { makeFragment, makeValue } from './Value';
export const TYPEAHEAD_DEBOUNCE = 300;
function flattenSuggestions(s: any[]): any[] {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
}
export interface Suggestion {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface SuggestionGroup {
/**
* Label that will be displayed for all entries of this group.
*/
label: string;
/**
* List of suggestions of this group.
*/
items: Suggestion[];
/**
* If true, match only by prefix (and not mid-word).
*/
prefixMatch?: boolean;
/**
* If true, do not filter items in this group based on the search.
*/
skipFilter?: boolean;
/**
* If true, do not sort items.
*/
skipSort?: boolean;
}
interface TypeaheadFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
initialValue: string | null;
onBlur?: () => void;
onFocus?: () => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onValueChanged?: (value: Value) => void;
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
placeholder?: string;
portalPrefix?: string;
syntax?: string;
}
export interface TypeaheadFieldState {
suggestions: SuggestionGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
typeaheadText: string;
value: Value;
}
export interface TypeaheadInput {
editorNode: Element;
prefix: string;
selection?: Selection;
text: string;
value: Value;
wrapperNode: Element;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: SuggestionGroup[];
}
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
menuEl: HTMLElement | null;
plugins: any[];
resetTimer: any;
constructor(props, context) {
super(props, context);
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
this.state = {
suggestions: [],
typeaheadContext: null,
typeaheadIndex: 0,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.initialValue || '', props.syntax),
};
}
componentDidMount() {
this.updateMenu();
}
componentWillUnmount() {
clearTimeout(this.resetTimer);
}
componentDidUpdate() {
this.updateMenu();
}
componentWillReceiveProps(nextProps) {
// initialValue is null in case the user typed
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
this.setState({ value: makeValue(nextProps.initialValue, nextProps.syntax) });
}
}
onChange = ({ value }) => {
const changed = value.document !== this.state.value.document;
this.setState({ value }, () => {
if (changed) {
this.handleChangeValue();
}
});
if (changed) {
window.requestAnimationFrame(this.handleTypeahead);
}
};
handleChangeValue = () => {
// Send text change to parent
const { onValueChanged } = this.props;
if (onValueChanged) {
onValueChanged(Plain.serialize(this.state.value));
}
};
handleTypeahead = _.debounce(async () => {
const selection = window.getSelection();
const { cleanText, onTypeahead } = this.props;
const { value } = this.state;
if (onTypeahead && selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement;
const editorNode = wrapperNode.closest('.slate-query-field');
if (!editorNode || this.state.value.isBlurred) {
// Not inside this editor
return;
}
const range = selection.getRangeAt(0);
const offset = range.startOffset;
const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset);
if (cleanText) {
prefix = cleanText(prefix);
}
const { suggestions, context, refresher } = onTypeahead({
editorNode,
prefix,
selection,
text,
value,
wrapperNode,
});
const filteredSuggestions = suggestions
.map(group => {
if (group.items) {
if (prefix) {
// Filter groups based on prefix
if (!group.skipFilter) {
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
if (group.prefixMatch) {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
} else {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
}
}
// Filter out the already typed value (prefix) unless it inserts custom text
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
}
if (!group.skipSort) {
group.items = _.sortBy(group.items, item => item.sortText || item.label);
}
}
return group;
})
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
this.setState(
{
suggestions: filteredSuggestions,
typeaheadPrefix: prefix,
typeaheadContext: context,
typeaheadText: text,
},
() => {
if (refresher) {
refresher.then(this.handleTypeahead).catch(e => console.error(e));
}
}
);
}
}, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change: Change, suggestion: Suggestion): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
const move = suggestion.move || 0;
if (onWillApplySuggestion) {
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
}
this.resetTypeahead();
// Remove the current, incomplete text and replace it with the selected suggestion
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
const suffixLength = text.length - typeaheadPrefix.length;
const offset = typeaheadText.indexOf(typeaheadPrefix);
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
const forward = midWord ? suffixLength + offset : 0;
// If new-lines, apply suggestion as block
if (suggestionText.match(/\n/)) {
const fragment = makeFragment(suggestionText, syntax);
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertFragment(fragment)
.focus();
}
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.move(move)
.focus();
}
onKeyDown = (event, change) => {
const { typeaheadIndex, suggestions } = this.state;
switch (event.key) {
case 'Escape': {
if (this.menuEl) {
event.preventDefault();
event.stopPropagation();
this.resetTypeahead();
return true;
}
break;
}
case ' ': {
if (event.ctrlKey) {
event.preventDefault();
this.handleTypeahead();
return true;
}
break;
}
case 'Enter':
case 'Tab': {
if (this.menuEl) {
// 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;
}
break;
}
case 'ArrowDown': {
if (this.menuEl) {
// Select next suggestion
event.preventDefault();
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
}
break;
}
case 'ArrowUp': {
if (this.menuEl) {
// 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,
});
};
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();
}
};
onClickMenu = (item: Suggestion) => {
// 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) {
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Write DOM
requestAnimationFrame(() => {
menu.style.opacity = '1';
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + scrollX - 2}px`;
});
}
};
menuRef = el => {
this.menuEl = el;
};
renderMenu = () => {
const { portalPrefix } = this.props;
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 selectedItem: Suggestion | null =
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
// Create typeahead in DOM root so we can later position it absolutely
return (
<Portal prefix={portalPrefix}>
<Typeahead
menuRef={this.menuRef}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
groupedItems={suggestions}
/>
</Portal>
);
};
render() {
return (
<div className="slate-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>
);
}
}
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
node: HTMLElement;
constructor(props) {
super(props);
const { index = 0, prefix = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}
export default QueryField;

View File

@@ -0,0 +1,99 @@
import React, { PureComponent } from 'react';
// TODO make this datasource-plugin-dependent
import QueryField from './PromQueryField';
class QueryRow extends PureComponent<any, {}> {
onChangeQuery = (value, override?: boolean) => {
const { index, onChangeQuery } = this.props;
if (onChangeQuery) {
onChangeQuery(value, index, override);
}
};
onClickAddButton = () => {
const { index, onAddQueryRow } = this.props;
if (onAddQueryRow) {
onAddQueryRow(index);
}
};
onClickClearButton = () => {
this.onChangeQuery('', true);
};
onClickHintFix = action => {
const { index, onClickHintFix } = this.props;
if (onClickHintFix) {
onClickHintFix(action, index);
}
};
onClickRemoveButton = () => {
const { index, onRemoveQueryRow } = this.props;
if (onRemoveQueryRow) {
onRemoveQueryRow(index);
}
};
onPressEnter = () => {
const { onExecuteQuery } = this.props;
if (onExecuteQuery) {
onExecuteQuery();
}
};
render() {
const { edited, history, query, queryError, queryHint, request, supportsLogs } = this.props;
return (
<div className="query-row">
<div className="query-row-field">
<QueryField
error={queryError}
hint={queryHint}
initialQuery={edited ? null : query}
history={history}
portalPrefix="explore"
onClickHintFix={this.onClickHintFix}
onPressEnter={this.onPressEnter}
onQueryChange={this.onChangeQuery}
request={request}
supportsLogs={supportsLogs}
/>
</div>
<div className="query-row-tools">
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickClearButton}>
<i className="fa fa-times" />
</button>
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickAddButton}>
<i className="fa fa-plus" />
</button>
<button className="btn navbar-button navbar-button--tight" onClick={this.onClickRemoveButton}>
<i className="fa fa-minus" />
</button>
</div>
</div>
);
}
}
export default class QueryRows extends PureComponent<any, {}> {
render() {
const { className = '', queries, queryErrors = [], queryHints = [], ...handlers } = this.props;
return (
<div className={className}>
{queries.map((q, index) => (
<QueryRow
key={q.key}
index={index}
query={q.query}
queryError={queryErrors[index]}
queryHint={queryHints[index]}
edited={q.edited}
{...handlers}
/>
))}
</div>
);
}
}

View File

@@ -0,0 +1,84 @@
import React, { PureComponent } from 'react';
import TableModel from 'app/core/table_model';
const EMPTY_TABLE = new TableModel();
interface TableProps {
className?: string;
data: TableModel;
loading: boolean;
onClickCell?: (columnKey: string, rowValue: string) => void;
}
interface SFCCellProps {
columnIndex: number;
onClickCell?: (columnKey: string, rowValue: string, columnIndex: number, rowIndex: number, table: TableModel) => void;
rowIndex: number;
table: TableModel;
value: string;
}
function Cell(props: SFCCellProps) {
const { columnIndex, rowIndex, table, value, onClickCell } = props;
const column = table.columns[columnIndex];
if (column && column.filterable && onClickCell) {
const onClick = event => {
event.preventDefault();
onClickCell(column.text, value, columnIndex, rowIndex, table);
};
return (
<td>
<a className="link" onClick={onClick}>
{value}
</a>
</td>
);
}
return <td>{value}</td>;
}
export default class Table extends PureComponent<TableProps, {}> {
render() {
const { className = '', data, loading, onClickCell } = this.props;
const tableModel = data || EMPTY_TABLE;
if (!loading && data && data.rows.length === 0) {
return (
<table className={`${className} filter-table`}>
<thead>
<tr>
<th>Table</th>
</tr>
</thead>
<tbody>
<tr>
<td className="muted">The queries returned no data for a table.</td>
</tr>
</tbody>
</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((value, j) => (
<Cell
key={j}
columnIndex={j}
rowIndex={i}
value={String(value)}
table={data}
onClickCell={onClickCell}
/>
))}
</tr>
))}
</tbody>
</table>
);
}
}

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, 10);
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,94 @@
import React from 'react';
import { Suggestion, SuggestionGroup } from './QueryField';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
return;
}
const container = el.offsetParent as HTMLElement;
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
container.scrollTop = el.offsetTop - container.offsetTop;
}
}
interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
onClickItem: (Suggestion) => void;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
el: HTMLElement;
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el);
}
}
getRef = el => {
this.el = el;
};
onClick = () => {
this.props.onClickItem(this.props.item);
};
render() {
const { isSelected, item } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
return (
<li ref={this.getRef} className={className} onClick={this.onClick}>
{item.detail || item.label}
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li>
);
}
}
interface TypeaheadGroupProps {
items: Suggestion[];
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
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 => {
return (
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
);
})}
</ul>
</li>
);
}
}
interface TypeaheadProps {
groupedItems: SuggestionGroup[];
menuRef: any;
selectedItem: Suggestion | null;
onClickItem: (Suggestion) => void;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
render() {
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
return (
<ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
))}
</ul>
);
}
}
export default Typeahead;

View File

@@ -0,0 +1,41 @@
import { Block, Document, Text, Value } from 'slate';
const SCHEMA = {
blocks: {
paragraph: 'paragraph',
codeblock: 'code_block',
codeline: 'code_line',
},
inlines: {},
marks: {},
};
export const makeFragment = (text: string, syntax?: string) => {
const lines = text.split('\n').map(line =>
Block.create({
type: 'code_line',
nodes: [Text.create(line)],
})
);
const block = Block.create({
data: {
syntax,
},
type: 'code_block',
nodes: lines,
});
return Document.create({
nodes: [block],
});
};
export const makeValue = (text: string, syntax?: string) => {
const fragment = makeFragment(text, syntax);
return Value.create({
document: fragment,
SCHEMA,
});
};

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

@@ -0,0 +1,74 @@
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');
});
it('adds closing braces outside a selector', () => {
const change = Plain.deserialize('sumrate(metric{namespace="dev", cluster="c1"}[2m])').change();
let event;
change.move(3);
event = new window.KeyboardEvent('keydown', { key: '(' });
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('sum(rate(metric{namespace="dev", cluster="c1"}[2m]))');
});
it('removes closing brace when opening brace is removed', () => {
const change = Plain.deserialize('time()').change();
let event;
change.move(5);
event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('time');
});
it('keeps closing brace when opening brace is removed and inner values exist', () => {
const change = Plain.deserialize('time(value)').change();
let event;
change.move(5);
event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
const handled = handler(event, change);
expect(handled).toBeFalsy();
});
});

View File

@@ -0,0 +1,69 @@
const BRACES = {
'[': ']',
'{': '}',
'(': ')',
};
const NON_SELECTOR_SPACE_REGEXP = / (?![^}]+})/;
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 delimiterIndex = text.slice(offset).search(NON_SELECTOR_SPACE_REGEXP);
const length = delimiterIndex > -1 ? delimiterIndex + offset : 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;
}
case 'Backspace': {
const text = value.anchorText.text;
const offset = value.anchorOffset;
const previousChar = text[offset - 1];
const nextChar = text[offset];
if (BRACES[previousChar] && BRACES[previousChar] === nextChar) {
event.preventDefault();
// Remove closing brace if directly following
change
.deleteBackward()
.deleteForward()
.focus();
return true;
}
}
default: {
break;
}
}
return undefined;
},
};
}

View File

@@ -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 ');
});
});

View File

@@ -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;
},
};
}

View File

@@ -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();
}
},
};
}

View File

@@ -0,0 +1,424 @@
/* tslint:disable max-line-length */
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
const AGGREGATION_OPERATORS = [
{
label: 'sum',
insertText: 'sum()',
documentation: 'Calculate sum over dimensions',
},
{
label: 'min',
insertText: 'min()',
documentation: 'Select minimum over dimensions',
},
{
label: 'max',
insertText: 'max()',
documentation: 'Select maximum over dimensions',
},
{
label: 'avg',
insertText: 'avg()',
documentation: 'Calculate the average over dimensions',
},
{
label: 'stddev',
insertText: 'stddev()',
documentation: 'Calculate population standard deviation over dimensions',
},
{
label: 'stdvar',
insertText: 'stdvar()',
documentation: 'Calculate population standard variance over dimensions',
},
{
label: 'count',
insertText: 'count()',
documentation: 'Count number of elements in the vector',
},
{
label: 'count_values',
insertText: 'count_values()',
documentation: 'Count number of elements with the same value',
},
{
label: 'bottomk',
insertText: 'bottomk()',
documentation: 'Smallest k elements by sample value',
},
{
label: 'topk',
insertText: 'topk()',
documentation: 'Largest k elements by sample value',
},
{
label: 'quantile',
insertText: 'quantile()',
documentation: 'Calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions',
},
];
export const FUNCTIONS = [
...AGGREGATION_OPERATORS,
{
insertText: 'abs()',
label: 'abs',
detail: 'abs(v instant-vector)',
documentation: 'Returns the input vector with all sample values converted to their absolute value.',
},
{
insertText: 'absent()',
label: 'absent',
detail: 'absent(v instant-vector)',
documentation:
'Returns an empty vector if the vector passed to it has any elements and a 1-element vector with the value 1 if the vector passed to it has no elements. This is useful for alerting on when no time series exist for a given metric name and label combination.',
},
{
insertText: 'ceil()',
label: 'ceil',
detail: 'ceil(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` up to the nearest integer.',
},
{
insertText: 'changes()',
label: 'changes',
detail: 'changes(v range-vector)',
documentation:
'For each input time series, `changes(v range-vector)` returns the number of times its value has changed within the provided time range as an instant vector.',
},
{
insertText: 'clamp_max()',
label: 'clamp_max',
detail: 'clamp_max(v instant-vector, max scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have an upper limit of `max`.',
},
{
insertText: 'clamp_min()',
label: 'clamp_min',
detail: 'clamp_min(v instant-vector, min scalar)',
documentation: 'Clamps the sample values of all elements in `v` to have a lower limit of `min`.',
},
{
insertText: 'count_scalar()',
label: 'count_scalar',
detail: 'count_scalar(v instant-vector)',
documentation:
'Returns the number of elements in a time series vector as a scalar. This is in contrast to the `count()` aggregation operator, which always returns a vector (an empty one if the input vector is empty) and allows grouping by labels via a `by` clause.',
},
{
insertText: 'day_of_month()',
label: 'day_of_month',
detail: 'day_of_month(v=vector(time()) instant-vector)',
documentation: 'Returns the day of the month for each of the given times in UTC. Returned values are from 1 to 31.',
},
{
insertText: 'day_of_week()',
label: 'day_of_week',
detail: 'day_of_week(v=vector(time()) instant-vector)',
documentation:
'Returns the day of the week for each of the given times in UTC. Returned values are from 0 to 6, where 0 means Sunday etc.',
},
{
insertText: 'days_in_month()',
label: 'days_in_month',
detail: 'days_in_month(v=vector(time()) instant-vector)',
documentation:
'Returns number of days in the month for each of the given times in UTC. Returned values are from 28 to 31.',
},
{
insertText: 'delta()',
label: 'delta',
detail: 'delta(v range-vector)',
documentation:
'Calculates the difference between the first and last value of each time series element in a range vector `v`, returning an instant vector with the given deltas and equivalent labels. The delta is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if the sample values are all integers.',
},
{
insertText: 'deriv()',
label: 'deriv',
detail: 'deriv(v range-vector)',
documentation:
'Calculates the per-second derivative of the time series in a range vector `v`, using simple linear regression.',
},
{
insertText: 'drop_common_labels()',
label: 'drop_common_labels',
detail: 'drop_common_labels(instant-vector)',
documentation: 'Drops all labels that have the same name and value across all series in the input vector.',
},
{
insertText: 'exp()',
label: 'exp',
detail: 'exp(v instant-vector)',
documentation:
'Calculates the exponential function for all elements in `v`.\nSpecial cases are:\n* `Exp(+Inf) = +Inf` \n* `Exp(NaN) = NaN`',
},
{
insertText: 'floor()',
label: 'floor',
detail: 'floor(v instant-vector)',
documentation: 'Rounds the sample values of all elements in `v` down to the nearest integer.',
},
{
insertText: 'histogram_quantile()',
label: 'histogram_quantile',
detail: 'histogram_quantile(φ float, b instant-vector)',
documentation:
'Calculates the φ-quantile (0 ≤ φ ≤ 1) from the buckets `b` of a histogram. The samples in `b` are the counts of observations in each bucket. Each sample must have a label `le` where the label value denotes the inclusive upper bound of the bucket. (Samples without such a label are silently ignored.) The histogram metric type automatically provides time series with the `_bucket` suffix and the appropriate labels.',
},
{
insertText: 'holt_winters()',
label: 'holt_winters',
detail: 'holt_winters(v range-vector, sf scalar, tf scalar)',
documentation:
'Produces a smoothed value for time series based on the range in `v`. The lower the smoothing factor `sf`, the more importance is given to old data. The higher the trend factor `tf`, the more trends in the data is considered. Both `sf` and `tf` must be between 0 and 1.',
},
{
insertText: 'hour()',
label: 'hour',
detail: 'hour(v=vector(time()) instant-vector)',
documentation: 'Returns the hour of the day for each of the given times in UTC. Returned values are from 0 to 23.',
},
{
insertText: 'idelta()',
label: 'idelta',
detail: 'idelta(v range-vector)',
documentation:
'Calculates the difference between the last two samples in the range vector `v`, returning an instant vector with the given deltas and equivalent labels.',
},
{
insertText: 'increase()',
label: 'increase',
detail: 'increase(v range-vector)',
documentation:
'Calculates the increase in the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. The increase is extrapolated to cover the full time range as specified in the range vector selector, so that it is possible to get a non-integer result even if a counter increases only by integer increments.',
},
{
insertText: 'irate()',
label: 'irate',
detail: 'irate(v range-vector)',
documentation:
'Calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for.',
},
{
insertText: 'label_replace()',
label: 'label_replace',
detail: 'label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)',
documentation:
"For each timeseries in `v`, `label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)` matches the regular expression `regex` against the label `src_label`. If it matches, then the timeseries is returned with the label `dst_label` replaced by the expansion of `replacement`. `$1` is replaced with the first matching subgroup, `$2` with the second etc. If the regular expression doesn't match then the timeseries is returned unchanged.",
},
{
insertText: 'ln()',
label: 'ln',
detail: 'ln(v instant-vector)',
documentation:
'calculates the natural logarithm for all elements in `v`.\nSpecial cases are:\n * `ln(+Inf) = +Inf`\n * `ln(0) = -Inf`\n * `ln(x < 0) = NaN`\n * `ln(NaN) = NaN`',
},
{
insertText: 'log2()',
label: 'log2',
detail: 'log2(v instant-vector)',
documentation:
'Calculates the binary logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'log10()',
label: 'log10',
detail: 'log10(v instant-vector)',
documentation:
'Calculates the decimal logarithm for all elements in `v`. The special cases are equivalent to those in `ln`.',
},
{
insertText: 'minute()',
label: 'minute',
detail: 'minute(v=vector(time()) instant-vector)',
documentation:
'Returns the minute of the hour for each of the given times in UTC. Returned values are from 0 to 59.',
},
{
insertText: 'month()',
label: 'month',
detail: 'month(v=vector(time()) instant-vector)',
documentation:
'Returns the month of the year for each of the given times in UTC. Returned values are from 1 to 12, where 1 means January etc.',
},
{
insertText: 'predict_linear()',
label: 'predict_linear',
detail: 'predict_linear(v range-vector, t scalar)',
documentation:
'Predicts the value of time series `t` seconds from now, based on the range vector `v`, using simple linear regression.',
},
{
insertText: 'rate()',
label: 'rate',
detail: 'rate(v range-vector)',
documentation:
"Calculates the per-second average rate of increase of the time series in the range vector. Breaks in monotonicity (such as counter resets due to target restarts) are automatically adjusted for. Also, the calculation extrapolates to the ends of the time range, allowing for missed scrapes or imperfect alignment of scrape cycles with the range's time period.",
},
{
insertText: 'resets()',
label: 'resets',
detail: 'resets(v range-vector)',
documentation:
'For each input time series, `resets(v range-vector)` returns the number of counter resets within the provided time range as an instant vector. Any decrease in the value between two consecutive samples is interpreted as a counter reset.',
},
{
insertText: 'round()',
label: 'round',
detail: 'round(v instant-vector, to_nearest=1 scalar)',
documentation:
'Rounds the sample values of all elements in `v` to the nearest integer. Ties are resolved by rounding up. The optional `to_nearest` argument allows specifying the nearest multiple to which the sample values should be rounded. This multiple may also be a fraction.',
},
{
insertText: 'scalar()',
label: 'scalar',
detail: 'scalar(v instant-vector)',
documentation:
'Given a single-element input vector, `scalar(v instant-vector)` returns the sample value of that single element as a scalar. If the input vector does not have exactly one element, `scalar` will return `NaN`.',
},
{
insertText: 'sort()',
label: 'sort',
detail: 'sort(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in ascending order.',
},
{
insertText: 'sort_desc()',
label: 'sort_desc',
detail: 'sort_desc(v instant-vector)',
documentation: 'Returns vector elements sorted by their sample values, in descending order.',
},
{
insertText: 'sqrt()',
label: 'sqrt',
detail: 'sqrt(v instant-vector)',
documentation: 'Calculates the square root of all elements in `v`.',
},
{
insertText: 'time()',
label: 'time',
detail: 'time()',
documentation:
'Returns the number of seconds since January 1, 1970 UTC. Note that this does not actually return the current time, but the time at which the expression is to be evaluated.',
},
{
insertText: 'vector()',
label: 'vector',
detail: 'vector(s scalar)',
documentation: 'Returns the scalar `s` as a vector with no labels.',
},
{
insertText: 'year()',
label: 'year',
detail: 'year(v=vector(time()) instant-vector)',
documentation: 'Returns the year for each of the given times in UTC.',
},
{
insertText: 'avg_over_time()',
label: 'avg_over_time',
detail: 'avg_over_time(range-vector)',
documentation: 'The average value of all points in the specified interval.',
},
{
insertText: 'min_over_time()',
label: 'min_over_time',
detail: 'min_over_time(range-vector)',
documentation: 'The minimum value of all points in the specified interval.',
},
{
insertText: 'max_over_time()',
label: 'max_over_time',
detail: 'max_over_time(range-vector)',
documentation: 'The maximum value of all points in the specified interval.',
},
{
insertText: 'sum_over_time()',
label: 'sum_over_time',
detail: 'sum_over_time(range-vector)',
documentation: 'The sum of all values in the specified interval.',
},
{
insertText: 'count_over_time()',
label: 'count_over_time',
detail: 'count_over_time(range-vector)',
documentation: 'The count of all values in the specified interval.',
},
{
insertText: 'quantile_over_time()',
label: 'quantile_over_time',
detail: 'quantile_over_time(scalar, range-vector)',
documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
},
{
insertText: 'stddev_over_time()',
label: 'stddev_over_time',
detail: 'stddev_over_time(range-vector)',
documentation: 'The population standard deviation of the values in the specified interval.',
},
{
insertText: 'stdvar_over_time()',
label: 'stdvar_over_time',
detail: 'stdvar_over_time(range-vector)',
documentation: 'The population standard variance of the values in the specified interval.',
},
];
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.map(f => f.label).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;

View File

@@ -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;
},
};
}

View File

@@ -0,0 +1,14 @@
// Based on underscore.js debounce()
export default function debounce(func, wait) {
let timeout;
return function(this: any) {
const context = this;
const args = arguments;
const later = () => {
timeout = null;
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View File

@@ -0,0 +1,41 @@
// Node.closest() polyfill
if ('Element' in window && !Element.prototype.closest) {
Element.prototype.closest = function(this: any, 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) {}
el = el.parentElement;
} while (i < 0 && el);
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);
}

View File

@@ -0,0 +1,64 @@
import { parseSelector } from './prometheus';
describe('parseSelector()', () => {
let parsed;
it('returns a clean selector from an empty selector', () => {
parsed = parseSelector('{}', 1);
expect(parsed.selector).toBe('{}');
expect(parsed.labelKeys).toEqual([]);
});
it('throws if selector is broken', () => {
expect(() => parseSelector('{foo')).toThrow();
});
it('returns the selector sorted by label key', () => {
parsed = parseSelector('{foo="bar"}');
expect(parsed.selector).toBe('{foo="bar"}');
expect(parsed.labelKeys).toEqual(['foo']);
parsed = parseSelector('{foo="bar",baz="xx"}');
expect(parsed.selector).toBe('{baz="xx",foo="bar"}');
});
it('returns a clean selector from an incomplete one', () => {
parsed = parseSelector('{foo}');
expect(parsed.selector).toBe('{}');
parsed = parseSelector('{foo="bar",baz}');
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar",baz="}');
expect(parsed.selector).toBe('{foo="bar"}');
});
it('throws if not inside a selector', () => {
expect(() => parseSelector('foo{}', 0)).toThrow();
expect(() => parseSelector('foo{} + bar{}', 5)).toThrow();
});
it('returns the selector nearest to the cursor offset', () => {
expect(() => parseSelector('{foo="bar"} + {foo="bar"}', 0)).toThrow();
parsed = parseSelector('{foo="bar"} + {foo="bar"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{foo="bar"} + {baz="xx"}', 1);
expect(parsed.selector).toBe('{foo="bar"}');
parsed = parseSelector('{baz="xx"} + {foo="bar"}', 16);
expect(parsed.selector).toBe('{foo="bar"}');
});
it('returns a selector with metric if metric is given', () => {
parsed = parseSelector('bar{foo}', 4);
expect(parsed.selector).toBe('{__name__="bar"}');
parsed = parseSelector('baz{foo="bar"}', 12);
expect(parsed.selector).toBe('{__name__="baz",foo="bar"}');
parsed = parseSelector('bar:metric:1m{}', 14);
expect(parsed.selector).toBe('{__name__="bar:metric:1m"}');
});
});

View File

@@ -0,0 +1,91 @@
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
export function processLabels(labels, withName = false) {
const values = {};
labels.forEach(l => {
const { __name__, ...rest } = l;
if (withName) {
values['__name__'] = values['__name__'] || [];
if (values['__name__'].indexOf(__name__) === -1) {
values['__name__'].push(__name__);
}
}
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();
// const cleanSelectorRegexp = /\{(\w+="[^"\n]*?")(,\w+="[^"\n]*?")*\}/;
const selectorRegexp = /\{[^}]*?\}/;
const labelRegexp = /\b\w+="[^"\n]*?"/g;
export function parseSelector(query: string, cursorOffset = 1): { labelKeys: any[]; selector: string } {
if (!query.match(selectorRegexp)) {
// Special matcher for metrics
if (query.match(/^[A-Za-z:][\w:]*$/)) {
return {
selector: `{__name__="${query}"}`,
labelKeys: ['__name__'],
};
}
throw new Error('Query must contain a selector: ' + query);
}
// Check if inside a selector
const prefix = query.slice(0, cursorOffset);
const prefixOpen = prefix.lastIndexOf('{');
const prefixClose = prefix.lastIndexOf('}');
if (prefixOpen === -1) {
throw new Error('Not inside selector, missing open brace: ' + prefix);
}
if (prefixClose > -1 && prefixClose > prefixOpen) {
throw new Error('Not inside selector, previous selector already closed: ' + prefix);
}
const suffix = query.slice(cursorOffset);
const suffixCloseIndex = suffix.indexOf('}');
const suffixClose = suffixCloseIndex + cursorOffset;
const suffixOpenIndex = suffix.indexOf('{');
const suffixOpen = suffixOpenIndex + cursorOffset;
if (suffixClose === -1) {
throw new Error('Not inside selector, missing closing brace in suffix: ' + suffix);
}
if (suffixOpenIndex > -1 && suffixOpen < suffixClose) {
throw new Error('Not inside selector, next selector opens before this one closed: ' + suffix);
}
// Extract clean labels to form clean selector, incomplete labels are dropped
const selector = query.slice(prefixOpen, suffixClose);
const labels = {};
selector.replace(labelRegexp, match => {
const delimiterIndex = match.indexOf('=');
const key = match.slice(0, delimiterIndex);
const value = match.slice(delimiterIndex + 1, match.length);
labels[key] = value;
return '';
});
// Add metric if there is one before the selector
const metricPrefix = query.slice(0, prefixOpen);
const metricMatch = metricPrefix.match(/[A-Za-z:][\w:]*$/);
if (metricMatch) {
labels['__name__'] = `"${metricMatch[0]}"`;
}
// Build sorted selector
const labelKeys = Object.keys(labels).sort();
const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(',');
const selectorString = ['{', cleanSelector, '}'].join('');
return { labelKeys, selector: selectorString };
}

View File

@@ -0,0 +1,14 @@
export function generateQueryKey(index = 0) {
return `Q-${Date.now()}-${Math.random()}-${index}`;
}
export function ensureQueries(queries?) {
if (queries && typeof queries === 'object' && queries.length > 0 && typeof queries[0] === 'string') {
return queries.map((query, i) => ({ key: generateQueryKey(i), query }));
}
return [{ key: generateQueryKey(), query: '' }];
}
export function hasQuery(queries) {
return queries.some(q => q.query);
}