Files
openobserve/web/src/components/CodeQueryEditor.vue
T
ab156bad8d fix: changed code-mirror to monaco-editor (#8047)
### **PR Type**
Enhancement, Bug fix


___

### **Description**
- Replace CodeMirror with Monaco editor

- Add Monaco autocomplete and markers

- Update build to bundle Monaco

- Remove re-render key from logs UI


___

### Diagram Walkthrough


```mermaid
flowchart LR
  CM["CodeMirror-based editor"] -- Replaced by --> ME["Monaco editor integration"]
  ME -- Autocomplete/Markers --> UX["Improved editor UX"]
  Build["Vite config"] -- Add monaco plugin/chunks --> Bundle["Optimized bundles"]
  UI["Logs and SearchBar"] -- Minor fixes/cleanup --> Stability["Reduced re-renders"]
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>3
files</summary><table>
<tr>
<td><strong>CodeQueryEditor.vue</strong><dd><code>Replace CodeMirror
with Monaco, add features</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-52a36bf2d64e590036ed1c1f89b07a4a6b971a84e4fae9b4272cbc69d63f9bef">+369/-980</a></td>

</tr>

<tr>
<td><strong>Index.vue</strong><dd><code>Cleanups, visualization flow,
formatting tweaks</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-0323c3da69b369843c5d72bf4df5b00ca6b5147192a213baf331659ecf92af62">+105/-78</a></td>

</tr>

<tr>
<td><strong>SearchBar.vue</strong><dd><code>Editor placeholder, menus,
toggle guards</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-81893de4edf186b0556f610acb280c87baa2ffe67d3b48a60a866094c7635baf">+148/-82</a></td>

</tr>

</table></details></td></tr><tr><td><strong>Dependencies</strong></td><td><details><summary>3
files</summary><table>
<tr>
<td><strong>CodeQueryEditorDarkTheme.ts</strong><dd><code>Remove legacy
CodeMirror dark theme</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-4ee0166b549a9a7d6a91e72d546dffd2d031b0ad6015ef5f735b67742e13c66d">+0/-230</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>CodeQueryEditorLightTheme.ts</strong><dd><code>Remove legacy
CodeMirror light theme</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-4e80117fff859d80b9dde6898e8026f2c2514c43244e48e2b639527409d38682">+0/-246</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>package.json</strong><dd><code>Remove CodeMirror deps; add
Monaco and plugin</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-b861012a5dd72b8a9f3281b7cf09f5a779c98569d040b1bbc1db50f1b15e7cce">+2/-16</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Configuration
changes</strong></td><td><details><summary>1 files</summary><table>
<tr>
<td><strong>vite.config.ts</strong><dd><code>Integrate Monaco plugin and
chunking</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/8047/files#diff-9430906049744cbbfe5d0b382b2466843a42cfebf755ff86fec850b0712258ef">+25/-15</a>&nbsp;
</td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___

---------

Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: Bhargav <BJP232004@GMAIL.COM>
2025-09-12 16:50:35 +05:30

783 lines
22 KiB
Vue

<!-- Copyright 2023 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
data-test="query-editor"
class="logs-query-editor"
ref="editorRef"
:id="editorId"
/>
</template>
<script lang="ts">
import {
defineComponent,
ref,
onMounted,
nextTick,
type Ref,
onDeactivated,
onUnmounted,
onActivated,
watch,
computed,
} from "vue";
import "monaco-editor/esm/vs/editor/editor.all.js";
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition";
import { useStore } from "vuex";
import { debounce } from "quasar";
import useLogs from "@/composables/useLogs";
export default defineComponent({
props: {
editorId: {
type: String,
default: "editor",
},
query: {
type: String,
default: "",
},
showAutoComplete: {
type: Boolean,
default: true,
},
keywords: {
type: Array,
default: () => [],
},
suggestions: {
type: Array,
default: () => [],
},
debounceTime: {
type: Number,
default: 500,
},
readOnly: {
type: Boolean,
default: false,
},
language: {
type: String,
default: "sql",
},
functions: {
type: Array,
default: () => [],
},
fields: {
type: Array,
default: () => [],
},
},
emits: ["update-query", "run-query", "update:query", "focus", "blur"],
setup(props, { emit }) {
const store = useStore();
const editorRef: any = ref();
// editor object is used to interact with the monaco editor instance
let editorObj: any = null;
const { searchObj } = useLogs();
let provider: Ref<monaco.IDisposable | null> = ref(null);
const CompletionKind: any = {
Keyword: monaco.languages.CompletionItemKind.Keyword,
Operator: monaco.languages.CompletionItemKind.Operator,
Text: monaco.languages.CompletionItemKind.Text,
Value: monaco.languages.CompletionItemKind.Value,
Method: monaco.languages.CompletionItemKind.Method,
Function: monaco.languages.CompletionItemKind.Function,
Constructor: monaco.languages.CompletionItemKind.Constructor,
Field: monaco.languages.CompletionItemKind.Field,
Variable: monaco.languages.CompletionItemKind.Variable,
};
const insertTextRules: any = {
InsertAsSnippet:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
KeepWhitespace:
monaco.languages.CompletionItemInsertTextRule.KeepWhitespace,
None: monaco.languages.CompletionItemInsertTextRule.None,
};
const defaultKeywords = [
{
label: "and",
kind: "Keyword",
insertText: "and ",
},
{
label: "or",
kind: "Keyword",
insertText: "or ",
},
{
label: "like",
kind: "Keyword",
insertText: "like '%${1:params}%' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "in",
kind: "Keyword",
insertText: "in ('${1:params}') ",
insertTextRules: "InsertAsSnippet",
},
{
label: "not in",
kind: "Keyword",
insertText: "not in ('${1:params}') ",
insertTextRules: "InsertAsSnippet",
},
{
label: "between",
kind: "Keyword",
insertText: "between '${1:params}' and '${1:params}' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "not between",
kind: "Keyword",
insertText: "not between '${1:params}' and '${1:params}' ",
insertTextRules: "InsertAsSnippet",
},
{
label: "is null",
kind: "Keyword",
insertText: "is null ",
},
{
label: "is not null",
kind: "Keyword",
insertText: "is not null ",
},
{
label: ">",
kind: "Operator",
insertText: "> ",
},
{
label: "<",
kind: "Operator",
insertText: "< ",
},
{
label: ">=",
kind: "Operator",
insertText: ">= ",
},
{
label: "<=",
kind: "Operator",
insertText: "<= ",
},
{
label: "<>",
kind: "Operator",
insertText: "<> ",
},
{
label: "=",
kind: "Operator",
insertText: "= ",
},
{
label: "!=",
kind: "Operator",
insertText: "!= ",
},
{
label: "()",
kind: "Keyword",
insertText: "(${1:condition}) ",
insertTextRules: "InsertAsSnippet",
},
];
const defaultSuggestions = [
{
label: (_keyword: string) => `match_all('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `match_all('${_keyword}')`,
},
{
label: (_keyword: string) => `match_all_raw('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `match_all_raw('${_keyword}')`,
},
{
label: (_keyword: string) => `match_all_raw_ignore_case('${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) =>
`match_all_raw_ignore_case('${_keyword}')`,
},
{
label: (_keyword: string) =>
`re_match(fieldname: string, regular_expression: string)`,
kind: "Text",
insertText: (_keyword: string) => `re_match(fieldname, '')`,
},
{
label: (_keyword: string) =>
`re_not_match(fieldname: string, regular_expression: string)`,
kind: "Text",
insertText: (_keyword: string) => `re_not_match(fieldname, '')`,
},
{
label: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) => `str_match(fieldname, '${_keyword}')`,
},
{
label: (_keyword: string) =>
`str_match_ignore_case(fieldname, '${_keyword}')`,
kind: "Text",
insertText: (_keyword: string) =>
`str_match_ignore_case(fieldname, '${_keyword}')`,
},
{
label: (_keyword: string) => `fuzzy_match(fieldname, '${_keyword}', 1)`,
kind: "Text",
insertText: (_keyword: string) =>
`fuzzy_match(fieldname, '${_keyword}', 1)`,
},
{
label: (_keyword: string) => `fuzzy_match_all('${_keyword}', 1)`,
kind: "Text",
insertText: (_keyword: string) => `fuzzy_match_all('${_keyword}', 1)`,
},
];
watch(
() => store.state.theme,
() => {
monaco.editor.setTheme(
store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
);
},
);
const keywords = computed(() => {
if (props.language === "sql" && !props.keywords?.length) {
return defaultKeywords;
}
return props.keywords;
});
const suggestions = computed(() => {
if (props.language === "sql" && !props.suggestions?.length) {
return defaultSuggestions;
}
return props.suggestions;
});
const createDependencyProposals = (range: any) => {
return keywords.value.map((keyword: any) => {
const itemObj: any = {
...keyword,
label: keyword["label"],
kind: CompletionKind[keyword["kind"]],
insertText: keyword["insertText"],
range: range,
};
if (insertTextRules[keyword["insertTextRule"]]) {
itemObj["insertTextRules"] =
insertTextRules[keyword["insertTextRule"]];
}
return itemObj;
});
};
const setupEditor = async () => {
monaco.editor.defineTheme("myCustomTheme", {
base: "vs", // can also be vs-dark or hc-black
inherit: true, // can also be false to completely replace the builtin rules
rules: [{ token: "comment", background: "FFFFFF" }],
colors: {
"editor.foreground": "#000000",
"editor.background": "#fafafa",
"editorCursor.foreground": "#000000",
"editor.lineHighlightBackground": "#FFFFFF",
"editorLineNumber.foreground": "#000000",
"editor.border": "#000000",
},
});
// Dispose the provider if it already exists before registering a new one
provider.value?.dispose();
registerAutoCompleteProvider();
let editorElement = document.getElementById(props.editorId);
let retryCount = 0;
const maxRetries = 5;
// Retry mechanism to ensure the editor element is found
while (!editorElement && retryCount < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait for 100ms
editorElement = document.getElementById(props.editorId);
retryCount++;
}
if (!editorElement) {
console.error("Query Editor element not found after retries");
return;
}
if (editorElement && editorElement?.hasChildNodes()) return;
editorObj = monaco.editor.create(editorElement as HTMLElement, {
value: props.query?.trim(),
language: props.language,
theme: store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
showFoldingControls: enableCodeFolding.value ? "always" : "never",
folding: enableCodeFolding.value,
wordWrap: "on",
automaticLayout: true,
lineNumbers: "on",
lineNumbersMinChars: 0,
overviewRulerLanes: 0,
fixedOverflowWidgets: true,
overviewRulerBorder: false,
lineDecorationsWidth: 3,
hideCursorInOverviewRuler: true,
renderLineHighlight: "none",
glyphMargin: false,
scrollBeyondLastColumn: 0,
scrollBeyondLastLine: false,
smoothScrolling: true,
mouseWheelScrollSensitivity: 1,
fastScrollSensitivity: 1,
scrollbar: { horizontal: "auto", vertical: "visible" },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: "never",
seedSearchStringFromSelection: "never",
},
minimap: { enabled: false },
readOnly: props.readOnly,
renderValidationDecorations: "on",
});
editorObj.onDidChangeModelContent(
debounce((e: any) => {
emit("update-query", editorObj.getValue()?.trim(), e);
emit("update:query", editorObj.getValue()?.trim(), e);
}, props.debounceTime),
);
editorObj.createContextKey("ctrlenter", true);
editorObj.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
function () {
setTimeout(() => {
emit("run-query");
}, 300);
},
"ctrlenter",
);
editorObj.onDidFocusEditorWidget(() => {
emit("focus");
});
editorObj.onDidBlurEditorWidget(() => {
const model = editorObj.getModel();
const value = model.getValue();
const trimmedValue = value.trim();
// Only apply trim if there are actually tailing and leading spaces to trim
if (value !== trimmedValue) {
const lastLine = model.getLineCount();
const lastLineLength = model.getLineLength(lastLine);
// Create an edit operation that replaces the entire content
// This preserves undo history becuase it treats this as a single edit operation
//and it will be in the undo stack as one operation
model.pushEditOperations(
[],
[
{
range: new monaco.Range(1, 1, lastLine, lastLineLength + 1),
text: trimmedValue,
},
],
() => null,
);
}
emit("blur");
});
const handleWindowClick = () => {
editorObj?.layout();
};
const handleWindowResize = async () => {
await nextTick();
editorObj?.layout();
// queryEditorRef.value.resetEditorLayout();
};
window.addEventListener("click", handleWindowClick);
window.addEventListener("resize", handleWindowResize);
// Store references for cleanup
editorObj._windowClickHandler = handleWindowClick;
editorObj._windowResizeHandler = handleWindowResize;
};
onMounted(async () => {
provider.value?.dispose();
if (props.language === "vrl") {
monaco.languages.register({ id: "vrl" });
// Register a tokens provider for the language
monaco.languages.setMonarchTokensProvider(
"vrl",
vrlLanguageDefinition as any,
);
}
if (props.language === "sql") {
await import(
"monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js"
);
}
if (props.language === "json") {
await import(
"monaco-editor/esm/vs/language/json/monaco.contribution.js"
);
}
if (props.language === "html") {
await import(
"monaco-editor/esm/vs/language/html/monaco.contribution.js"
);
}
if (props.language === "markdown") {
await import(
"monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js"
);
}
if (props.language === "python") {
await import(
"monaco-editor/esm/vs/basic-languages/python/python.contribution.js"
);
}
if (props.language === "javascript") {
await import(
"monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution.js"
);
}
setupEditor();
});
onActivated(async () => {
if (!editorObj) {
setupEditor();
editorObj?.layout();
} else {
provider.value?.dispose();
registerAutoCompleteProvider();
}
});
onDeactivated(() => {
provider.value?.dispose();
});
onUnmounted(() => {
provider.value?.dispose();
// Clean up global event listeners
if (editorObj) {
if (editorObj._windowClickHandler) {
window.removeEventListener("click", editorObj._windowClickHandler);
}
if (editorObj._windowResizeHandler) {
window.removeEventListener("resize", editorObj._windowResizeHandler);
}
// Dispose the editor
editorObj.dispose();
editorObj = null;
}
});
const enableCodeFolding = computed(() => {
return ["json", "html", "javascript"].includes(props.language);
});
// update readonly when prop value changes
watch(
() => props.readOnly,
() => {
editorObj?.updateOptions({ readOnly: props.readOnly });
},
);
watch(
() => store.state.theme,
() => {
monaco.editor.setTheme(
store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
);
},
);
// update readonly when prop value changes
watch(
() => props.query,
() => {
if (props.readOnly || !editorObj?.hasWidgetFocus()) {
editorObj?.getModel().setValue(props.query);
}
},
);
const setValue = (value: string) => {
if (editorObj?.setValue) {
editorObj.setValue(value);
editorObj?.layout();
}
};
const registerAutoCompleteProvider = () => {
if (!props.showAutoComplete) return;
provider.value = monaco.languages.registerCompletionItemProvider(
props.language,
{
provideCompletionItems: function (model, position) {
// find out if we are completing a property in the 'dependencies' object.
var textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
var word = model.getWordUntilPosition(position);
var range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
let arr = textUntilPosition.trim().split(" ");
let filteredSuggestions = [];
filteredSuggestions = createDependencyProposals(range);
filteredSuggestions = filteredSuggestions.filter((item) => {
return item.label.toLowerCase().includes(word.word.toLowerCase());
});
const lastElement = arr.pop();
suggestions.value.forEach((suggestion: any) => {
filteredSuggestions.push({
label: suggestion.label(lastElement),
kind: monaco.languages.CompletionItemKind[
suggestion.kind || "Text"
],
insertText: suggestion.insertText(lastElement),
range: range,
});
});
return {
suggestions: filteredSuggestions,
};
},
},
);
};
const resetEditorLayout = () => {
editorObj?.layout();
};
const triggerAutoComplete = async (value: string) => {
disableSuggestionPopup();
await nextTick();
editorObj.trigger(value, "editor.action.triggerSuggest", {});
};
const disableSuggestionPopup = () => {
const escEvent = new KeyboardEvent("keydown", {
keyCode: 27,
code: "Escape",
key: "Escape",
bubbles: true,
});
editorRef.value.dispatchEvent(escEvent);
};
const formatDocument = async () => {
// As Monaco editor does not support formatting in read-only mode, we need to temporarily disable it while formatting
return new Promise((resolve) => {
editorObj.updateOptions({ readOnly: false });
editorObj
.getAction("editor.action.formatDocument")
.run()
.then(() => {
editorObj.updateOptions({ readOnly: props.readOnly });
resolve(true);
})
.catch(() => {
editorObj.updateOptions({ readOnly: props.readOnly });
resolve(false);
});
});
};
const getCursorIndex = () => {
const currentPosition = editorObj.getPosition();
const cursorIndex =
editorObj?.getModel().getOffsetAt(currentPosition) - 1;
return cursorIndex || null;
};
const getModel = () => {
return editorObj?.getModel();
};
const getValue = () => {
return editorObj?.getValue();
};
const decorateRanges = (ranges: any[]) => {
// Highlight the ranges
const decorations = ranges.map((range) => {
return {
range: new monaco.Range(range.startLine, 1, range.endLine, 1),
options: {
isWholeLine: true,
className: "highlight-error", // Add this class to style the highlighted lines
glyphMarginClassName: "error-glyph", // Optional: add a custom icon to the gutter
},
};
});
const decorationIds = editorObj.deltaDecorations([], decorations);
};
function addErrorDiagnostics(ranges: any) {
// const markers = [
// {
// resource: {
// $mid: 1,
// external: "inmemory://model/4",
// path: "/4",
// scheme: "inmemory",
// authority: "model",
// },
// owner: "owner",
// code: "MY_ERROR_CODE",
// severity: monaco.MarkerSeverity.Error,
// message: "Error: Something went wrong",
// startLineNumber: 2,
// startColumn: 1,
// endLineNumber: 7,
// endColumn: 1,
// },
// {
// resource: {
// $mid: 1,
// external: "inmemory://model/4",
// path: "/4",
// scheme: "inmemory",
// authority: "model",
// },
// owner: "owner",
// code: "MY_ERROR_CODE",
// severity: monaco.MarkerSeverity.Error,
// message: "Error: Something went wrong",
// startLineNumber: 8,
// startColumn: 1,
// endLineNumber: 13,
// endColumn: 1,
// },
// ];
// Set markers to the model
// monaco.editor.setModelMarkers(getModel(), "owner", markers);
const markers = ranges.map((range: any) => ({
severity: monaco.MarkerSeverity.Error, // Mark as error
startLineNumber: range.startLine,
startColumn: 1, // Start of the line
endLineNumber: range.endLine,
endColumn: 1, // End of the line
message: range.error, // The error message
code: "", // Optional error code
}));
monaco.editor.setModelMarkers(getModel(), "owner", []);
monaco.editor.setModelMarkers(getModel(), "owner", markers);
}
return {
editorRef,
editorObj,
setValue,
resetEditorLayout,
disableSuggestionPopup,
triggerAutoComplete,
getCursorIndex,
searchObj,
formatDocument,
getModel,
getValue,
decorateRanges,
addErrorDiagnostics,
};
},
});
</script>
<style scoped>
#editor {
width: 100%;
height: 78%;
border-radius: 5px;
}
</style>
<style lang="scss">
.logs-query-editor {
.monaco-editor,
.monaco-editor .monaco-editor {
padding: 0px 0px 0px 0px !important;
.editor-widget .suggest-widget {
z-index: 9999;
display: flex !important;
visibility: visible !important;
}
}
}
.highlight-error {
background-color: rgba(255, 0, 0, 0.1);
text-decoration: underline;
text-decoration-color: red;
}
</style>