mirror of
https://github.com/grafana/grafana.git
synced 2024-11-30 04:34:23 -06:00
eadaff6191
* style header like other grafana components * use panel container for graph and same styles for query field * fix typeahead CSS selector (was created outside of .explore) * use navbar buttons for +/- of rows * moved elapsed time under run query button * fix JS error on multiple timeseries being returned * fix color for graph lines * show prometheus query errors
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
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('explore-typeahead', `explore-typeahead-${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;
|