import { css } from '@emotion/css'; import { useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { TemporaryAlert } from '@grafana/o11y-ds-frontend'; import { reportInteraction } from '@grafana/runtime'; import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui'; import { TempoDatasource } from '../datasource'; import { TempoQuery } from '../types'; import { CompletionProvider, CompletionItemType } from './autocomplete'; import { getErrorNodes, setMarkers } from './highlighting'; import { languageDefinition } from './traceql'; interface Props { placeholder: string; query: TempoQuery; onChange: (val: TempoQuery) => void; onRunQuery: () => void; datasource: TempoDatasource; readOnly?: boolean; } export function TraceQLEditor(props: Props) { const [alertText, setAlertText] = useState(); const { query, onChange, onRunQuery, placeholder } = props; const setupAutocompleteFn = useAutocomplete(props.datasource, setAlertText); const theme = useTheme2(); const styles = getStyles(theme, placeholder); // The Monaco Editor uses the first version of props.onChange in handleOnMount i.e. always has the initial // value of query because underlying Monaco editor is passed `query` below in the onEditorChange callback. // handleOnMount is called only once when the editor is mounted and does not get updates to query. // So we need useRef to get the latest version of query in the onEditorChange callback. const queryRef = useRef(query); queryRef.current = query; const onEditorChange = (value: string) => { onChange({ ...queryRef.current, query: value }); }; // work around the problem that `onEditorDidMount` is called once // and wouldn't get new version of onRunQuery const onRunQueryRef = useRef(onRunQuery); onRunQueryRef.current = onRunQuery; const errorTimeoutId = useRef(); return ( <> { if (!props.readOnly) { setupAutocompleteFn(editor, monaco, setupRegisterInteractionCommand(editor)); setupActions(editor, monaco, () => onRunQueryRef.current()); setupPlaceholder(editor, monaco, styles); } setupAutoSize(editor); // Parse query that might already exist (e.g., after a page refresh) const model = editor.getModel(); if (model) { const errorNodes = getErrorNodes(model.getValue()); setMarkers(monaco, model, errorNodes); } // Register callback for query changes editor.onDidChangeModelContent((changeEvent) => { const model = editor.getModel(); if (!model) { return; } // Remove previous callback if existing, to prevent squiggles from been shown while the user is still typing window.clearTimeout(errorTimeoutId.current); const errorNodes = getErrorNodes(model.getValue()); const cursorPosition = changeEvent.changes[0].rangeOffset; // Immediately updates the squiggles, in case the user fixed an error, // excluding the error around the cursor position setMarkers( monaco, model, errorNodes.filter((errorNode) => !(errorNode.from <= cursorPosition && cursorPosition <= errorNode.to)) ); // Show all errors after a short delay, to avoid flickering errorTimeoutId.current = window.setTimeout(() => { setMarkers(monaco, model, errorNodes); }, 500); }); }} /> {alertText && } ); } function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) { const placeholderDecorators = [ { range: new monaco.Range(1, 1, 1, 1), options: { className: styles.placeholder, isWholeLine: true, }, }, ]; let decorators: string[] = []; const checkDecorators = (): void => { const model = editor.getModel(); if (!model) { return; } const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : []; decorators = model.deltaDecorations(decorators, newDecorators); }; checkDecorators(); editor.onDidChangeModelContent(checkDecorators); } function setupActions(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, onRunQuery: () => void) { editor.addAction({ id: 'run-query', label: 'Run Query', keybindings: [monaco.KeyMod.Shift | monaco.KeyCode.Enter], contextMenuGroupId: 'navigation', contextMenuOrder: 1.5, run: function () { onRunQuery(); }, }); } function setupRegisterInteractionCommand(editor: monacoTypes.editor.IStandaloneCodeEditor): string | null { return editor.addCommand(0, function (_, label, type: CompletionItemType) { const properties: Record = { datasourceType: 'tempo', type }; // Filter out the label for TAG_VALUE completions to avoid potentially exposing sensitive data if (type !== 'TAG_VALUE') { properties.label = label; } reportInteraction('grafana_traces_traceql_completion', properties); }); } function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) { const container = editor.getDomNode(); const updateHeight = () => { if (container) { const contentHeight = Math.min(1000, editor.getContentHeight()); const width = parseInt(container.style.width, 10); container.style.width = `${width}px`; container.style.height = `${contentHeight}px`; editor.layout({ width, height: contentHeight }); } }; editor.onDidContentSizeChange(updateHeight); updateHeight(); } /** * Hook that returns function that will set up monaco autocomplete for the label selector * @param datasource the Tempo datasource instance * @param setAlertText setter for alert's text */ function useAutocomplete(datasource: TempoDatasource, setAlertText: (text?: string) => void) { // We need the provider ref so we can pass it the label/values data later. This is because we run the call for the // values here but there is additional setup needed for the provider later on. We could run the getSeries() in the // returned function but that is run after the monaco is mounted so would delay the request a bit when it does not // need to. const providerRef = useRef( new CompletionProvider({ languageProvider: datasource.languageProvider, setAlertText }) ); useEffect(() => { const fetchTags = async () => { try { await datasource.languageProvider.start(); setAlertText(undefined); } catch (error) { if (error instanceof Error) { setAlertText(`Error: ${error.message}`); } } }; fetchTags(); }, [datasource, setAlertText]); const autocompleteDisposeFun = useRef<(() => void) | null>(null); useEffect(() => { // when we unmount, we unregister the autocomplete-function, if it was registered return () => { autocompleteDisposeFun.current?.(); }; }, []); // This should be run in monaco onEditorDidMount return ( editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, registerInteractionCommandId: string | null ) => { providerRef.current.editor = editor; providerRef.current.monaco = monaco; providerRef.current.setRegisterInteractionCommandId(registerInteractionCommandId); const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current); autocompleteDisposeFun.current = dispose; }; } // we must only run the setup code once let traceqlSetupDone = false; const langId = 'traceql'; function ensureTraceQL(monaco: Monaco) { if (!traceqlSetupDone) { traceqlSetupDone = true; const { aliases, extensions, mimetypes, def } = languageDefinition; monaco.languages.register({ id: langId, aliases, extensions, mimetypes }); monaco.languages.setMonarchTokensProvider(langId, def.language); monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration); } } interface EditorStyles { placeholder: string; queryField: string; } const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => { return { queryField: css({ borderRadius: theme.shape.radius.default, border: `1px solid ${theme.components.input.borderColor}`, flex: 1, }), placeholder: css({ '::after': { content: `'${placeholder}'`, fontFamily: theme.typography.fontFamilyMonospace, opacity: 0.3, }, }), }; };