mirror of https://github.com/grafana/grafana
parent
1dd4f03100
commit
f1220fd2a4
@ -0,0 +1,46 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
const INTERVAL = 150; |
||||
|
||||
export default class ElapsedTime extends PureComponent<any, any> { |
||||
offset: number; |
||||
timer: NodeJS.Timer; |
||||
|
||||
state = { |
||||
elapsed: 0, |
||||
}; |
||||
|
||||
start() { |
||||
this.offset = Date.now(); |
||||
this.timer = setInterval(this.tick, INTERVAL); |
||||
} |
||||
|
||||
tick = () => { |
||||
const jetzt = Date.now(); |
||||
const elapsed = jetzt - this.offset; |
||||
this.setState({ elapsed }); |
||||
}; |
||||
|
||||
componentWillReceiveProps(nextProps) { |
||||
if (nextProps.time) { |
||||
clearInterval(this.timer); |
||||
} else if (this.props.time) { |
||||
this.start(); |
||||
} |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.start(); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
clearInterval(this.timer); |
||||
} |
||||
|
||||
render() { |
||||
const { elapsed } = this.state; |
||||
const { className, time } = this.props; |
||||
const value = (time || elapsed) / 1000; |
||||
return <span className={className}>{value.toFixed(1)}s</span>; |
||||
} |
||||
} |
@ -0,0 +1,246 @@ |
||||
import React from 'react'; |
||||
import { hot } from 'react-hot-loader'; |
||||
import colors from 'app/core/utils/colors'; |
||||
import TimeSeries from 'app/core/time_series2'; |
||||
|
||||
import ElapsedTime from './ElapsedTime'; |
||||
import Legend from './Legend'; |
||||
import QueryField from './QueryField'; |
||||
import Graph from './Graph'; |
||||
import Table from './Table'; |
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; |
||||
|
||||
function buildQueryOptions({ format, interval, instant, now, query }) { |
||||
const to = now; |
||||
const from = to - 1000 * 60 * 60 * 3; |
||||
return { |
||||
interval, |
||||
range: { |
||||
from, |
||||
to, |
||||
}, |
||||
targets: [ |
||||
{ |
||||
expr: query, |
||||
format, |
||||
instant, |
||||
}, |
||||
], |
||||
}; |
||||
} |
||||
|
||||
function makeTimeSeriesList(dataList, options) { |
||||
return dataList.map((seriesData, index) => { |
||||
const datapoints = seriesData.datapoints || []; |
||||
const alias = seriesData.target; |
||||
|
||||
const colorIndex = index % colors.length; |
||||
const color = colors[colorIndex]; |
||||
|
||||
const series = new TimeSeries({ |
||||
datapoints: datapoints, |
||||
alias: alias, |
||||
color: color, |
||||
unit: seriesData.unit, |
||||
}); |
||||
|
||||
if (datapoints && datapoints.length > 0) { |
||||
const last = datapoints[datapoints.length - 1][1]; |
||||
const from = options.range.from; |
||||
if (last - from < -10000) { |
||||
series.isOutsideRange = true; |
||||
} |
||||
} |
||||
|
||||
return series; |
||||
}); |
||||
} |
||||
|
||||
interface IExploreState { |
||||
datasource: any; |
||||
datasourceError: any; |
||||
datasourceLoading: any; |
||||
graphResult: any; |
||||
latency: number; |
||||
loading: any; |
||||
requestOptions: any; |
||||
showingGraph: boolean; |
||||
showingTable: boolean; |
||||
tableResult: any; |
||||
} |
||||
|
||||
// @observer
|
||||
export class Explore extends React.Component<any, IExploreState> { |
||||
datasourceSrv: DatasourceSrv; |
||||
query: string; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
this.state = { |
||||
datasource: null, |
||||
datasourceError: null, |
||||
datasourceLoading: true, |
||||
graphResult: null, |
||||
latency: 0, |
||||
loading: false, |
||||
requestOptions: null, |
||||
showingGraph: true, |
||||
showingTable: true, |
||||
tableResult: null, |
||||
}; |
||||
} |
||||
|
||||
async componentDidMount() { |
||||
const datasource = await this.props.datasourceSrv.get(); |
||||
const testResult = await datasource.testDatasource(); |
||||
if (testResult.status === 'success') { |
||||
this.setState({ datasource, datasourceError: null, datasourceLoading: false }); |
||||
} else { |
||||
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false }); |
||||
} |
||||
} |
||||
|
||||
handleClickGraphButton = () => { |
||||
this.setState(state => ({ showingGraph: !state.showingGraph })); |
||||
}; |
||||
|
||||
handleClickTableButton = () => { |
||||
this.setState(state => ({ showingTable: !state.showingTable })); |
||||
}; |
||||
|
||||
handleRequestError({ error }) { |
||||
console.error(error); |
||||
} |
||||
|
||||
handleQueryChange = query => { |
||||
this.query = query; |
||||
}; |
||||
|
||||
handleSubmit = () => { |
||||
const { showingGraph, showingTable } = this.state; |
||||
if (showingTable) { |
||||
this.runTableQuery(); |
||||
} |
||||
if (showingGraph) { |
||||
this.runGraphQuery(); |
||||
} |
||||
}; |
||||
|
||||
async runGraphQuery() { |
||||
const { query } = this; |
||||
const { datasource } = this.state; |
||||
if (!query) { |
||||
return; |
||||
} |
||||
this.setState({ latency: 0, loading: true, graphResult: null }); |
||||
const now = Date.now(); |
||||
const options = buildQueryOptions({ |
||||
format: 'time_series', |
||||
interval: datasource.interval, |
||||
instant: false, |
||||
now, |
||||
query, |
||||
}); |
||||
try { |
||||
const res = await datasource.query(options); |
||||
const result = makeTimeSeriesList(res.data, options); |
||||
const latency = Date.now() - now; |
||||
this.setState({ latency, loading: false, graphResult: result, requestOptions: options }); |
||||
} catch (error) { |
||||
console.error(error); |
||||
this.setState({ loading: false, graphResult: error }); |
||||
} |
||||
} |
||||
|
||||
async runTableQuery() { |
||||
const { query } = this; |
||||
const { datasource } = this.state; |
||||
if (!query) { |
||||
return; |
||||
} |
||||
this.setState({ latency: 0, loading: true, tableResult: null }); |
||||
const now = Date.now(); |
||||
const options = buildQueryOptions({ format: 'table', interval: datasource.interval, instant: true, now, query }); |
||||
try { |
||||
const res = await datasource.query(options); |
||||
const tableModel = res.data[0]; |
||||
const latency = Date.now() - now; |
||||
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options }); |
||||
} catch (error) { |
||||
console.error(error); |
||||
this.setState({ loading: false, tableResult: null }); |
||||
} |
||||
} |
||||
|
||||
request = url => { |
||||
const { datasource } = this.state; |
||||
return datasource.metadataRequest(url); |
||||
}; |
||||
|
||||
render() { |
||||
const { |
||||
datasource, |
||||
datasourceError, |
||||
datasourceLoading, |
||||
latency, |
||||
loading, |
||||
requestOptions, |
||||
graphResult, |
||||
showingGraph, |
||||
showingTable, |
||||
tableResult, |
||||
} = this.state; |
||||
const showingBoth = showingGraph && showingTable; |
||||
const graphHeight = showingBoth ? '200px' : null; |
||||
const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1'; |
||||
const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1'; |
||||
return ( |
||||
<div className="explore"> |
||||
<div className="page-body page-full"> |
||||
<h2 className="page-sub-heading">Explore</h2> |
||||
{datasourceLoading ? <div>Loading datasource...</div> : null} |
||||
|
||||
{datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null} |
||||
|
||||
{datasource ? ( |
||||
<div className="m-r-3"> |
||||
<div className="nav m-b-1"> |
||||
<div className="pull-right" style={{ paddingRight: '6rem' }}> |
||||
<button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}> |
||||
<i className="fa fa-return" /> Run Query |
||||
</button> |
||||
</div> |
||||
<div> |
||||
<button className={graphButtonClassName} onClick={this.handleClickGraphButton}> |
||||
Graph |
||||
</button> |
||||
<button className={tableButtonClassName} onClick={this.handleClickTableButton}> |
||||
Table |
||||
</button> |
||||
</div> |
||||
</div> |
||||
<div className="query-field-wrapper"> |
||||
<QueryField |
||||
request={this.request} |
||||
onPressEnter={this.handleSubmit} |
||||
onQueryChange={this.handleQueryChange} |
||||
onRequestError={this.handleRequestError} |
||||
/> |
||||
</div> |
||||
{loading || latency ? <ElapsedTime time={latency} className="m-l-1" /> : null} |
||||
<main className="m-t-2"> |
||||
{showingGraph ? ( |
||||
<Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} /> |
||||
) : null} |
||||
{showingGraph ? <Legend data={graphResult} /> : null} |
||||
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null} |
||||
</main> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default hot(module)(Explore); |
@ -0,0 +1,123 @@ |
||||
import $ from 'jquery'; |
||||
import React, { Component } from 'react'; |
||||
|
||||
import TimeSeries from 'app/core/time_series2'; |
||||
|
||||
import 'vendor/flot/jquery.flot'; |
||||
import 'vendor/flot/jquery.flot.time'; |
||||
|
||||
// Copied from graph.ts
|
||||
function time_format(ticks, min, max) { |
||||
if (min && max && ticks) { |
||||
var range = max - min; |
||||
var secPerTick = range / ticks / 1000; |
||||
var oneDay = 86400000; |
||||
var oneYear = 31536000000; |
||||
|
||||
if (secPerTick <= 45) { |
||||
return '%H:%M:%S'; |
||||
} |
||||
if (secPerTick <= 7200 || range <= oneDay) { |
||||
return '%H:%M'; |
||||
} |
||||
if (secPerTick <= 80000) { |
||||
return '%m/%d %H:%M'; |
||||
} |
||||
if (secPerTick <= 2419200 || range <= oneYear) { |
||||
return '%m/%d'; |
||||
} |
||||
return '%Y-%m'; |
||||
} |
||||
|
||||
return '%H:%M'; |
||||
} |
||||
|
||||
const FLOT_OPTIONS = { |
||||
legend: { |
||||
show: false, |
||||
}, |
||||
series: { |
||||
lines: { |
||||
linewidth: 1, |
||||
zero: false, |
||||
}, |
||||
shadowSize: 0, |
||||
}, |
||||
grid: { |
||||
minBorderMargin: 0, |
||||
markings: [], |
||||
backgroundColor: null, |
||||
borderWidth: 0, |
||||
// hoverable: true,
|
||||
clickable: true, |
||||
color: '#a1a1a1', |
||||
margin: { left: 0, right: 0 }, |
||||
labelMarginX: 0, |
||||
}, |
||||
// selection: {
|
||||
// mode: 'x',
|
||||
// color: '#666',
|
||||
// },
|
||||
// crosshair: {
|
||||
// mode: 'x',
|
||||
// },
|
||||
}; |
||||
|
||||
class Graph extends Component<any, any> { |
||||
componentDidMount() { |
||||
this.draw(); |
||||
} |
||||
|
||||
componentDidUpdate(prevProps) { |
||||
if ( |
||||
prevProps.data !== this.props.data || |
||||
prevProps.options !== this.props.options || |
||||
prevProps.height !== this.props.height |
||||
) { |
||||
this.draw(); |
||||
} |
||||
} |
||||
|
||||
draw() { |
||||
const { data, options: userOptions } = this.props; |
||||
if (!data) { |
||||
return; |
||||
} |
||||
const series = data.map((ts: TimeSeries) => ({ |
||||
label: ts.label, |
||||
data: ts.getFlotPairs('null'), |
||||
})); |
||||
|
||||
const $el = $(`#${this.props.id}`); |
||||
const ticks = $el.width() / 100; |
||||
const min = userOptions.range.from.valueOf(); |
||||
const max = userOptions.range.to.valueOf(); |
||||
const dynamicOptions = { |
||||
xaxis: { |
||||
mode: 'time', |
||||
min: min, |
||||
max: max, |
||||
label: 'Datetime', |
||||
ticks: ticks, |
||||
timeformat: time_format(ticks, min, max), |
||||
}, |
||||
}; |
||||
const options = { |
||||
...FLOT_OPTIONS, |
||||
...dynamicOptions, |
||||
...userOptions, |
||||
}; |
||||
$.plot($el, series, options); |
||||
} |
||||
|
||||
render() { |
||||
const style = { |
||||
height: this.props.height || '400px', |
||||
width: this.props.width || '100%', |
||||
}; |
||||
|
||||
return <div id={this.props.id} style={style} />; |
||||
} |
||||
} |
||||
|
||||
export default Graph; |
@ -0,0 +1,22 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
const LegendItem = ({ series }) => ( |
||||
<div className="graph-legend-series"> |
||||
<div className="graph-legend-icon"> |
||||
<i className="fa fa-minus pointer" style={{ color: series.color }} /> |
||||
</div> |
||||
<a className="graph-legend-alias pointer">{series.alias}</a> |
||||
</div> |
||||
); |
||||
|
||||
export default class Legend extends PureComponent<any, any> { |
||||
render() { |
||||
const { className = '', data } = this.props; |
||||
const items = data || []; |
||||
return ( |
||||
<div className={`${className} graph-legend ps`}> |
||||
{items.map(series => <LegendItem key={series.id} series={series} />)} |
||||
</div> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,562 @@ |
||||
import React from 'react'; |
||||
import ReactDOM from 'react-dom'; |
||||
import { Value } from 'slate'; |
||||
import { Editor } from 'slate-react'; |
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from './utils/dom'; |
||||
import BracesPlugin from './slate-plugins/braces'; |
||||
import ClearPlugin from './slate-plugins/clear'; |
||||
import NewlinePlugin from './slate-plugins/newline'; |
||||
import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index'; |
||||
import RunnerPlugin from './slate-plugins/runner'; |
||||
import debounce from './utils/debounce'; |
||||
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus'; |
||||
|
||||
import Typeahead from './Typeahead'; |
||||
|
||||
const EMPTY_METRIC = ''; |
||||
const TYPEAHEAD_DEBOUNCE = 300; |
||||
|
||||
function flattenSuggestions(s) { |
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : []; |
||||
} |
||||
|
||||
const getInitialValue = query => |
||||
Value.fromJSON({ |
||||
document: { |
||||
nodes: [ |
||||
{ |
||||
object: 'block', |
||||
type: 'paragraph', |
||||
nodes: [ |
||||
{ |
||||
object: 'text', |
||||
leaves: [ |
||||
{ |
||||
text: query, |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}, |
||||
}); |
||||
|
||||
class Portal extends React.Component { |
||||
node: any; |
||||
constructor(props) { |
||||
super(props); |
||||
this.node = document.createElement('div'); |
||||
this.node.classList.add(`query-field-portal-${props.index}`); |
||||
document.body.appendChild(this.node); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
document.body.removeChild(this.node); |
||||
} |
||||
|
||||
render() { |
||||
return ReactDOM.createPortal(this.props.children, this.node); |
||||
} |
||||
} |
||||
|
||||
class QueryField extends React.Component<any, any> { |
||||
menuEl: any; |
||||
plugins: any; |
||||
resetTimer: any; |
||||
|
||||
constructor(props, context) { |
||||
super(props, context); |
||||
|
||||
this.plugins = [ |
||||
BracesPlugin(), |
||||
ClearPlugin(), |
||||
RunnerPlugin({ handler: props.onPressEnter }), |
||||
NewlinePlugin(), |
||||
PluginPrism(), |
||||
]; |
||||
|
||||
this.state = { |
||||
labelKeys: {}, |
||||
labelValues: {}, |
||||
metrics: props.metrics || [], |
||||
suggestions: [], |
||||
typeaheadIndex: 0, |
||||
typeaheadPrefix: '', |
||||
value: getInitialValue(props.initialQuery || ''), |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.updateMenu(); |
||||
|
||||
if (this.props.metrics === undefined) { |
||||
this.fetchMetricNames(); |
||||
} |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
clearTimeout(this.resetTimer); |
||||
} |
||||
|
||||
componentDidUpdate() { |
||||
this.updateMenu(); |
||||
} |
||||
|
||||
componentWillReceiveProps(nextProps) { |
||||
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) { |
||||
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived); |
||||
} |
||||
// initialQuery is null in case the user typed
|
||||
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) { |
||||
this.setState({ value: getInitialValue(nextProps.initialQuery) }); |
||||
} |
||||
} |
||||
|
||||
onChange = ({ value }) => { |
||||
const changed = value.document !== this.state.value.document; |
||||
this.setState({ value }, () => { |
||||
if (changed) { |
||||
this.handleChangeQuery(); |
||||
} |
||||
}); |
||||
|
||||
window.requestAnimationFrame(this.handleTypeahead); |
||||
}; |
||||
|
||||
onMetricsReceived = () => { |
||||
if (!this.state.metrics) { |
||||
return; |
||||
} |
||||
configurePrismMetricsTokens(this.state.metrics); |
||||
// Trigger re-render
|
||||
window.requestAnimationFrame(() => { |
||||
// Bogus edit to trigger highlighting
|
||||
const change = this.state.value |
||||
.change() |
||||
.insertText(' ') |
||||
.deleteBackward(1); |
||||
this.onChange(change); |
||||
}); |
||||
}; |
||||
|
||||
request = url => { |
||||
if (this.props.request) { |
||||
return this.props.request(url); |
||||
} |
||||
return fetch(url); |
||||
}; |
||||
|
||||
handleChangeQuery = () => { |
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props; |
||||
if (onQueryChange) { |
||||
onQueryChange(Plain.serialize(this.state.value)); |
||||
} |
||||
}; |
||||
|
||||
handleTypeahead = debounce(() => { |
||||
const selection = window.getSelection(); |
||||
if (selection.anchorNode) { |
||||
const wrapperNode = selection.anchorNode.parentElement; |
||||
const editorNode = wrapperNode.closest('.query-field'); |
||||
if (!editorNode || this.state.value.isBlurred) { |
||||
// Not inside this editor
|
||||
return; |
||||
} |
||||
|
||||
const range = selection.getRangeAt(0); |
||||
const text = selection.anchorNode.textContent; |
||||
const offset = range.startOffset; |
||||
const prefix = cleanText(text.substr(0, offset)); |
||||
|
||||
// Determine candidates by context
|
||||
const suggestionGroups = []; |
||||
const wrapperClasses = wrapperNode.classList; |
||||
let typeaheadContext = null; |
||||
|
||||
// Take first metric as lucky guess
|
||||
const metricNode = editorNode.querySelector('.metric'); |
||||
|
||||
if (wrapperClasses.contains('context-range')) { |
||||
// Rate ranges
|
||||
typeaheadContext = 'context-range'; |
||||
suggestionGroups.push({ |
||||
label: 'Range vector', |
||||
items: [...RATE_RANGES], |
||||
}); |
||||
} else if (wrapperClasses.contains('context-labels') && metricNode) { |
||||
const metric = metricNode.textContent; |
||||
const labelKeys = this.state.labelKeys[metric]; |
||||
if (labelKeys) { |
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) { |
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name'); |
||||
if (labelKeyNode) { |
||||
const labelKey = labelKeyNode.textContent; |
||||
const labelValues = this.state.labelValues[metric][labelKey]; |
||||
typeaheadContext = 'context-label-values'; |
||||
suggestionGroups.push({ |
||||
label: 'Label values', |
||||
items: labelValues, |
||||
}); |
||||
} |
||||
} else { |
||||
// Label keys
|
||||
typeaheadContext = 'context-labels'; |
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys }); |
||||
} |
||||
} else { |
||||
this.fetchMetricLabels(metric); |
||||
} |
||||
} else if (wrapperClasses.contains('context-labels') && !metricNode) { |
||||
// Empty name queries
|
||||
const defaultKeys = ['job', 'instance']; |
||||
// Munge all keys that we have seen together
|
||||
const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => { |
||||
return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1)); |
||||
}, defaultKeys); |
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) { |
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name'); |
||||
if (labelKeyNode) { |
||||
const labelKey = labelKeyNode.textContent; |
||||
if (this.state.labelValues[EMPTY_METRIC]) { |
||||
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey]; |
||||
typeaheadContext = 'context-label-values'; |
||||
suggestionGroups.push({ |
||||
label: 'Label values', |
||||
items: labelValues, |
||||
}); |
||||
} else { |
||||
// Can only query label values for now (API to query keys is under development)
|
||||
this.fetchLabelValues(labelKey); |
||||
} |
||||
} |
||||
} else { |
||||
// Label keys
|
||||
typeaheadContext = 'context-labels'; |
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys }); |
||||
} |
||||
} else if (metricNode && wrapperClasses.contains('context-aggregation')) { |
||||
typeaheadContext = 'context-aggregation'; |
||||
const metric = metricNode.textContent; |
||||
const labelKeys = this.state.labelKeys[metric]; |
||||
if (labelKeys) { |
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys }); |
||||
} else { |
||||
this.fetchMetricLabels(metric); |
||||
} |
||||
} else if ( |
||||
(this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) || |
||||
wrapperClasses.contains('context-function') |
||||
) { |
||||
// Need prefix for metrics
|
||||
typeaheadContext = 'context-metrics'; |
||||
suggestionGroups.push({ |
||||
label: 'Metrics', |
||||
items: this.state.metrics, |
||||
}); |
||||
} |
||||
|
||||
let results = 0; |
||||
const filteredSuggestions = suggestionGroups.map(group => { |
||||
if (group.items) { |
||||
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1); |
||||
results += group.items.length; |
||||
} |
||||
return group; |
||||
}); |
||||
|
||||
console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext); |
||||
|
||||
this.setState({ |
||||
typeaheadPrefix: prefix, |
||||
typeaheadContext, |
||||
typeaheadText: text, |
||||
suggestions: results > 0 ? filteredSuggestions : [], |
||||
}); |
||||
} |
||||
}, TYPEAHEAD_DEBOUNCE); |
||||
|
||||
applyTypeahead(change, suggestion) { |
||||
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; |
||||
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) { |
||||
case 'context-labels': { |
||||
const nextChar = getNextCharacter(); |
||||
if (!nextChar || nextChar === '}' || nextChar === ',') { |
||||
suggestion += '='; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case 'context-label-values': { |
||||
// Always add quotes and remove existing ones instead
|
||||
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) { |
||||
suggestion = `"${suggestion}`; |
||||
} |
||||
if (getNextCharacter() !== '"') { |
||||
suggestion = `${suggestion}"`; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
default: |
||||
} |
||||
|
||||
this.resetTypeahead(); |
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
let backward = typeaheadPrefix.length; |
||||
const text = cleanText(typeaheadText); |
||||
const suffixLength = text.length - typeaheadPrefix.length; |
||||
const offset = typeaheadText.indexOf(typeaheadPrefix); |
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText); |
||||
const forward = midWord ? suffixLength + offset : 0; |
||||
|
||||
return ( |
||||
change |
||||
// TODO this line breaks if cursor was moved left and length is longer than whole prefix
|
||||
.deleteBackward(backward) |
||||
.deleteForward(forward) |
||||
.insertText(suggestion) |
||||
.focus() |
||||
); |
||||
} |
||||
|
||||
onKeyDown = (event, change) => { |
||||
if (this.menuEl) { |
||||
const { typeaheadIndex, suggestions } = this.state; |
||||
|
||||
switch (event.key) { |
||||
case 'Escape': { |
||||
if (this.menuEl) { |
||||
event.preventDefault(); |
||||
this.resetTypeahead(); |
||||
return true; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case 'Tab': { |
||||
// Dont blur input
|
||||
event.preventDefault(); |
||||
if (!suggestions || suggestions.length === 0) { |
||||
return undefined; |
||||
} |
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions); |
||||
const selected = Math.abs(typeaheadIndex); |
||||
const selectedIndex = selected % flattenedSuggestions.length || 0; |
||||
const suggestion = flattenedSuggestions[selectedIndex]; |
||||
|
||||
this.applyTypeahead(change, suggestion); |
||||
return true; |
||||
} |
||||
|
||||
case 'ArrowDown': { |
||||
// Select next suggestion
|
||||
event.preventDefault(); |
||||
this.setState({ typeaheadIndex: typeaheadIndex + 1 }); |
||||
break; |
||||
} |
||||
|
||||
case 'ArrowUp': { |
||||
// Select previous suggestion
|
||||
event.preventDefault(); |
||||
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); |
||||
break; |
||||
} |
||||
|
||||
default: { |
||||
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
||||
break; |
||||
} |
||||
} |
||||
} |
||||
return undefined; |
||||
}; |
||||
|
||||
resetTypeahead = () => { |
||||
this.setState({ |
||||
suggestions: [], |
||||
typeaheadIndex: 0, |
||||
typeaheadPrefix: '', |
||||
typeaheadContext: null, |
||||
}); |
||||
}; |
||||
|
||||
async fetchLabelValues(key) { |
||||
const url = `/api/v1/label/${key}/values`; |
||||
try { |
||||
const res = await this.request(url); |
||||
const body = await (res.data || res.json()); |
||||
const pairs = this.state.labelValues[EMPTY_METRIC]; |
||||
const values = { |
||||
...pairs, |
||||
[key]: body.data, |
||||
}; |
||||
// const labelKeys = {
|
||||
// ...this.state.labelKeys,
|
||||
// [EMPTY_METRIC]: keys,
|
||||
// };
|
||||
const labelValues = { |
||||
...this.state.labelValues, |
||||
[EMPTY_METRIC]: values, |
||||
}; |
||||
this.setState({ labelValues }, this.handleTypeahead); |
||||
} catch (e) { |
||||
if (this.props.onRequestError) { |
||||
this.props.onRequestError(e); |
||||
} else { |
||||
console.error(e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
async fetchMetricLabels(name) { |
||||
const url = `/api/v1/series?match[]=${name}`; |
||||
try { |
||||
const res = await this.request(url); |
||||
const body = await (res.data || res.json()); |
||||
const { keys, values } = processLabels(body.data); |
||||
const labelKeys = { |
||||
...this.state.labelKeys, |
||||
[name]: keys, |
||||
}; |
||||
const labelValues = { |
||||
...this.state.labelValues, |
||||
[name]: values, |
||||
}; |
||||
this.setState({ labelKeys, labelValues }, this.handleTypeahead); |
||||
} catch (e) { |
||||
if (this.props.onRequestError) { |
||||
this.props.onRequestError(e); |
||||
} else { |
||||
console.error(e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
async fetchMetricNames() { |
||||
const url = '/api/v1/label/__name__/values'; |
||||
try { |
||||
const res = await this.request(url); |
||||
const body = await (res.data || res.json()); |
||||
this.setState({ metrics: body.data }, this.onMetricsReceived); |
||||
} catch (error) { |
||||
if (this.props.onRequestError) { |
||||
this.props.onRequestError(error); |
||||
} else { |
||||
console.error(error); |
||||
} |
||||
} |
||||
} |
||||
|
||||
handleBlur = () => { |
||||
const { onBlur } = this.props; |
||||
// If we dont wait here, menu clicks wont work because the menu
|
||||
// will be gone.
|
||||
this.resetTimer = setTimeout(this.resetTypeahead, 100); |
||||
if (onBlur) { |
||||
onBlur(); |
||||
} |
||||
}; |
||||
|
||||
handleFocus = () => { |
||||
const { onFocus } = this.props; |
||||
if (onFocus) { |
||||
onFocus(); |
||||
} |
||||
}; |
||||
|
||||
handleClickMenu = item => { |
||||
// Manually triggering change
|
||||
const change = this.applyTypeahead(this.state.value.change(), item); |
||||
this.onChange(change); |
||||
}; |
||||
|
||||
updateMenu = () => { |
||||
const { suggestions } = this.state; |
||||
const menu = this.menuEl; |
||||
const selection = window.getSelection(); |
||||
const node = selection.anchorNode; |
||||
|
||||
// No menu, nothing to do
|
||||
if (!menu) { |
||||
return; |
||||
} |
||||
|
||||
// No suggestions or blur, remove menu
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0; |
||||
if (!hasSuggesstions) { |
||||
menu.removeAttribute('style'); |
||||
return; |
||||
} |
||||
|
||||
// Align menu overlay to editor node
|
||||
if (node) { |
||||
const rect = node.parentElement.getBoundingClientRect(); |
||||
menu.style.opacity = 1; |
||||
menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`; |
||||
menu.style.left = `${rect.left + window.scrollX - 2}px`; |
||||
} |
||||
}; |
||||
|
||||
menuRef = el => { |
||||
this.menuEl = el; |
||||
}; |
||||
|
||||
renderMenu = () => { |
||||
const { suggestions } = this.state; |
||||
const hasSuggesstions = suggestions && suggestions.length > 0; |
||||
if (!hasSuggesstions) { |
||||
return null; |
||||
} |
||||
|
||||
// Guard selectedIndex to be within the length of the suggestions
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0); |
||||
const flattenedSuggestions = flattenSuggestions(suggestions); |
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0; |
||||
const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []; |
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return ( |
||||
<Portal> |
||||
<Typeahead |
||||
menuRef={this.menuRef} |
||||
selectedItems={selectedKeys} |
||||
onClickItem={this.handleClickMenu} |
||||
groupedItems={suggestions} |
||||
/> |
||||
</Portal> |
||||
); |
||||
}; |
||||
|
||||
render() { |
||||
return ( |
||||
<div className="query-field"> |
||||
{this.renderMenu()} |
||||
<Editor |
||||
autoCorrect={false} |
||||
onBlur={this.handleBlur} |
||||
onKeyDown={this.onKeyDown} |
||||
onChange={this.onChange} |
||||
onFocus={this.handleFocus} |
||||
placeholder={this.props.placeholder} |
||||
plugins={this.plugins} |
||||
spellCheck={false} |
||||
value={this.state.value} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default QueryField; |
@ -0,0 +1,24 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
// import TableModel from 'app/core/table_model';
|
||||
|
||||
const EMPTY_TABLE = { |
||||
columns: [], |
||||
rows: [], |
||||
}; |
||||
|
||||
export default class Table extends PureComponent<any, any> { |
||||
render() { |
||||
const { className = '', data } = this.props; |
||||
const tableModel = data || EMPTY_TABLE; |
||||
return ( |
||||
<table className={`${className} filter-table`}> |
||||
<thead> |
||||
<tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr> |
||||
</thead> |
||||
<tbody> |
||||
{tableModel.rows.map((row, i) => <tr key={i}>{row.map((content, j) => <td key={j}>{content}</td>)}</tr>)} |
||||
</tbody> |
||||
</table> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,66 @@ |
||||
import React from 'react'; |
||||
|
||||
function scrollIntoView(el) { |
||||
if (!el || !el.offsetParent) { |
||||
return; |
||||
} |
||||
const container = el.offsetParent; |
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { |
||||
container.scrollTop = el.offsetTop - container.offsetTop; |
||||
} |
||||
} |
||||
|
||||
class TypeaheadItem extends React.PureComponent<any, any> { |
||||
el: any; |
||||
componentDidUpdate(prevProps) { |
||||
if (this.props.isSelected && !prevProps.isSelected) { |
||||
scrollIntoView(this.el); |
||||
} |
||||
} |
||||
|
||||
getRef = el => { |
||||
this.el = el; |
||||
}; |
||||
|
||||
render() { |
||||
const { isSelected, label, onClickItem } = this.props; |
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; |
||||
const onClick = () => onClickItem(label); |
||||
return ( |
||||
<li ref={this.getRef} className={className} onClick={onClick}> |
||||
{label} |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
class TypeaheadGroup extends React.PureComponent<any, any> { |
||||
render() { |
||||
const { items, label, selected, onClickItem } = this.props; |
||||
return ( |
||||
<li className="typeahead-group"> |
||||
<div className="typeahead-group__title">{label}</div> |
||||
<ul className="typeahead-group__list"> |
||||
{items.map(item => ( |
||||
<TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={item} /> |
||||
))} |
||||
</ul> |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
class Typeahead extends React.PureComponent<any, any> { |
||||
render() { |
||||
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props; |
||||
return ( |
||||
<ul className="typeahead" ref={menuRef}> |
||||
{groupedItems.map(g => ( |
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} /> |
||||
))} |
||||
</ul> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default Typeahead; |
@ -0,0 +1,47 @@ |
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
import BracesPlugin from './braces'; |
||||
|
||||
declare global { |
||||
interface Window { |
||||
KeyboardEvent: any; |
||||
} |
||||
} |
||||
|
||||
describe('braces', () => { |
||||
const handler = BracesPlugin().onKeyDown; |
||||
|
||||
it('adds closing braces around empty value', () => { |
||||
const change = Plain.deserialize('').change(); |
||||
const event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('()'); |
||||
}); |
||||
|
||||
it('adds closing braces around a value', () => { |
||||
const change = Plain.deserialize('foo').change(); |
||||
const event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('(foo)'); |
||||
}); |
||||
|
||||
it('adds closing braces around the following value only', () => { |
||||
const change = Plain.deserialize('foo bar ugh').change(); |
||||
let event; |
||||
event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('(foo) bar ugh'); |
||||
|
||||
// Wrap bar
|
||||
change.move(5); |
||||
event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('(foo) (bar) ugh'); |
||||
|
||||
// Create empty parens after (bar)
|
||||
change.move(4); |
||||
event = new window.KeyboardEvent('keydown', { key: '(' }); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('(foo) (bar)() ugh'); |
||||
}); |
||||
}); |
@ -0,0 +1,51 @@ |
||||
const BRACES = { |
||||
'[': ']', |
||||
'{': '}', |
||||
'(': ')', |
||||
}; |
||||
|
||||
export default function BracesPlugin() { |
||||
return { |
||||
onKeyDown(event, change) { |
||||
const { value } = change; |
||||
if (!value.isCollapsed) { |
||||
return undefined; |
||||
} |
||||
|
||||
switch (event.key) { |
||||
case '{': |
||||
case '[': { |
||||
event.preventDefault(); |
||||
// Insert matching braces
|
||||
change |
||||
.insertText(`${event.key}${BRACES[event.key]}`) |
||||
.move(-1) |
||||
.focus(); |
||||
return true; |
||||
} |
||||
|
||||
case '(': { |
||||
event.preventDefault(); |
||||
const text = value.anchorText.text; |
||||
const offset = value.anchorOffset; |
||||
const space = text.indexOf(' ', offset); |
||||
const length = space > 0 ? space : text.length; |
||||
const forward = length - offset; |
||||
// Insert matching braces
|
||||
change |
||||
.insertText(event.key) |
||||
.move(forward) |
||||
.insertText(BRACES[event.key]) |
||||
.move(-1 - forward) |
||||
.focus(); |
||||
return true; |
||||
} |
||||
|
||||
default: { |
||||
break; |
||||
} |
||||
} |
||||
return undefined; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,38 @@ |
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
import ClearPlugin from './clear'; |
||||
|
||||
describe('clear', () => { |
||||
const handler = ClearPlugin().onKeyDown; |
||||
|
||||
it('does not change the empty value', () => { |
||||
const change = Plain.deserialize('').change(); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears to the end of the line', () => { |
||||
const change = Plain.deserialize('foo').change(); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual(''); |
||||
}); |
||||
|
||||
it('clears from the middle to the end of the line', () => { |
||||
const change = Plain.deserialize('foo bar').change(); |
||||
change.move(4); |
||||
const event = new window.KeyboardEvent('keydown', { |
||||
key: 'k', |
||||
ctrlKey: true, |
||||
}); |
||||
handler(event, change); |
||||
expect(Plain.serialize(change.value)).toEqual('foo '); |
||||
}); |
||||
}); |
@ -0,0 +1,22 @@ |
||||
// Clears the rest of the line after the caret
|
||||
export default function ClearPlugin() { |
||||
return { |
||||
onKeyDown(event, change) { |
||||
const { value } = change; |
||||
if (!value.isCollapsed) { |
||||
return undefined; |
||||
} |
||||
|
||||
if (event.key === 'k' && event.ctrlKey) { |
||||
event.preventDefault(); |
||||
const text = value.anchorText.text; |
||||
const offset = value.anchorOffset; |
||||
const length = text.length; |
||||
const forward = length - offset; |
||||
change.deleteForward(forward); |
||||
return true; |
||||
} |
||||
return undefined; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,35 @@ |
||||
function getIndent(text) { |
||||
let offset = text.length - text.trimLeft().length; |
||||
if (offset) { |
||||
let indent = text[0]; |
||||
while (--offset) { |
||||
indent += text[0]; |
||||
} |
||||
return indent; |
||||
} |
||||
return ''; |
||||
} |
||||
|
||||
export default function NewlinePlugin() { |
||||
return { |
||||
onKeyDown(event, change) { |
||||
const { value } = change; |
||||
if (!value.isCollapsed) { |
||||
return undefined; |
||||
} |
||||
|
||||
if (event.key === 'Enter' && event.shiftKey) { |
||||
event.preventDefault(); |
||||
|
||||
const { startBlock } = value; |
||||
const currentLineText = startBlock.text; |
||||
const indent = getIndent(currentLineText); |
||||
|
||||
return change |
||||
.splitBlock() |
||||
.insertText(indent) |
||||
.focus(); |
||||
} |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,122 @@ |
||||
import React from 'react'; |
||||
import Prism from 'prismjs'; |
||||
|
||||
import Promql from './promql'; |
||||
|
||||
Prism.languages.promql = Promql; |
||||
|
||||
const TOKEN_MARK = 'prism-token'; |
||||
|
||||
export function configurePrismMetricsTokens(metrics) { |
||||
Prism.languages.promql.metric = { |
||||
alias: 'variable', |
||||
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`), |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Code-highlighting plugin based on Prism and |
||||
* https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
|
||||
* |
||||
* (Adapted to handle nested grammar definitions.) |
||||
*/ |
||||
|
||||
export default function PrismPlugin() { |
||||
return { |
||||
/** |
||||
* Render a Slate mark with appropiate CSS class names |
||||
* |
||||
* @param {Object} props |
||||
* @return {Element} |
||||
*/ |
||||
|
||||
renderMark(props) { |
||||
const { children, mark } = props; |
||||
// Only apply spans to marks identified by this plugin
|
||||
if (mark.type !== TOKEN_MARK) { |
||||
return undefined; |
||||
} |
||||
const className = `token ${mark.data.get('types')}`; |
||||
return <span className={className}>{children}</span>; |
||||
}, |
||||
|
||||
/** |
||||
* Decorate code blocks with Prism.js highlighting. |
||||
* |
||||
* @param {Node} node |
||||
* @return {Array} |
||||
*/ |
||||
|
||||
decorateNode(node) { |
||||
if (node.type !== 'paragraph') { |
||||
return []; |
||||
} |
||||
|
||||
const texts = node.getTexts().toArray(); |
||||
const tstring = texts.map(t => t.text).join('\n'); |
||||
const grammar = Prism.languages.promql; |
||||
const tokens = Prism.tokenize(tstring, grammar); |
||||
const decorations = []; |
||||
let startText = texts.shift(); |
||||
let endText = startText; |
||||
let startOffset = 0; |
||||
let endOffset = 0; |
||||
let start = 0; |
||||
|
||||
function processToken(token, acc?) { |
||||
// Accumulate token types down the tree
|
||||
const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`; |
||||
|
||||
// Add mark for token node
|
||||
if (typeof token === 'string' || typeof token.content === 'string') { |
||||
startText = endText; |
||||
startOffset = endOffset; |
||||
|
||||
const content = typeof token === 'string' ? token : token.content; |
||||
const newlines = content.split('\n').length - 1; |
||||
const length = content.length - newlines; |
||||
const end = start + length; |
||||
|
||||
let available = startText.text.length - startOffset; |
||||
let remaining = length; |
||||
|
||||
endOffset = startOffset + remaining; |
||||
|
||||
while (available < remaining) { |
||||
endText = texts.shift(); |
||||
remaining = length - available; |
||||
available = endText.text.length; |
||||
endOffset = remaining; |
||||
} |
||||
|
||||
// Inject marks from up the tree (acc) as well
|
||||
if (typeof token !== 'string' || acc) { |
||||
const range = { |
||||
anchorKey: startText.key, |
||||
anchorOffset: startOffset, |
||||
focusKey: endText.key, |
||||
focusOffset: endOffset, |
||||
marks: [{ type: TOKEN_MARK, data: { types } }], |
||||
}; |
||||
|
||||
decorations.push(range); |
||||
} |
||||
|
||||
start = end; |
||||
} else if (token.content && token.content.length) { |
||||
// Tokens can be nested
|
||||
for (const subToken of token.content) { |
||||
processToken(subToken, types); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Process top-level tokens
|
||||
for (const token of tokens) { |
||||
processToken(token); |
||||
} |
||||
|
||||
return decorations; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,123 @@ |
||||
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without']; |
||||
|
||||
const AGGREGATION_OPERATORS = [ |
||||
'sum', |
||||
'min', |
||||
'max', |
||||
'avg', |
||||
'stddev', |
||||
'stdvar', |
||||
'count', |
||||
'count_values', |
||||
'bottomk', |
||||
'topk', |
||||
'quantile', |
||||
]; |
||||
|
||||
export const FUNCTIONS = [ |
||||
...AGGREGATION_OPERATORS, |
||||
'abs', |
||||
'absent', |
||||
'ceil', |
||||
'changes', |
||||
'clamp_max', |
||||
'clamp_min', |
||||
'count_scalar', |
||||
'day_of_month', |
||||
'day_of_week', |
||||
'days_in_month', |
||||
'delta', |
||||
'deriv', |
||||
'drop_common_labels', |
||||
'exp', |
||||
'floor', |
||||
'histogram_quantile', |
||||
'holt_winters', |
||||
'hour', |
||||
'idelta', |
||||
'increase', |
||||
'irate', |
||||
'label_replace', |
||||
'ln', |
||||
'log2', |
||||
'log10', |
||||
'minute', |
||||
'month', |
||||
'predict_linear', |
||||
'rate', |
||||
'resets', |
||||
'round', |
||||
'scalar', |
||||
'sort', |
||||
'sort_desc', |
||||
'sqrt', |
||||
'time', |
||||
'vector', |
||||
'year', |
||||
'avg_over_time', |
||||
'min_over_time', |
||||
'max_over_time', |
||||
'sum_over_time', |
||||
'count_over_time', |
||||
'quantile_over_time', |
||||
'stddev_over_time', |
||||
'stdvar_over_time', |
||||
]; |
||||
|
||||
const tokenizer = { |
||||
comment: { |
||||
pattern: /(^|[^\n])#.*/, |
||||
lookbehind: true, |
||||
}, |
||||
'context-aggregation': { |
||||
pattern: /((by|without)\s*)\([^)]*\)/, // by ()
|
||||
lookbehind: true, |
||||
inside: { |
||||
'label-key': { |
||||
pattern: /[^,\s][^,]*[^,\s]*/, |
||||
alias: 'attr-name', |
||||
}, |
||||
}, |
||||
}, |
||||
'context-labels': { |
||||
pattern: /\{[^}]*(?=})/, |
||||
inside: { |
||||
'label-key': { |
||||
pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/, |
||||
alias: 'attr-name', |
||||
}, |
||||
'label-value': { |
||||
pattern: /"(?:\\.|[^\\"])*"/, |
||||
greedy: true, |
||||
alias: 'attr-value', |
||||
}, |
||||
}, |
||||
}, |
||||
function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'), |
||||
'context-range': [ |
||||
{ |
||||
pattern: /\[[^\]]*(?=])/, // [1m]
|
||||
inside: { |
||||
'range-duration': { |
||||
pattern: /\b\d+[smhdwy]\b/i, |
||||
alias: 'number', |
||||
}, |
||||
}, |
||||
}, |
||||
{ |
||||
pattern: /(offset\s+)\w+/, // offset 1m
|
||||
lookbehind: true, |
||||
inside: { |
||||
'range-duration': { |
||||
pattern: /\b\d+[smhdwy]\b/i, |
||||
alias: 'number', |
||||
}, |
||||
}, |
||||
}, |
||||
], |
||||
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/, |
||||
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'), |
||||
punctuation: /[{};()`,.]/, |
||||
}; |
||||
|
||||
export default tokenizer; |
@ -0,0 +1,14 @@ |
||||
export default function RunnerPlugin({ handler }) { |
||||
return { |
||||
onKeyDown(event) { |
||||
// Handle enter
|
||||
if (handler && event.key === 'Enter' && !event.shiftKey) { |
||||
// Submit on Enter
|
||||
event.preventDefault(); |
||||
handler(event); |
||||
return true; |
||||
} |
||||
return undefined; |
||||
}, |
||||
}; |
||||
} |
@ -0,0 +1,14 @@ |
||||
// Based on underscore.js debounce()
|
||||
export default function debounce(func, wait) { |
||||
let timeout; |
||||
return function() { |
||||
const context = this; |
||||
const args = arguments; |
||||
const later = function() { |
||||
timeout = null; |
||||
func.apply(context, args); |
||||
}; |
||||
clearTimeout(timeout); |
||||
timeout = setTimeout(later, wait); |
||||
}; |
||||
} |
@ -0,0 +1,40 @@ |
||||
// Node.closest() polyfill
|
||||
if ('Element' in window && !Element.prototype.closest) { |
||||
Element.prototype.closest = function(s) { |
||||
const matches = (this.document || this.ownerDocument).querySelectorAll(s); |
||||
let el = this; |
||||
let i; |
||||
// eslint-disable-next-line
|
||||
do { |
||||
i = matches.length; |
||||
// eslint-disable-next-line
|
||||
while (--i >= 0 && matches.item(i) !== el) {} |
||||
} while (i < 0 && (el = el.parentElement)); |
||||
return el; |
||||
}; |
||||
} |
||||
|
||||
export function getPreviousCousin(node, selector) { |
||||
let sibling = node.parentElement.previousSibling; |
||||
let el; |
||||
while (sibling) { |
||||
el = sibling.querySelector(selector); |
||||
if (el) { |
||||
return el; |
||||
} |
||||
sibling = sibling.previousSibling; |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
export function getNextCharacter(global = window) { |
||||
const selection = global.getSelection(); |
||||
if (!selection.anchorNode) { |
||||
return null; |
||||
} |
||||
|
||||
const range = selection.getRangeAt(0); |
||||
const text = selection.anchorNode.textContent; |
||||
const offset = range.startOffset; |
||||
return text.substr(offset, 1); |
||||
} |
@ -0,0 +1,20 @@ |
||||
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h']; |
||||
|
||||
export function processLabels(labels) { |
||||
const values = {}; |
||||
labels.forEach(l => { |
||||
const { __name__, ...rest } = l; |
||||
Object.keys(rest).forEach(key => { |
||||
if (!values[key]) { |
||||
values[key] = []; |
||||
} |
||||
if (values[key].indexOf(rest[key]) === -1) { |
||||
values[key].push(rest[key]); |
||||
} |
||||
}); |
||||
}); |
||||
return { values, keys: Object.keys(values) }; |
||||
} |
||||
|
||||
// Strip syntax chars
|
||||
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); |
@ -0,0 +1,304 @@ |
||||
.explore { |
||||
.graph-legend { |
||||
flex-wrap: wrap; |
||||
} |
||||
} |
||||
|
||||
.query-field { |
||||
font-size: 14px; |
||||
font-family: Consolas, Menlo, Courier, monospace; |
||||
height: auto; |
||||
} |
||||
|
||||
.query-field-wrapper { |
||||
position: relative; |
||||
display: inline-block; |
||||
padding: 6px 7px 4px; |
||||
width: calc(100% - 6rem); |
||||
cursor: text; |
||||
line-height: 1.5; |
||||
color: rgba(0, 0, 0, 0.65); |
||||
background-color: #fff; |
||||
background-image: none; |
||||
border: 1px solid lightgray; |
||||
border-radius: 4px; |
||||
transition: all 0.3s; |
||||
} |
||||
|
||||
.typeahead { |
||||
position: absolute; |
||||
z-index: auto; |
||||
top: -10000px; |
||||
left: -10000px; |
||||
opacity: 0; |
||||
border-radius: 4px; |
||||
transition: opacity 0.75s; |
||||
border: 1px solid #e4e4e4; |
||||
max-height: calc(66vh); |
||||
overflow-y: scroll; |
||||
max-width: calc(66%); |
||||
overflow-x: hidden; |
||||
outline: none; |
||||
list-style: none; |
||||
background: #fff; |
||||
color: rgba(0, 0, 0, 0.65); |
||||
transition: opacity 0.4s ease-out; |
||||
} |
||||
|
||||
.typeahead-group__title { |
||||
color: rgba(0, 0, 0, 0.43); |
||||
font-size: 12px; |
||||
line-height: 1.5; |
||||
padding: 8px 16px; |
||||
} |
||||
|
||||
.typeahead-item { |
||||
line-height: 200%; |
||||
height: auto; |
||||
font-family: Consolas, Menlo, Courier, monospace; |
||||
padding: 0 16px 0 28px; |
||||
font-size: 12px; |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
margin-left: -1px; |
||||
left: 1px; |
||||
position: relative; |
||||
z-index: 1; |
||||
display: block; |
||||
white-space: nowrap; |
||||
cursor: pointer; |
||||
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), |
||||
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1); |
||||
} |
||||
|
||||
.typeahead-item__selected { |
||||
background-color: #ecf6fd; |
||||
color: #108ee9; |
||||
} |
||||
|
||||
/* SYNTAX */ |
||||
|
||||
/** |
||||
* prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML |
||||
* Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics); |
||||
* @author Tim Shedor |
||||
*/ |
||||
|
||||
code[class*='language-'], |
||||
pre[class*='language-'] { |
||||
color: black; |
||||
background: none; |
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; |
||||
text-align: left; |
||||
white-space: pre; |
||||
word-spacing: normal; |
||||
word-break: normal; |
||||
word-wrap: normal; |
||||
line-height: 1.5; |
||||
|
||||
-moz-tab-size: 4; |
||||
-o-tab-size: 4; |
||||
tab-size: 4; |
||||
|
||||
-webkit-hyphens: none; |
||||
-moz-hyphens: none; |
||||
-ms-hyphens: none; |
||||
hyphens: none; |
||||
} |
||||
|
||||
/* Code blocks */ |
||||
pre[class*='language-'] { |
||||
position: relative; |
||||
margin: 0.5em 0; |
||||
overflow: visible; |
||||
padding: 0; |
||||
} |
||||
pre[class*='language-'] > code { |
||||
position: relative; |
||||
border-left: 10px solid #358ccb; |
||||
box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf; |
||||
background-color: #fdfdfd; |
||||
background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%); |
||||
background-size: 3em 3em; |
||||
background-origin: content-box; |
||||
background-attachment: local; |
||||
} |
||||
|
||||
code[class*='language'] { |
||||
max-height: inherit; |
||||
height: inherit; |
||||
padding: 0 1em; |
||||
display: block; |
||||
overflow: auto; |
||||
} |
||||
|
||||
/* Margin bottom to accomodate shadow */ |
||||
:not(pre) > code[class*='language-'], |
||||
pre[class*='language-'] { |
||||
background-color: #fdfdfd; |
||||
-webkit-box-sizing: border-box; |
||||
-moz-box-sizing: border-box; |
||||
box-sizing: border-box; |
||||
margin-bottom: 1em; |
||||
} |
||||
|
||||
/* Inline code */ |
||||
:not(pre) > code[class*='language-'] { |
||||
position: relative; |
||||
padding: 0.2em; |
||||
border-radius: 0.3em; |
||||
color: #c92c2c; |
||||
border: 1px solid rgba(0, 0, 0, 0.1); |
||||
display: inline; |
||||
white-space: normal; |
||||
} |
||||
|
||||
pre[class*='language-']:before, |
||||
pre[class*='language-']:after { |
||||
content: ''; |
||||
z-index: -2; |
||||
display: block; |
||||
position: absolute; |
||||
bottom: 0.75em; |
||||
left: 0.18em; |
||||
width: 40%; |
||||
height: 20%; |
||||
max-height: 13em; |
||||
box-shadow: 0px 13px 8px #979797; |
||||
-webkit-transform: rotate(-2deg); |
||||
-moz-transform: rotate(-2deg); |
||||
-ms-transform: rotate(-2deg); |
||||
-o-transform: rotate(-2deg); |
||||
transform: rotate(-2deg); |
||||
} |
||||
|
||||
:not(pre) > code[class*='language-']:after, |
||||
pre[class*='language-']:after { |
||||
right: 0.75em; |
||||
left: auto; |
||||
-webkit-transform: rotate(2deg); |
||||
-moz-transform: rotate(2deg); |
||||
-ms-transform: rotate(2deg); |
||||
-o-transform: rotate(2deg); |
||||
transform: rotate(2deg); |
||||
} |
||||
|
||||
.token.comment, |
||||
.token.block-comment, |
||||
.token.prolog, |
||||
.token.doctype, |
||||
.token.cdata { |
||||
color: #7d8b99; |
||||
} |
||||
|
||||
.token.punctuation { |
||||
color: #5f6364; |
||||
} |
||||
|
||||
.token.property, |
||||
.token.tag, |
||||
.token.boolean, |
||||
.token.number, |
||||
.token.function-name, |
||||
.token.constant, |
||||
.token.symbol, |
||||
.token.deleted { |
||||
color: #c92c2c; |
||||
} |
||||
|
||||
.token.selector, |
||||
.token.attr-name, |
||||
.token.string, |
||||
.token.char, |
||||
.token.function, |
||||
.token.builtin, |
||||
.token.inserted { |
||||
color: #2f9c0a; |
||||
} |
||||
|
||||
.token.operator, |
||||
.token.entity, |
||||
.token.url, |
||||
.token.variable { |
||||
color: #a67f59; |
||||
background: rgba(255, 255, 255, 0.5); |
||||
} |
||||
|
||||
.token.atrule, |
||||
.token.attr-value, |
||||
.token.keyword, |
||||
.token.class-name { |
||||
color: #1990b8; |
||||
} |
||||
|
||||
.token.regex, |
||||
.token.important { |
||||
color: #e90; |
||||
} |
||||
|
||||
.language-css .token.string, |
||||
.style .token.string { |
||||
color: #a67f59; |
||||
background: rgba(255, 255, 255, 0.5); |
||||
} |
||||
|
||||
.token.important { |
||||
font-weight: normal; |
||||
} |
||||
|
||||
.token.bold { |
||||
font-weight: bold; |
||||
} |
||||
.token.italic { |
||||
font-style: italic; |
||||
} |
||||
|
||||
.token.entity { |
||||
cursor: help; |
||||
} |
||||
|
||||
.namespace { |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
@media screen and (max-width: 767px) { |
||||
pre[class*='language-']:before, |
||||
pre[class*='language-']:after { |
||||
bottom: 14px; |
||||
box-shadow: none; |
||||
} |
||||
} |
||||
|
||||
/* Plugin styles */ |
||||
.token.tab:not(:empty):before, |
||||
.token.cr:before, |
||||
.token.lf:before { |
||||
color: #e0d7d1; |
||||
} |
||||
|
||||
/* Plugin styles: Line Numbers */ |
||||
pre[class*='language-'].line-numbers { |
||||
padding-left: 0; |
||||
} |
||||
|
||||
pre[class*='language-'].line-numbers code { |
||||
padding-left: 3.8em; |
||||
} |
||||
|
||||
pre[class*='language-'].line-numbers .line-numbers-rows { |
||||
left: 0; |
||||
} |
||||
|
||||
/* Plugin styles: Line Highlight */ |
||||
pre[class*='language-'][data-line] { |
||||
padding-top: 0; |
||||
padding-bottom: 0; |
||||
padding-left: 0; |
||||
} |
||||
pre[data-line] code { |
||||
position: relative; |
||||
padding-left: 4em; |
||||
} |
||||
pre .line-highlight { |
||||
margin-top: 0; |
||||
} |
Loading…
Reference in new issue