From e85baca92d48f59eafe32f6af28f8b27f9b140cf Mon Sep 17 00:00:00 2001 From: Dhruv Patel <71836462+DHRUV6029@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:45:44 -0700 Subject: [PATCH] fix: logs timechart required-fields error and URL refresh failure (#13244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #12897 — two defects on the Logs page **Timechart** tab: 1. The error **"Please select required fields to render the chart"** appeared even though the chart should render. 2. **Refreshing the URL** broke the tab entirely: a *"Select \* query is not supported for visualization"* toast plus a permanent **No Data** state. ## Root cause ### 1. False "required fields" error (regression) `convertPanelData()` throws this error when a chart-type panel has empty `x`/`y` fields. The check was introduced in #10305, then **commented out in #11297 specifically because it broke the logs visualize toggle** (the NOTE explaining that was left in the code), and then **re-enabled in #11586**, which reintroduced the bug. The Timechart drives a **custom-query** panel whose axes are populated *asynchronously* from the result schema (`resetFields()` → `result_schema` network call → repopulate). Empty `x`/`y` is therefore a legitimate transient state for this panel — any render that landed inside that window (or after an extraction early-exit) hit the throw and pushed the error into the errors panel. ### 2. URL refresh broken Two ordering problems on page load: - `handleBeforeMount()` restores `logsVisualizeToggle` from the URL **before** the stream selection is restored, so the visualize toggle watcher fired with an empty stream and built literally `select * from "undefined"` → rejected with the SELECT \* toast (confirmed by instrumenting the running app). - Nothing ever re-ran the visualization after URL restoration completed — `loadVisualizeData()` only loads stream fields — so the tab stayed on **No Data** forever. ## The fix | Change | File | What it does | |---|---|---| | Scope the empty-fields guard to builder-mode panels (`!query.customQuery`) | `web/src/utils/dashboard/convertPanelData.ts` | Builder panels keep the protection (the panel editor's own validation also still blocks empty applies); custom-query panels (logs Timechart, alerts preview) are no longer blocked by their transient empty-fields state | | Bail out of the visualize toggle watcher when no stream is selected yet | `web/src/plugins/logs/Index.vue` | Stops the premature page-load run from building `select * from "undefined"` and toasting | | Run the visualization after URL restoration completes | `web/src/plugins/logs/Index.vue` (`setupLogsTab`) | Mirrors the manual-toggle setup via a shared `prepareVisualizeMode()` (quick-mode auto-enable, layout, `customQuery`, VRL copy), restores the saved chart type/config from `visualization_data` (`restoreVisualizationFromUrlOnLoad()`), then triggers the run. Scoped to the visualize toggle only — Search/build/patterns tabs untouched | | Broaden the stream-field readiness check | `web/src/utils/logs/visualizeStreamFields.ts` (new) | Extracted predicate also reloads fields when quick mode is on but `interestingFieldList` hasn't loaded yet — the state that produced SELECT \* mid-restore | ## Verification - **Unit tests (written failing-first against the unfixed code):** guard skips custom-query panels / still throws for builder panels / PromQL exempt; readiness-predicate cases. 75/75 pass across the two touched suites. - **End-to-end (local backend + dev UI + Playwright):** reproduced both symptoms on the unfixed build, then verified on this branch: run → render; chart-type switch (h-bar) → URL refresh → **chart type + config + query restored and chart rendered, zero errors**; VRL-function variant renders; dashboards builder empty-fields apply is still blocked by its validation; build-tab refresh behavior unchanged. - `eslint` clean on all touched files (the one `no-fallthrough` error in `convertPanelData.ts` pre-exists on `main`, untouched line); `vue-tsc` clean. ## Before / After Both recordings follow the identical flow: select stream → `match_all('error')` → open Timechart → switch chart type to horizontal bar → **refresh the URL**. ### Before (`main`) — refresh shows the SELECT \* toast and permanent No Data ![before animation: refresh → error toast → No Data](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/before-refresh-bug.gif) 📹 Full recording: [before-refresh-bug.webm](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/before-refresh-bug.webm) ### After (this branch) — refresh restores chart type, query, and renders ![after animation: refresh → chart type and query restored → chart renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/after-fix.gif) 📹 Full recording: [after-fix-full-flow.webm](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/after-fix-full-flow.webm)
Screenshots — final states and pre-refresh states on both builds Final state after refresh (unfixed build): ![before: refresh breaks with SELECT * toast and No Data](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/03-before-BUG-refresh-toast-and-no-data.png) Final state after refresh (this branch): ![after: refresh restores h-bar chart with no errors](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/06-after-FIXED-refresh-restores-chart.png) Timechart renders before refresh (unfixed build): ![before: timechart renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/01-before-timechart-renders.png) H-bar renders before refresh (unfixed build): ![before: h-bar renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/02-before-hbar-renders.png) Timechart on this branch: ![after: timechart renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/04-after-timechart-renders.png) H-bar on this branch: ![after: h-bar renders](https://raw.githubusercontent.com/DHRUV6029/openobserve/pr-12897-assets/05-after-hbar-renders.png)
--- web/src/plugins/logs/Index.vue | 255 ++++++++++++++---- .../utils/dashboard/convertPanelData.spec.ts | 80 ++++++ web/src/utils/dashboard/convertPanelData.ts | 6 +- .../utils/logs/visualizeStreamFields.spec.ts | 78 ++++++ web/src/utils/logs/visualizeStreamFields.ts | 38 +++ 5 files changed, 398 insertions(+), 59 deletions(-) create mode 100644 web/src/utils/logs/visualizeStreamFields.spec.ts create mode 100644 web/src/utils/logs/visualizeStreamFields.ts diff --git a/web/src/plugins/logs/Index.vue b/web/src/plugins/logs/Index.vue index 6ab10e04da..903023c389 100644 --- a/web/src/plugins/logs/Index.vue +++ b/web/src/plugins/logs/Index.vue @@ -376,6 +376,7 @@ import SearchBar from "@/plugins/logs/SearchBar.vue"; import { type ActivationState, PageType } from "@/ts/interfaces/logs.ts"; import { isWebSocketEnabled, isStreamingEnabled } from "@/utils/zincutils"; import { allSelectionFieldsHaveAlias } from "@/utils/query/visualizationUtils"; +import { shouldReloadStreamFieldsForVisualize } from "@/utils/logs/visualizeStreamFields"; import useAiChat from "@/composables/useAiChat"; import { logsUtils } from "@/composables/useLogs/logsUtils"; import { searchState } from "@/composables/useLogs/searchState"; @@ -958,7 +959,14 @@ export default defineComponent({ // Main method for handling before mount logic async function handleBeforeMount() { if (Object.hasOwn(router.currentRoute.value?.query, "logs_visualize_toggle")) { - searchObj.meta.logsVisualizeToggle = router.currentRoute.value.query.logs_visualize_toggle; + const urlToggle = router.currentRoute.value.query.logs_visualize_toggle; + // Restoring directly onto the Timechart tab: setupLogsTab() will run the + // visualization once fields are ready, so tell the toggle watcher to skip + // the page-load fire it is about to receive from the assignment below. + if (urlToggle === "visualize") { + isInitialVisualizeRestore.value = true; + } + searchObj.meta.logsVisualizeToggle = urlToggle; } // Always setup logs tab on mount @@ -1063,8 +1071,27 @@ export default defineComponent({ await loadPatternsData(); await extractPatternsForCurrentQuery(); } else { - loadVisualizeData(); + await loadVisualizeData(); searchObj.loading = false; + // The visualize toggle watcher bails out during page load because it + // fires before URL restoration completes. Now that the + // stream and its fields are restored, mirror the watcher's setup, + // restore the saved chart type/config from the URL, and run the + // visualization. Scoped to the visualize tab — the build tab loads + // through BuildQueryPage and must not auto-run here. + if ( + searchObj.meta.logsVisualizeToggle === "visualize" && + searchObj.data.stream.selectedStream?.length + ) { + prepareVisualizeMode(); + // Suppress the chart-type watcher while restoring (it would + // trigger a duplicate updateVisualization for the type change). + isRestoringFromUrl.value = true; + restoreVisualizationFromUrlOnLoad(); + await nextTick(); + isRestoringFromUrl.value = false; + handleVisualizeTab(); + } } store.dispatch("logs/setIsInitialized", true); @@ -1727,6 +1754,115 @@ export default defineComponent({ // Used to restore chart type from URL only on first toggle (for shared links) const isFirstBuildToggle = ref(true); + // On page load with the Timechart tab in the URL, handleBeforeMount() sets + // the visualize toggle, which fires the toggle watcher before setupLogsTab() + // has restored the stream and extracted fields. That early fire would build + // a stale `select *` and show a spurious error. setupLogsTab() owns the + // page-load restoration (it calls handleVisualizeTab() once fields are + // ready), so the watcher skips its work exactly once on that initial fire. + const isInitialVisualizeRestore = ref(false); + + // Chart types the logs Timechart supports restoring from a shared URL + const validLogsChartTypes = ["area", "bar", "h-bar", "line", "scatter", "table"]; + + // Shared setup for entering visualize (Timechart) mode. Used by the + // logsVisualizeToggle watcher (manual toggle) and by setupLogsTab on + // page load, so both entry paths behave identically. + function prepareVisualizeMode() { + // Enable quick mode automatically when switching to visualization if: + // 1. SQL mode is disabled OR + // 2. Query is "SELECT * FROM some_stream" (simple select all query) + // 3. Default quick mode config is true + const shouldEnableQuickMode = + !searchObj.meta.sqlMode || isSimpleSelectAllQuery(searchObj.data.query); + + const isQuickModeDisabled = !searchObj.meta.quickMode; + const isQuickModeConfigEnabled = store.state.zoConfig.quick_mode_enabled === true; + + if (shouldEnableQuickMode && isQuickModeDisabled && isQuickModeConfigEnabled) { + searchObj.meta.quickMode = true; + handleQuickModeChange(); + } + + // close field list and splitter + dashboardPanelData.layout.splitter = 0; + dashboardPanelData.layout.showFieldList = false; + + dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex].customQuery = + true; + + // Copy VRL function query if present + if (searchObj.data.tempFunctionContent && searchObj.data.transformType === "function") { + dashboardPanelData.data.queries[ + dashboardPanelData.layout.currentQueryIndex + ].vrlFunctionQuery = searchObj.data.tempFunctionContent; + } else { + dashboardPanelData.data.queries[ + dashboardPanelData.layout.currentQueryIndex + ].vrlFunctionQuery = ""; + } + } + + // Restore the chart type and panel config saved in the URL + // (visualization_data) when the page loads directly on the Timechart tab. + // The visualize toggle watcher normally does this, but on page load it + // bails out before restoring because it fires ahead of URL/stream + // restoration. + function restoreVisualizationFromUrlOnLoad() { + const visualizationDataParam = router.currentRoute.value.query.visualization_data; + if (!visualizationDataParam || typeof visualizationDataParam !== "string") { + return; + } + + let restoredData = null; + try { + restoredData = decodeVisualizationConfig(visualizationDataParam); + } catch (error) { + console.warn("Failed to restore visualization config from URL:", error); + return; + } + if (!restoredData || typeof restoredData !== "object") return; + + if ( + isFirstVisualizationToggle.value && + restoredData.type && + typeof restoredData.type === "string" && + validLogsChartTypes.includes(restoredData.type) + ) { + dashboardPanelData.data.type = restoredData.type; + } + + if (restoredData.config && typeof restoredData.config === "object") { + dashboardPanelData.data.config = { + ...dashboardPanelData.data.config, + connect_nulls: true, + ...restoredData.config, + }; + } + + // The URL restore counts as the first-toggle restoration. + isFirstVisualizationToggle.value = false; + } + + // The effective SQL that visualization runs for the current logs query. + // In SQL mode this is the raw user query; otherwise buildSearch() resolves + // the field list (quick mode fields, or `*` when quick mode is off). + const getEffectiveVisualizeQuery = (): string => { + if (searchObj.meta.sqlMode) { + return searchObj.data.query ?? ""; + } + return buildSearch()?.query?.sql ?? ""; + }; + + // Table charts render the raw query columns, so a bare `SELECT *` is not a + // meaningful table visualization. Histogram-based charts (line/bar/area/ + // scatter) ignore the SELECT columns and render it as a histogram, so this + // only blocks the table chart. Quick mode yields `SELECT ` (not + // select-all), so tables render normally there. + const isSelectStarForTable = (): boolean => + store.state.zoConfig.quick_mode_enabled === true && + isSimpleSelectAllQuery(getEffectiveVisualizeQuery()); + watch( () => [searchObj?.meta?.logsVisualizeToggle], async () => { @@ -1748,40 +1884,24 @@ export default defineComponent({ } if (searchObj.meta.logsVisualizeToggle == "visualize") { - // Enable quick mode automatically when switching to visualization if: - // 1. SQL mode is disabled OR - // 2. Query is "SELECT * FROM some_stream" (simple select all query) - // 3. Default quick mode config is true - const shouldEnableQuickMode = - !searchObj.meta.sqlMode || isSimpleSelectAllQuery(searchObj.data.query); - - const isQuickModeDisabled = !searchObj.meta.quickMode; - const isQuickModeConfigEnabled = store.state.zoConfig.quick_mode_enabled === true; - - if (shouldEnableQuickMode && isQuickModeDisabled && isQuickModeConfigEnabled) { - searchObj.meta.quickMode = true; - handleQuickModeChange(); + // Skip the initial page-load fire (see isInitialVisualizeRestore). + // setupLogsTab() restores the stream, extracts fields, and then runs + // the visualization via handleVisualizeTab(). Running here too would + // race that flow with stale/empty fields and build a spurious + // `select *` (which shows the "not supported" error). Genuine user + // toggles after mount have the flag unset and fall through normally. + if (isInitialVisualizeRestore.value) { + isInitialVisualizeRestore.value = false; + return; } - // close field list and splitter - dashboardPanelData.layout.splitter = 0; - dashboardPanelData.layout.showFieldList = false; - - dashboardPanelData.data.queries[ - dashboardPanelData.layout.currentQueryIndex - ].customQuery = true; - - // Copy VRL function query if present - if (searchObj.data.tempFunctionContent && searchObj.data.transformType === "function") { - dashboardPanelData.data.queries[ - dashboardPanelData.layout.currentQueryIndex - ].vrlFunctionQuery = searchObj.data.tempFunctionContent; - } else { - dashboardPanelData.data.queries[ - dashboardPanelData.layout.currentQueryIndex - ].vrlFunctionQuery = ""; + // Defensive: no stream selected yet — nothing to visualize. + if (!searchObj.data.stream.selectedStream?.length) { + return; } + prepareVisualizeMode(); + // Store current config and chart type to preserve them during rebuild const queryParams = router.currentRoute.value.query; let preservedConfig = null; @@ -1816,7 +1936,6 @@ export default defineComponent({ restoredData.type && typeof restoredData.type === "string" ) { - const validLogsChartTypes = ["area", "bar", "h-bar", "line", "scatter", "table"]; if (validLogsChartTypes.includes(restoredData.type)) { // Valid chart type found - set it and disable auto-selection dashboardPanelData.data.type = restoredData.type; @@ -1835,8 +1954,12 @@ export default defineComponent({ // finished populating interestingFieldList yet. Without fields, // buildSearch() produces SELECT * which is invalid for visualization. if ( - searchObj.data.stream.selectedStream?.length > 0 && - searchObj.data.stream.selectedStreamFields?.length === 0 + shouldReloadStreamFieldsForVisualize({ + selectedStream: searchObj.data.stream.selectedStream, + selectedStreamFields: searchObj.data.stream.selectedStreamFields, + interestingFieldList: searchObj.data.stream.interestingFieldList, + quickMode: searchObj.meta.quickMode, + }) ) { await getStreamList(); await extractFields(); @@ -1848,14 +1971,12 @@ export default defineComponent({ const queryBuild = buildSearch(); logsPageQuery = queryBuild?.query?.sql ?? ""; - // Check if query is SELECT * which is not supported for visualization - if ( - store.state.zoConfig.quick_mode_enabled === true && - isSimpleSelectAllQuery(logsPageQuery) - ) { - showErrorNotification(t("logs.index.selectStarNotSupportedForVisualization")); - return; - } + // NOTE: `SELECT *` is intentionally allowed for histogram-based charts + // (line/bar/area/scatter). They render histogram(_timestamp), count(*), + // which ignores the query's SELECT columns, so `SELECT *` (produced when + // quick mode is off or in SQL mode) is a valid input. The table chart is + // the exception — it renders the raw query columns — and is guarded below + // once the chart type is finalized. // Use conditional auto-selection based on first toggle and URL chart type isRestoringFromUrl.value = true; @@ -1895,6 +2016,13 @@ export default defineComponent({ shouldUseHistogramQuery.value = false; } + // On entry/reload, if the finalized chart type is a table with a + // bare `SELECT *`, surface the error (the table renders raw columns). + if (dashboardPanelData.data.type === "table" && isSelectStarForTable()) { + showErrorNotification(t("logs.index.selectStarNotSupportedForVisualization")); + return; + } + // Only reuse cached search results if the current query matches // the query that produced those results. When the user modifies // the query in Build/Patterns mode and switches to Visualize, @@ -2123,12 +2251,25 @@ export default defineComponent({ watch( () => dashboardPanelData.data.type, - async () => { + async (newType, oldType) => { // Skip processing if we're currently restoring from URL if (isRestoringFromUrl.value) { return; } + // A table chart renders the raw query columns, so a bare `SELECT *` + // (quick mode off / SQL mode) is not a meaningful table visualization. + // Histogram-based charts ignore the SELECT columns, so this only blocks + // the table chart. Surface the error and revert to the previous chart + // type so the raw-`SELECT *` data is never shown. + if (newType === "table" && isSelectStarForTable()) { + showErrorNotification(t("logs.index.selectStarNotSupportedForVisualization")); + if (oldType && oldType !== "table") { + dashboardPanelData.data.type = oldType; + } + return; + } + const currentQuery = dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex].query; @@ -2304,24 +2445,24 @@ export default defineComponent({ // finished populating interestingFieldList yet. Without fields, // buildSearch() produces SELECT * which is invalid for visualization. if ( - searchObj.data.stream.selectedStream?.length > 0 && - searchObj.data.stream.selectedStreamFields?.length === 0 + shouldReloadStreamFieldsForVisualize({ + selectedStream: searchObj.data.stream.selectedStream, + selectedStreamFields: searchObj.data.stream.selectedStreamFields, + interestingFieldList: searchObj.data.stream.interestingFieldList, + quickMode: searchObj.meta.quickMode, + }) ) { await getStreamList(); await extractFields(); } - let logsPageQuery = ""; - - // Build the query regardless of sqlMode - const queryBuild = buildSearch(); - logsPageQuery = queryBuild?.query?.sql ?? ""; - - // Check if query is SELECT * which is not supported for visualization - if ( - store.state.zoConfig.quick_mode_enabled === true && - isSimpleSelectAllQuery(logsPageQuery) - ) { + // Build the query for its side effect (prunes interestingFieldList to + // fields present in the stream). Histogram-based charts ignore the + // SELECT columns (updateVisualization builds their histogram query), + // so `SELECT *` is fine for them. The table chart renders the raw query + // columns, so a bare `SELECT *` there is not a meaningful visualization. + buildSearch(); + if (dashboardPanelData.data.type === "table" && isSelectStarForTable()) { showErrorNotification(t("logs.index.selectStarNotSupportedForVisualization")); return; } diff --git a/web/src/utils/dashboard/convertPanelData.spec.ts b/web/src/utils/dashboard/convertPanelData.spec.ts index 535619a4c8..63ac5bbcf5 100644 --- a/web/src/utils/dashboard/convertPanelData.spec.ts +++ b/web/src/utils/dashboard/convertPanelData.spec.ts @@ -544,6 +544,86 @@ describe("convertPanelData", () => { }); }); + describe("Required fields validation", () => { + const emptyFields = { x: [], y: [], breakdown: [] }; + + it("should not throw for custom-query panels with empty x/y fields (logs Timechart)", async () => { + // The logs Timechart drives a customQuery panel whose x/y fields are + // populated asynchronously — they are legitimately empty during and + // after extraction early-exits. Rendering must not be blocked. + const panelSchema = { + type: "line", + queryType: "sql", + queries: [ + { + query: + "SELECT histogram(_timestamp) AS zo_sql_key, count(*) AS zo_sql_num FROM test GROUP BY zo_sql_key", + customQuery: true, + fields: emptyFields, + }, + ], + }; + + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + ); + + expect(result.chartType).toBe("line"); + }); + + it("should throw for builder-mode panels with empty x/y fields", async () => { + const panelSchema = { + type: "line", + queryType: "sql", + queries: [{ query: "", customQuery: false, fields: emptyFields }], + }; + + await expect( + convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + ), + ).rejects.toThrow("Please select required fields to render the chart"); + }); + + it("should not throw for promql panels with empty x/y fields", async () => { + const panelSchema = { + type: "line", + queryType: "promql", + queries: [{ query: "up", fields: emptyFields }], + }; + + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + ); + + expect(result.chartType).toBe("line"); + }); + }); + describe("Error Handling", () => { it("should handle SQL conversion errors gracefully", async () => { const panelSchema = { diff --git a/web/src/utils/dashboard/convertPanelData.ts b/web/src/utils/dashboard/convertPanelData.ts index 58d44f65b6..83e5c763b2 100644 --- a/web/src/utils/dashboard/convertPanelData.ts +++ b/web/src/utils/dashboard/convertPanelData.ts @@ -56,13 +56,15 @@ export const convertPanelData = async ( case "scatter": case "metric": case "gauge": { - // NOTE: on logs to visualize toggle, it shows below error because breakdown field is not required for all the charts // Skip conversion if no fields are selected in builder mode // (prevents echarts errors like "axis.getAxesOnZeroOf is not a function") - // PromQL queries don't use builder fields, so skip this check for them + // PromQL queries don't use builder fields, so skip this check for them. + // Custom-query panels (e.g. logs Timechart) derive axes from the + // SQL result asynchronously, so empty x/y is a valid transient state there. const query = panelSchema?.queries?.[0]; if ( panelSchema?.queryType !== "promql" && + !query?.customQuery && !query?.fields?.x?.length && !query?.fields?.y?.length ) { diff --git a/web/src/utils/logs/visualizeStreamFields.spec.ts b/web/src/utils/logs/visualizeStreamFields.spec.ts new file mode 100644 index 0000000000..4307baceb5 --- /dev/null +++ b/web/src/utils/logs/visualizeStreamFields.spec.ts @@ -0,0 +1,78 @@ +// 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 . + +import { describe, expect, it } from "vitest"; +import { shouldReloadStreamFieldsForVisualize } from "./visualizeStreamFields"; + +describe("shouldReloadStreamFieldsForVisualize", () => { + it("returns true when stream fields are not loaded yet", () => { + expect( + shouldReloadStreamFieldsForVisualize({ + selectedStream: ["default"], + selectedStreamFields: [], + interestingFieldList: [], + quickMode: false, + }), + ).toBe(true); + }); + + it("returns true in quick mode when interesting fields are not loaded (refresh race)", () => { + // On page reload the visualize toggle can run after selectedStreamFields + // are restored but before interestingFieldList is populated. In quick + // mode buildSearch() would then produce SELECT * and visualization + // rejects it — so fields must be (re)extracted first. + expect( + shouldReloadStreamFieldsForVisualize({ + selectedStream: ["default"], + selectedStreamFields: [{ name: "_timestamp" }], + interestingFieldList: [], + quickMode: true, + }), + ).toBe(true); + }); + + it("returns false when stream and interesting fields are loaded", () => { + expect( + shouldReloadStreamFieldsForVisualize({ + selectedStream: ["default"], + selectedStreamFields: [{ name: "_timestamp" }], + interestingFieldList: ["_timestamp"], + quickMode: true, + }), + ).toBe(false); + }); + + it("returns false when no stream is selected", () => { + expect( + shouldReloadStreamFieldsForVisualize({ + selectedStream: [], + selectedStreamFields: [], + interestingFieldList: [], + quickMode: true, + }), + ).toBe(false); + }); + + it("returns false outside quick mode when stream fields are loaded", () => { + expect( + shouldReloadStreamFieldsForVisualize({ + selectedStream: ["default"], + selectedStreamFields: [{ name: "_timestamp" }], + interestingFieldList: [], + quickMode: false, + }), + ).toBe(false); + }); +}); diff --git a/web/src/utils/logs/visualizeStreamFields.ts b/web/src/utils/logs/visualizeStreamFields.ts new file mode 100644 index 0000000000..8bbcbbf1a0 --- /dev/null +++ b/web/src/utils/logs/visualizeStreamFields.ts @@ -0,0 +1,38 @@ +// 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 . + +/** + * Decides whether stream fields must be (re)extracted before building the + * query for the logs visualization (Timechart) view. + * + * On page reload the visualize toggle can run before async field loading has + * finished. Building the query too early produces SELECT * — which is + * rejected for visualization — and leaves the panel without axis fields. + * Two readiness signals matter: + * - selectedStreamFields: schema fields for the selected stream(s) + * - interestingFieldList: fields used by buildSearch() in quick mode + */ +export const shouldReloadStreamFieldsForVisualize = (state: { + selectedStream: unknown[]; + selectedStreamFields: unknown[]; + interestingFieldList: string[]; + quickMode: boolean; +}): boolean => { + if (!state.selectedStream?.length) return false; + + if (!state.selectedStreamFields?.length) return true; + + return state.quickMode && !state.interestingFieldList?.length; +};