diff --git a/web/package.json b/web/package.json index 652d362f00..cbd381f874 100644 --- a/web/package.json +++ b/web/package.json @@ -45,6 +45,7 @@ "@openobserve/browser-rum": "0.4.1", "@openobserve/node-sql-parser": "^0.1.5", "@openobserve/rrweb-player": "^0.2.1", + "@prometheus-io/codemirror-promql": "^0.311.3", "@rudderstack/analytics-js": "3.6.0", "@tanstack/vue-form": "^1.29.1", "@tanstack/vue-table": "^8.21.3", diff --git a/web/src/components/CodeQueryEditor.vue b/web/src/components/CodeQueryEditor.vue index 875305e751..73cc9ffaa1 100644 --- a/web/src/components/CodeQueryEditor.vue +++ b/web/src/components/CodeQueryEditor.vue @@ -113,6 +113,7 @@ import { wantsNumericColumn, rankNumericFieldsFirst, } from "@/utils/query/editorProviders"; +import { findDoubleQuoteIssues } from "@/utils/query/doubleQuoteWarnings"; import { loadPromqlLanguage } from "@/utils/query/promqlLanguageDefinition"; import { useStore } from "vuex"; @@ -948,13 +949,11 @@ export default defineComponent({ }; const disableSuggestionPopup = () => { - const escEvent = new KeyboardEvent("keydown", { - keyCode: 27, - code: "Escape", - key: "Escape", - bubbles: true, - }); - editorRef.value.dispatchEvent(escEvent); + // monaco's own command, which this file already uses elsewhere. The + // synthetic Escape this replaced was a guess about monaco's internal key + // handling that nothing verified, and it bubbled out of the editor to + // anything else listening for Escape. + editorObj?.trigger("disableSuggestionPopup", "hideSuggestWidget", {}); }; const formatDocument = async () => { @@ -1082,43 +1081,23 @@ export default defineComponent({ const model = editorObj.getModel(); if (!model) return; + // Deciding WHAT is wrong lives in utils/query/doubleQuoteWarnings.ts, + // where it is comment- and string-aware and can be tested without an + // editor. What is left here is the monaco half: offsets to positions, + // positions to markers. const text = model.getValue(); - const markers: any[] = []; - - // Two patterns are flagged — both only within value position (after a - // SQL comparison/membership operator). FROM "table" / SELECT "col" are - // intentionally NOT matched. - // - // Pattern A — fully double-quoted: field = "value" - // Pattern B — mismatched quotes: field = "value' or field = 'value" - // Capture group 1: the invalid quoted token - const regex = - /(?:NOT\s+LIKE|NOT\s+IN\s*\(|!=|<>|>=|<=|=|>|<|LIKE|IN\s*\()\s*("[^'"]*'|'[^'"]*"|"[^"]*")/gi; - - let match; - while ((match = regex.exec(text)) !== null) { - const quotedStr = match[1]; // the invalid quoted token - const startOffset = match.index + match[0].length - quotedStr.length; - const endOffset = startOffset + quotedStr.length; - - const startPos = model.getPositionAt(startOffset); - const endPos = model.getPositionAt(endOffset); - - const isMixed = - (quotedStr.startsWith('"') && quotedStr.endsWith("'")) || - (quotedStr.startsWith("'") && quotedStr.endsWith('"')); - - markers.push({ + const markers = findDoubleQuoteIssues(text).map((issue) => { + const startPos = model.getPositionAt(issue.startOffset); + const endPos = model.getPositionAt(issue.endOffset); + return { severity: monaco.MarkerSeverity.Warning, startLineNumber: startPos.lineNumber, startColumn: startPos.column, endLineNumber: endPos.lineNumber, endColumn: endPos.column, - message: isMixed - ? "Mismatched quotes. Use matching single quotes for string values." - : "Double quotes are not valid for string values. Use single quotes instead.", - }); - } + message: issue.message, + }; + }); monaco.editor.setModelMarkers(model, "dq-validation", markers); }; diff --git a/web/src/composables/usePromqlSuggestions.ts b/web/src/composables/usePromqlSuggestions.ts index b56116a27d..7cc88e41e8 100644 --- a/web/src/composables/usePromqlSuggestions.ts +++ b/web/src/composables/usePromqlSuggestions.ts @@ -1,5 +1,7 @@ import searchService from "@/services/search"; import { nextTick, ref } from "vue"; +import { PROMQL_CATALOG } from "@/utils/query/promqlCompletion"; +import { SORT_LANE } from "@/utils/query/sqlCompletion"; import { useStore } from "vuex"; const usePromqlSuggestions = () => { @@ -19,7 +21,10 @@ const usePromqlSuggestions = () => { }, }); const store = useStore(); - const autoCompletePromqlKeywords: any = ref([]); + // Seeded, not empty: this list is otherwise only filled by getSuggestions, + // which runs on a query update — so Ctrl+Space on a freshly opened PromQL + // editor offered nothing at all until the user typed a character. + const autoCompletePromqlKeywords: any = ref([...PROMQL_CATALOG]); const metricKeywords: any = ref([]); const parsePromQlQuery = (query: string) => { @@ -163,7 +168,9 @@ const usePromqlSuggestions = () => { const metricName = parsedQuery?.metricName || ""; const labels = parsedQuery?.label?.labels || {}; - autoCompletePromqlKeywords.value = []; + // NOT cleared here. Two of the branches below return without + // refilling it, and an empty list is never the better answer: the + // catalog is the floor. const startISOTimestamp: any = autoCompleteData.value.dateTime.startTime; const endISOTimestamp: any = autoCompleteData.value.dateTime.endTime; // import search service and call search.get_promql_series @@ -213,7 +220,9 @@ const usePromqlSuggestions = () => { .finally(() => { if (labelSuggestions) updatePromqlKeywords(labelSuggestions); else { - autoCompletePromqlKeywords.value = []; + // Back to the catalog rather than to nothing — the labels are + // unavailable, the language is not. + updatePromqlKeywords([]); autoCompleteData.value.popup.close(""); } }); @@ -250,20 +259,12 @@ const usePromqlSuggestions = () => { }; const updatePromqlKeywords = async (data: any[]) => { - autoCompletePromqlKeywords.value = []; - const functions = ["sum", "avg_over_time", "rate", "avg", "max", "topk", "histogram_quantile"]; - if (!data.length) { - functions.forEach((fun) => { - autoCompletePromqlKeywords.value.push({ - label: fun, - kind: "Function", - insertText: fun, - }); - }); - autoCompletePromqlKeywords.value.push(...metricKeywords.value); - } else { - autoCompletePromqlKeywords.value.push(...data); - } + // A caller with something contextual to show — label names, label values — + // replaces the list outright. Otherwise: the catalog, plus this org's + // metrics, which sort above it. + autoCompletePromqlKeywords.value = data.length + ? [...data] + : [...PROMQL_CATALOG, ...metricKeywords.value]; await nextTick(); autoCompleteData.value.popup.open(""); @@ -276,6 +277,9 @@ const usePromqlSuggestions = () => { label: metric.label + (metric.type ? `(${metric.type})` : ""), kind: "Variable", insertText: metric.label, + // The field lane. A metric is what the user came to type; behind 107 + // catalog entries is the same as not offering it. + sortText: SORT_LANE.field + metric.label, }); }); }; diff --git a/web/src/utils/query/doubleQuoteWarnings.ts b/web/src/utils/query/doubleQuoteWarnings.ts new file mode 100644 index 0000000000..62a9dd76b2 --- /dev/null +++ b/web/src/utils/query/doubleQuoteWarnings.ts @@ -0,0 +1,146 @@ +// Copyright 2026 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 . + +/** + * Where a query uses double quotes for a string VALUE. + * + * `WHERE level = "error"` parses as a comparison against a COLUMN named + * `error`, which usually exists nowhere, so the query returns nothing and looks + * like missing data. Worth a warning — but the scan used to run over the raw + * text, so it also warned about `-- level = "error"` in a comment and about the + * legal literal `'he said "hi"'`. A warning on valid SQL teaches people to + * ignore the squiggle, including the times it is right. + * + * Pure on purpose: the editor owns the monaco half (offsets to positions, + * positions to markers), and this owns the decision, which is string in, + * offsets out and can be tested as such. + */ + +export interface DoubleQuoteIssue { + /** Index of the opening quote in the ORIGINAL text. */ + startOffset: number; + /** Index one past the closing quote. */ + endOffset: number; + message: string; +} + +const MESSAGE_DOUBLE = "Double quotes are not valid for string values. Use single quotes instead."; +const MESSAGE_MIXED = "Mismatched quotes. Use matching single quotes for string values."; + +/** + * Blank out everything that is not executable SQL, preserving every offset. + * + * Line comments, block comments and the INTERIOR of single-quoted strings + * become spaces. String delimiters are deliberately kept: `a = "x'` is a + * mismatched pair that has to stay visible to be reported, and it is only the + * text between quotes that must stop matching. + */ +const maskNonCode = (text: string): string => { + const out = text.split(""); + let i = 0; + + const blank = (index: number) => { + // Newlines survive so line/column mapping downstream stays correct. + if (out[index] !== "\n") out[index] = " "; + }; + + while (i < text.length) { + const ch = text[i]; + const next = text[i + 1]; + + if (ch === "-" && next === "-") { + while (i < text.length && text[i] !== "\n") blank(i++); + continue; + } + + if (ch === "/" && next === "*") { + blank(i++); + blank(i++); + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) blank(i++); + // An unterminated block comment swallows the rest, as SQL does. + if (i < text.length) { + blank(i++); + blank(i++); + } + continue; + } + + if (ch === "'") { + // Find the terminator first. An UNTERMINATED literal is left exactly as + // written, because `a = 'x"` is not a string containing a quote — it is + // the mismatched pair this scan exists to report, and masking its + // interior would hide the `"` that makes it reportable. + let end = i + 1; + while (end < text.length) { + if (text[end] === "'") { + // A doubled quote is an escaped quote, not the end of the literal. + if (text[end + 1] === "'") { + end += 2; + continue; + } + break; + } + end++; + } + if (end >= text.length) break; + + for (let j = i + 1; j < end; j++) blank(j); + i = end + 1; // both delimiters survive + continue; + } + + i++; + } + + return out.join(""); +}; + +/** + * Two shapes are reported, both only in value position — after a comparison or + * membership operator. `FROM "table"` and `SELECT "col"` are correct quoting of + * an identifier and are never matched. + * + * fully double-quoted: field = "value" + * mismatched: field = "value' or field = 'value" + */ +const VALUE_QUOTE_REGEX = + /(?:NOT\s+LIKE|NOT\s+IN\s*\(|!=|<>|>=|<=|=|>|<|LIKE|IN\s*\()\s*("[^'"]*'|'[^'"]*"|"[^"]*")/gi; + +export const findDoubleQuoteIssues = (text: string): DoubleQuoteIssue[] => { + if (!text) return []; + + // Matched against the masked copy; offsets index the original, which the + // masking preserves character for character. + const masked = maskNonCode(text); + const issues: DoubleQuoteIssue[] = []; + const regex = new RegExp(VALUE_QUOTE_REGEX.source, VALUE_QUOTE_REGEX.flags); + + let match: RegExpExecArray | null; + while ((match = regex.exec(masked)) !== null) { + const quoted = match[1]; + const startOffset = match.index + match[0].length - quoted.length; + const isMixed = + (quoted.startsWith('"') && quoted.endsWith("'")) || + (quoted.startsWith("'") && quoted.endsWith('"')); + + issues.push({ + startOffset, + endOffset: startOffset + quoted.length, + message: isMixed ? MESSAGE_MIXED : MESSAGE_DOUBLE, + }); + } + + return issues; +}; diff --git a/web/src/utils/query/promqlCompletion.ts b/web/src/utils/query/promqlCompletion.ts new file mode 100644 index 0000000000..fed02e4a64 --- /dev/null +++ b/web/src/utils/query/promqlCompletion.ts @@ -0,0 +1,135 @@ +// Copyright 2026 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 . + +/** + * The PromQL completion catalog. + * + * PromQL completion used to be seven names in an array literal — sum, + * avg_over_time, rate, avg, max, topk, histogram_quantile — so `irate`, + * `increase`, `label_replace`, most of the *_over_time family and every + * grouping modifier were simply undiscoverable. + * + * The list is not hand-maintained here either. It is derived from the term + * tables in @prometheus-io/codemirror-promql: Prometheus's own vocabulary, + * carrying a one-line description per term, and versioned with the upstream + * package rather than with our memory of what Prometheus added last release. + * monaco-promql (already a dependency, already used here for the tokenizer) + * builds its keyword list from exactly these tables. + * + * Registering monaco-promql's own completion provider would have been the + * shorter path and is the wrong one: it labels EVERY term `Keyword`, so + * functions get the keyword glyph — the icon complaint this workstream started + * from, reintroduced in a second language. + */ + +import { + aggregateOpModifierTerms, + aggregateOpTerms, + atModifierTerms, + binOpModifierTerms, + binOpTerms, + functionIdentifierTerms, +} from "@prometheus-io/codemirror-promql/dist/cjs/complete/promql.terms"; +import { SORT_LANE, type CompletionKindName } from "./sqlCompletion"; + +/** Shaped like SqlCompletionEntry so both languages feed one item builder. */ +export interface PromqlCompletionEntry { + name: string; + label: string; + kind: CompletionKindName; + insertText: string; + detail?: string; + documentation?: string; + sortText?: string; +} + +/** One upstream term. `info` is the human description; `detail` the group. */ +interface PromqlTerm { + label: string; + detail?: string; + info?: string; + type?: string; +} + +/** + * Insertion is the label and nothing else. + * + * Upstream's reasoning, which holds: some PromQL keywords require parentheses, + * some forbid them, some are optional, and the term tables carry no signature + * to tell them apart. `rate(` would help; `by(` would be wrong; nothing here + * can distinguish the two. The at-modifiers are LABELLED `start()` and `end()`, + * so they insert their own parens without us inventing any. + */ +const toEntry = ( + term: PromqlTerm, + kind: CompletionKindName, + lane: string, + fallbackDetail: string, +): PromqlCompletionEntry => ({ + name: term.label, + label: term.label, + kind, + insertText: term.label, + // Upstream fills `detail` for functions and aggregations but leaves the + // modifiers blank; an entry with no detail renders a bare name beside + // entries that explain themselves. + detail: term.detail || fallbackDetail, + ...(term.info ? { documentation: term.info } : {}), + sortText: lane + term.label, +}); + +const byLabel = (a: PromqlCompletionEntry, b: PromqlCompletionEntry) => + a.label < b.label ? -1 : a.label > b.label ? 1 : 0; + +/** + * Aggregation operators and function identifiers — everything callable. + * + * Both groups are Function: `sum` and `rate` are the same kind of thing to + * someone typing, whatever Prometheus's grammar calls them. + */ +export const PROMQL_FUNCTIONS: PromqlCompletionEntry[] = [ + ...(aggregateOpTerms as PromqlTerm[]).map((t) => + toEntry(t, "Function", SORT_LANE.function, "aggregation"), + ), + ...(functionIdentifierTerms as PromqlTerm[]).map((t) => + toEntry(t, "Function", SORT_LANE.function, "function"), + ), +].sort(byLabel); + +/** + * Modifiers and word-shaped operators. + * + * The symbolic binary operators (+, -, ==, =~ …) are dropped: they are typed, + * not completed, and a dropdown of punctuation is noise. `and`, `or`, `unless` + * and `atan2` are words, so they stay. + */ +export const PROMQL_KEYWORDS: PromqlCompletionEntry[] = [ + ...(aggregateOpModifierTerms as PromqlTerm[]), + ...(binOpModifierTerms as PromqlTerm[]), + ...(atModifierTerms as PromqlTerm[]), + ...(binOpTerms as PromqlTerm[]).filter((t) => /^[a-z_][a-z0-9_]*$/i.test(t.label)), +] + .map((t) => toEntry(t, "Keyword", SORT_LANE.clause, "modifier")) + .sort(byLabel); + +/** + * What the editor receives. Deduplicated by label, functions before modifiers. + */ +export const PROMQL_CATALOG: PromqlCompletionEntry[] = (() => { + const seen = new Set(); + return [...PROMQL_FUNCTIONS, ...PROMQL_KEYWORDS].filter((e) => + seen.has(e.label) ? false : (seen.add(e.label), true), + ); +})();