2025-04-01 10:38:02 +09:00

277 lines
9.3 KiB
TypeScript

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<string>();
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<number>();
return (
<>
<CodeEditor
value={query.query || ''}
language={langId}
onBlur={onEditorChange}
onChange={onEditorChange}
containerStyles={styles.queryField}
readOnly={props.readOnly}
monacoOptions={{
folding: false,
fontSize: 14,
lineNumbers: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
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 && <TemporaryAlert severity="error" text={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<string, unknown> = { 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<CompletionProvider>(
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,
},
}),
};
};