1
import React, { FC, useState, useEffect, useRef } from 'react';
2
import { Alert, Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
4
import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view';
5
import { EditorState, Prec, Compartment } from '@codemirror/state';
6
import { bracketMatching, indentOnInput, syntaxHighlighting, syntaxTree } from '@codemirror/language';
7
import { defaultKeymap, history, historyKeymap, insertNewlineAndIndent } from '@codemirror/commands';
8
import { highlightSelectionMatches } from '@codemirror/search';
9
import { lintKeymap } from '@codemirror/lint';
17
} from '@codemirror/autocomplete';
18
import { baseTheme, lightTheme, darkTheme, promqlHighlighter, darkPromqlHighlighter } from './CMTheme';
20
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
21
import { faSearch, faSpinner, faGlobeEurope, faIndent, faCheck } from '@fortawesome/free-solid-svg-icons';
22
import MetricsExplorer from './MetricsExplorer';
23
import { usePathPrefix } from '../../contexts/PathPrefixContext';
24
import { useTheme } from '../../contexts/ThemeContext';
25
import { CompleteStrategy, PromQLExtension } from '@prometheus-io/codemirror-promql';
26
import { newCompleteStrategy } from '@prometheus-io/codemirror-promql/dist/esm/complete';
27
import { API_PATH } from '../../constants/constants';
29
const promqlExtension = new PromQLExtension();
31
interface CMExpressionInputProps {
33
onExpressionChange: (expr: string) => void;
34
queryHistory: string[];
35
metricNames: string[];
36
executeQuery: () => void;
38
enableAutocomplete: boolean;
39
enableHighlighting: boolean;
40
enableLinter: boolean;
43
const dynamicConfigCompartment = new Compartment();
45
// Autocompletion strategy that wraps the main one and enriches
46
// it with past query items.
47
export class HistoryCompleteStrategy implements CompleteStrategy {
48
private complete: CompleteStrategy;
49
private queryHistory: string[];
50
constructor(complete: CompleteStrategy, queryHistory: string[]) {
51
this.complete = complete;
52
this.queryHistory = queryHistory;
55
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null {
56
return Promise.resolve(this.complete.promQL(context)).then((res) => {
57
const { state, pos } = context;
58
const tree = syntaxTree(state).resolve(pos, -1);
59
const start = res != null ? res.from : tree.from;
65
const historyItems: CompletionResult = {
68
options: this.queryHistory.map((q) => ({
69
label: q.length < 80 ? q : q.slice(0, 76).concat('...'),
72
info: q.length < 80 ? undefined : q,
74
validFor: /^[a-zA-Z0-9_:]+$/,
78
historyItems.options = historyItems.options.concat(res.options);
85
const ExpressionInput: FC<CMExpressionInputProps> = ({
96
const containerRef = useRef<HTMLDivElement>(null);
97
const viewRef = useRef<EditorView | null>(null);
98
const [showMetricsExplorer, setShowMetricsExplorer] = useState<boolean>(false);
99
const pathPrefix = usePathPrefix();
100
const { theme } = useTheme();
102
const [formatError, setFormatError] = useState<string | null>(null);
103
const [isFormatting, setIsFormatting] = useState<boolean>(false);
104
const [exprFormatted, setExprFormatted] = useState<boolean>(false);
106
// (Re)initialize editor based on settings / setting changes.
108
// Build the dynamic part of the config.
110
.activateCompletion(enableAutocomplete)
111
.activateLinter(enableLinter)
113
completeStrategy: new HistoryCompleteStrategy(
114
newCompleteStrategy({
115
remote: { url: pathPrefix, cache: { initialMetricList: metricNames } },
121
let highlighter = syntaxHighlighting(theme === 'dark' ? darkPromqlHighlighter : promqlHighlighter);
122
if (theme === 'dark') {
123
highlighter = syntaxHighlighting(darkPromqlHighlighter);
126
const dynamicConfig = [
127
enableHighlighting ? highlighter : [],
128
promqlExtension.asExtension(),
129
theme === 'dark' ? darkTheme : lightTheme,
132
// Create or reconfigure the editor.
133
const view = viewRef.current;
135
// If the editor does not exist yet, create it.
136
if (!containerRef.current) {
137
throw new Error('expected CodeMirror container element to exist');
140
const startState = EditorState.create({
144
highlightSpecialChars(),
146
EditorState.allowMultipleSelections.of(true),
151
highlightSelectionMatches(),
152
EditorView.lineWrapping,
153
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ...completionKeymap, ...lintKeymap]),
154
placeholder('Expression (press Shift+Enter for newlines)'),
155
dynamicConfigCompartment.of(dynamicConfig),
156
// This keymap is added without precedence so that closing the autocomplete dropdown
157
// via Escape works without blurring the editor.
161
run: (v: EditorView): boolean => {
171
run: (v: EditorView): boolean => {
178
run: insertNewlineAndIndent,
182
EditorView.updateListener.of((update: ViewUpdate): void => {
183
if (update.docChanged) {
184
onExpressionChange(update.state.doc.toString());
185
setExprFormatted(false);
191
const view = new EditorView({
193
parent: containerRef.current,
196
viewRef.current = view;
200
// The editor already exists, just reconfigure the dynamically configured parts.
203
effects: dynamicConfigCompartment.reconfigure(dynamicConfig),
207
// "value" is only used in the initial render, so we don't want to
208
// re-run this effect every time that "value" changes.
210
// eslint-disable-next-line react-hooks/exhaustive-deps
211
}, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory, theme]);
213
const insertAtCursor = (value: string) => {
214
const view = viewRef.current;
218
const { from, to } = view.state.selection.ranges[0];
221
changes: { from, to, insert: value },
226
const formatExpression = () => {
227
setFormatError(null);
228
setIsFormatting(true);
231
`${pathPrefix}/${API_PATH}/format_query?${new URLSearchParams({
236
credentials: 'same-origin',
240
if (!resp.ok && resp.status !== 400) {
241
throw new Error(`format HTTP request failed: ${resp.statusText}`);
247
if (json.status !== 'success') {
248
throw new Error(json.error || 'invalid response JSON');
251
const view = viewRef.current;
256
view.dispatch(view.state.update({ changes: { from: 0, to: view.state.doc.length, insert: json.data } }));
257
setExprFormatted(true);
260
setFormatError(err.message);
263
setIsFormatting(false);
269
<InputGroup className="expression-input">
270
<InputGroupAddon addonType="prepend">
272
{loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
275
<div ref={containerRef} className="cm-expression-input" />
276
<InputGroupAddon addonType="append">
278
className="expression-input-action-btn"
279
title={isFormatting ? 'Formatting expression' : exprFormatted ? 'Expression formatted' : 'Format expression'}
280
onClick={formatExpression}
281
disabled={isFormatting || exprFormatted}
284
<FontAwesomeIcon icon={faSpinner} spin />
285
) : exprFormatted ? (
286
<FontAwesomeIcon icon={faCheck} />
288
<FontAwesomeIcon icon={faIndent} />
292
className="expression-input-action-btn"
293
title="Open metrics explorer"
294
onClick={() => setShowMetricsExplorer(true)}
296
<FontAwesomeIcon icon={faGlobeEurope} />
298
<Button className="execute-btn" color="primary" onClick={executeQuery}>
304
{formatError && <Alert color="danger">Error formatting expression: {formatError}</Alert>}
307
show={showMetricsExplorer}
308
updateShow={setShowMetricsExplorer}
309
metrics={metricNames}
310
insertAtCursor={insertAtCursor}
316
export default ExpressionInput;