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([]);