diff --git a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.spec.ts b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.spec.ts index 2a54f4b743..070f945b0a 100644 --- a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.spec.ts +++ b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.spec.ts @@ -39,6 +39,7 @@ import OSelect from "@/lib/forms/Select/OSelect.vue"; import OFormInput from "@/lib/forms/Input/OFormInput.vue"; import OInput from "@/lib/forms/Input/OInput.vue"; import { firstFieldError } from "@/lib/forms/Form/fieldError"; +import streamService from "@/services/stream"; // vi.mock must be hoisted — declared before component import vi.mock("@/services/stream", () => ({ @@ -47,6 +48,15 @@ vi.mock("@/services/stream", () => ({ }, })); +// The stored-value lookup the field-value resolver ends at. Stubbed so the +// resolver tests can assert the composite key it was asked for without an +// IndexedDB in jsdom. useSuggestions imports nothing else from this module. +// vi.hoisted, because vi.mock is lifted above ordinary declarations. +const { getFieldValuesForSuggestion } = vi.hoisted(() => ({ + getFieldValuesForSuggestion: vi.fn(async () => ["ERROR", "INFO"]), +})); +vi.mock("@/composables/useFieldValueStore", () => ({ getFieldValuesForSuggestion })); + vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({ default: { template: '
' }, })); @@ -896,4 +906,47 @@ describe("AnomalyDetectionConfig", () => { expect(ok).toBe(true); }); }); + + // ========================================================================= + // Editor autocomplete wiring. Both halves shipped broken: loadStreamFields + // cleared the field keywords on failure but never SET them on success, and + // the stream context the value resolver keys on was never populated at all. + // Neither was visible from the outside — the editor still opened, just with + // nothing stream-specific in it. + // ========================================================================= + describe("SQL editor autocomplete", () => { + it("feeds the selected stream's fields to the editor", async () => { + (streamService.schema as any).mockResolvedValueOnce({ + data: { + schema: [ + { name: "level", type: "Utf8" }, + { name: "code", type: "Int64" }, + ], + }, + }); + wrapper = mountConfig(); + await flushPromises(); + + const labels = ((wrapper.vm as any).effectiveKeywords ?? []).map((k: any) => k.label); + expect(labels).toContain("level"); + expect(labels).toContain("code"); + }); + + it("resolves field values under the selected stream's key", async () => { + wrapper = mountConfig({ stream_name: "my_stream", stream_type: "logs" }); + await flushPromises(); + + const values = await (wrapper.vm as any).resolveFieldValues("level"); + + expect(getFieldValuesForSuggestion).toHaveBeenCalledWith( + { + org: store.state.selectedOrganization.identifier, + streamType: "logs", + streamName: "my_stream", + }, + "level", + ); + expect(values).toEqual(["ERROR", "INFO"]); + }); + }); }); diff --git a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue index 6e9df0308e..c3ea26abb7 100644 --- a/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue +++ b/web/src/components/anomaly_detection/steps/AnomalyDetectionConfig.vue @@ -788,8 +788,13 @@ export default defineComponent({ // Same completion machinery every other SQL editor in the app uses, so this // one also gets SQL keywords, the O2 functions and the server function // catalog rather than bare field names. - const { effectiveKeywords, effectiveSuggestions, updateFieldKeywords, resolveFieldValues } = - useSqlSuggestions(); + const { + autoCompleteData, + effectiveKeywords, + effectiveSuggestions, + updateFieldKeywords, + resolveFieldValues, + } = useSqlSuggestions(); const numericStreamFields = ref([]); // only numeric types for avg/sum/min/max/pXX const filteredStreamFields = ref([]); const filteredDetectionFields = ref([]); @@ -815,6 +820,13 @@ export default defineComponent({ const loadStreamFields = async () => { const streamName = props.config.stream_name; const streamType = props.config.stream_type; + + // Field VALUES are looked up under "org|streamType|streamName|field", so + // the resolver returns nothing at all until this is set. + autoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? ""; + autoCompleteData.value.streamType = String(streamType ?? ""); + autoCompleteData.value.streamName = String(streamName ?? ""); + if (!streamName || !streamType) { allStreamFields.value = []; updateFieldKeywords([]); @@ -836,6 +848,10 @@ export default defineComponent({ ? schema.uds_schema : schema.schema || schema.fields || []; allStreamFields.value = fieldsArray.map((f: any) => f.name).sort(); + // The two failure branches below already cleared the keywords; without + // this the success branch never set them, so the SQL editor offered + // functions and keywords but not one field of the selected stream. + updateFieldKeywords(fieldsArray); numericStreamFields.value = fieldsArray .filter((f: any) => { const t: string = f.field_type || f.data_type || f.type || ""; diff --git a/web/src/components/dashboards/addPanel/DashboardQueryEditor.spec.ts b/web/src/components/dashboards/addPanel/DashboardQueryEditor.spec.ts index 6d50f00129..ad8373815f 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryEditor.spec.ts +++ b/web/src/components/dashboards/addPanel/DashboardQueryEditor.spec.ts @@ -15,6 +15,7 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; import { mount } from "@vue/test-utils"; +import { reactive } from "vue"; // Mock the zincutils utilities completely vi.mock("@/utils/zincutils", async (importOriginal) => { const actual = (await importOriginal()) as any; @@ -67,6 +68,7 @@ vi.mock("@/components/CodeQueryEditor.vue", () => ({ })); import DashboardQueryEditor from "@/components/dashboards/addPanel/DashboardQueryEditor.vue"; +import useSqlSuggestions from "@/composables/useSuggestions"; import i18n from "@/locales"; import store from "@/test/unit/helpers/store"; import router from "@/test/unit/helpers/router"; @@ -114,7 +116,11 @@ const createMockDashboardPanelData = () => { }; return { - dashboardPanelData: mockData, + // reactive() because the real composable's state is: the component watches + // the active query's stream, and a plain object would never fire the + // watcher — the test would pass or fail for reasons unrelated to the + // component. + dashboardPanelData: reactive(mockData), promqlMode: false, // Make this a direct boolean instead of ref addQuery: vi.fn(() => { mockData.data.queries.push({ @@ -155,6 +161,20 @@ vi.mock("@/composables/usePromqlSuggestions", () => ({ vi.mock("@/composables/useSuggestions", () => ({ default: vi.fn(() => ({ + // Mirrors the real composable's shape. autoCompleteData carries the stream + // context the field-value resolver looks values up under; omitting it here + // made every mount throw once the component started setting it. + autoCompleteData: { + value: { + org: "", + streamType: "", + streamName: "", + query: "", + cursorIndex: 0, + popup: { open: vi.fn(), close: vi.fn() }, + }, + }, + resolveFieldValues: vi.fn(async () => []), autoCompleteKeywords: { value: [] }, autoCompleteSuggestions: { value: [] }, effectiveKeywords: { value: [] }, @@ -410,6 +430,52 @@ describe("DashboardQueryEditor", () => { expect(wrapper.vm.dashboardPanelData.layout.currentQueryIndex).toBe(1); }); + // The field-value resolver looks values up under "org|streamType| + // streamName|field". This panel never set any of the three, so its + // resolver could only ever return [] — a working editor with value + // completion silently absent. Per QUERY, not per panel: each tab has its + // own stream and a stale context would offer the previous tab's values. + const sqlAutoCompleteData = () => + (useSqlSuggestions as any).mock.results.at(-1).value.autoCompleteData.value; + + it("publishes the active query's stream as the field-value lookup context", async () => { + wrapper = createWrapper(); + wrapper.vm.dashboardPanelData.data.queries[0].fields = { + stream: "app_logs", + stream_type: "logs", + }; + await wrapper.vm.$nextTick(); + + expect(sqlAutoCompleteData()).toMatchObject({ + org: "test-org", + streamType: "logs", + streamName: "app_logs", + }); + }); + + it("follows the stream when the user switches query tabs", async () => { + wrapper = createWrapper(); + wrapper.vm.dashboardPanelData.data.queries[0].fields = { + stream: "app_logs", + stream_type: "logs", + }; + wrapper.vm.dashboardPanelData.data.queries.push({ + query: "", + queryType: "sql", + customQuery: true, + fields: { stream: "app_metrics", stream_type: "metrics" }, + }); + await wrapper.vm.$nextTick(); + + wrapper.vm.dashboardPanelData.layout.currentQueryIndex = 1; + await wrapper.vm.$nextTick(); + + expect(sqlAutoCompleteData()).toMatchObject({ + streamType: "metrics", + streamName: "app_metrics", + }); + }); + it("should handle query editor configuration", () => { wrapper = createWrapper(); diff --git a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue index 896f2b5239..e4afb44c1c 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue +++ b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue @@ -646,6 +646,27 @@ export default defineComponent({ { immediate: true }, ); + // Field VALUES are looked up under "org|streamType|streamName|field", so the + // resolver returns nothing at all until this is set. Tracked per QUERY, not + // per panel: each tab has its own stream, and switching tabs must not leave + // the previous tab's values on offer. A multi-stream query is keyed on its + // primary stream — the same one the Fields panel is built from. + watch( + [ + () => dashboardPanelData.layout.currentQueryIndex, + () => + dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields, + ], + () => { + const fields = + dashboardPanelData.data.queries?.[dashboardPanelData.layout.currentQueryIndex]?.fields; + sqlAutoCompleteData.value.org = store.state.selectedOrganization?.identifier ?? ""; + sqlAutoCompleteData.value.streamType = String(fields?.stream_type ?? ""); + sqlAutoCompleteData.value.streamName = String(fields?.stream ?? ""); + }, + { immediate: true, deep: true }, + ); + const removeTab = async (rawIndex: string | number) => { const index = Number(rawIndex); if (dashboardPanelData.layout.currentQueryIndex >= dashboardPanelData.data.queries.length - 1) diff --git a/web/src/composables/useSuggestions.serverCatalog.spec.ts b/web/src/composables/useSuggestions.serverCatalog.spec.ts index 54fc82fc0a..59da9eb325 100644 --- a/web/src/composables/useSuggestions.serverCatalog.spec.ts +++ b/web/src/composables/useSuggestions.serverCatalog.spec.ts @@ -168,10 +168,13 @@ describe("Phase 2 — the server catalog is actually fetched (B4 wiring)", () => // ─── Surfaces that never call getSuggestions ───────────────────────────────── // The SLO form (AddSlo.vue) wires the editor with updateFieldKeywords ONLY: it -// never calls getSuggestions and never sets autoCompleteData.org. Hanging the -// catalog fetch off getSuggestions therefore left that page with the ~26 local -// functions and none of the ~330 from the registry — reported as "many -// functions are not available in typeahead". +// never calls getSuggestions. Hanging the catalog fetch off getSuggestions +// therefore left that page with the ~26 local functions and none of the ~330 +// from the registry — reported as "many functions are not available in +// typeahead". It does now set autoCompleteData.org (for the field-value +// resolver's lookup key), but only once a stream is chosen — so the fallback +// to the store's org still carries the fetch for every surface, including this +// one before a stream is picked. describe("Phase 2 — the catalog loads for surfaces that only set up fields", () => { const SERVER_LIST = [ diff --git a/web/src/utils/query/editorWiring.spec.ts b/web/src/utils/query/editorWiring.spec.ts index beaa7f0f63..e8f90d0911 100644 --- a/web/src/utils/query/editorWiring.spec.ts +++ b/web/src/utils/query/editorWiring.spec.ts @@ -88,6 +88,36 @@ describe("editor wiring — every surface supplies both completion sources", () ).toBe(true); }); + // A resolver that can never resolve anything is the same class of silent gap + // as a missing prop: the wiring test passes, the editor works, and value + // completion just quietly does nothing. resolveFieldValues reads the stored + // values under the composite key "org|streamType|streamName|field", so a + // surface that never sets those three gets [] on every lookup and the + // provider falls straight through to the ordinary function list. + const composableHosts = editorHosts.filter((f) => /useSqlSuggestions\s*\(/.test(f.source)); + + it("finds the surfaces that own a composable (not the pass-through wrappers)", () => { + expect(composableHosts.length).toBeGreaterThan(8); + // QueryEditor.vue and SloExpressionField.vue forward a resolver prop rather + // than owning one; they have no stream context to set and must not be + // required to have any. + expect(composableHosts.map((f) => f.path)).not.toContain("components/QueryEditor.vue"); + }); + + it.each(composableHosts.map((f) => f.path))( + "%s sets the stream context its resolver looks values up under", + (path) => { + const { source } = composableHosts.find((f) => f.path === path)!; + for (const key of ["org", "streamType", "streamName"]) { + expect( + new RegExp(`[Aa]utoCompleteData(\\.value)?\\.${key}\\s*=`).test(source), + `${path} owns a useSqlSuggestions resolver but never sets ` + + `autoCompleteData.${key}, so every field-value lookup returns []`, + ).toBe(true); + } + }, + ); + it.each(editorHosts.map((f) => f.path))("%s does not bind the base keyword list", (path) => { const { source } = editorHosts.find((f) => f.path === path)!; // autoCompleteKeywords is the pre-context list; binding it means field diff --git a/web/src/views/slos/AddSlo.vue b/web/src/views/slos/AddSlo.vue index ec8a268fd9..95a67e7529 100644 --- a/web/src/views/slos/AddSlo.vue +++ b/web/src/views/slos/AddSlo.vue @@ -561,10 +561,22 @@ const streamFieldNames = computed(() => streamFields.value.map((f) => f.value)); // builds the field list (dropping the timestamp column) and merges it with // the SQL keyword and function sets. Nothing about autocomplete is // reimplemented here. -const { effectiveKeywords, effectiveSuggestions, updateFieldKeywords, resolveFieldValues } = - useSqlSuggestions(); +const { + autoCompleteData, + effectiveKeywords, + effectiveSuggestions, + updateFieldKeywords, + resolveFieldValues, +} = useSqlSuggestions(); async function loadStreamFields(streamName: string) { + // Field VALUES are looked up under "org|streamType|streamName|field", so the + // resolver returns nothing at all until this is set. Cleared alongside the + // field list so a de-selected stream cannot keep offering its old values. + autoCompleteData.value.org = org.value ?? ""; + autoCompleteData.value.streamType = String(form.config.stream_type ?? ""); + autoCompleteData.value.streamName = streamName; + if (!streamName || !form.config.stream_type) { streamFields.value = []; updateFieldKeywords([]);