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

📹 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

📹 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):

Final state after refresh (this branch):

Timechart renders before refresh (unfixed build):

H-bar renders before refresh (unfixed build):

Timechart on this branch:

H-bar on this branch:

---
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;
+};