diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 22234a5456..01938d16b5 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -103,6 +103,7 @@ along with this program. If not, see . /> { return import("@/components/dashboards/panels/ChartRenderer.vue"); @@ -584,6 +589,7 @@ export default defineComponent({ // stores the converted data which can be directly used for rendering different types of panels const panelData: any = shallowRef({}); // holds the data to render the panel after getting data from the api based on panel config const chartPanelRef: any = ref(null); // holds the ref to the whole div + const chartRendererRef: any = ref(null); // holds the ref to the ChartRenderer component const selectedAnnotationData: any = ref([]); const drilldownPopUpRef: any = ref(null); @@ -706,6 +712,48 @@ export default defineComponent({ return filtered; }); + // The latest metadata chunk's time_offset.start_time (┬╡s) marks the left boundary + // of data received so far. Also pull the full query start so the overlay function + // can compute the fraction against the complete query range, not just received data. + // Direction-aware boundary for the streaming overlay. + // + // - RTL (chunks arrive newest-first): fresh data is to the RIGHT of + // `boundaryTime`. Overlay covers the stale LEFT portion. boundaryTime + // is the LAST entry's time_offset.start_time (earliest received). + // - LTR (chunks arrive earliest-first): fresh data is to the LEFT of + // `boundaryTime`. Overlay covers the stale RIGHT portion. boundaryTime + // is the LAST entry's time_offset.end_time (latest received). + // + // Times are in microseconds to match resultMetaData; convert to ms downstream. + const overlayBoundaryInfo = computed(() => { + const resultMeta = resultMetaData.value?.[0]; + const queryStart = Number(metadata.value?.queries?.[0]?.startTime ?? 0); + const queryEnd = Number(metadata.value?.queries?.[0]?.endTime ?? 0); + // PromQL always streams LTR (oldest timestamps first), no time_offset. + const isPromQL = panelSchema.value?.queryType === "promql"; + + if (isPromQL || !resultMeta?.length) { + return { boundaryTime: 0, queryStart, queryEnd, isLTR: isPromQL }; + } + + const firstEntry = resultMeta[0]; + const lastEntry = resultMeta[resultMeta.length - 1]; + + const isLTR = + detectChunkingDirection( + firstEntry?.time_offset?.start_time ?? 0, + firstEntry?.time_offset?.end_time ?? 0, + queryStart, + queryEnd, + ) ?? false; + + const boundaryTime = isLTR + ? (lastEntry?.time_offset?.end_time ?? 0) + : (lastEntry?.time_offset?.start_time ?? 0); + + return { boundaryTime, queryStart, queryEnd, isLTR }; + }); + // need tableRendererRef to access downloadTableAsCSV method const tableRendererRef: any = ref(null); @@ -833,13 +881,13 @@ export default defineComponent({ annotationPopupRef.value = null; tableRendererRef.value = null; }); - const convertPanelDataCommon = async () => { + const convertPanelDataCommon = async (applyOverlay = false) => { if ( !errorDetail?.value?.message && validatePanelData?.value?.length === 0 ) { try { - panelData.value = await convertPanelData( + const result = await convertPanelData( panelSchema.value, filteredData.value, store, @@ -852,6 +900,118 @@ export default defineComponent({ loading.value, ); + // Apply overlay BEFORE assigning to panelData.value. + // This ensures a single watcher trigger with the overlaid options, + // avoiding the double-setOption issue (one without graphic, one with). + // Even without old data, overlay adds phantom anchor points so the + // first streaming chunk doesn't fill the entire chart width. + if (applyOverlay) { + const hasFullOverlay = + previousOptionsSnapshot && + isOverlayEligible(panelSchema.value, previousOptionsSnapshot); + + // Pass container dimensions so the overlay can calculate + // graphic width/height and barMaxWidth for bar charts. + const containerEl = chartPanelRef.value; + const containerSize = containerEl + ? { + width: containerEl.clientWidth, + height: containerEl.clientHeight, + } + : undefined; + const boundaryInfo = overlayBoundaryInfo.value; + // Resolve the primary theme color using the same priority as App.vue: + // 1. Vuex tempThemeColors (live preview) + // 2. localStorage saved color + // 3. Org settings color + // 4. Default theme color from store + const _themeMode = store.state.theme === "dark" ? "dark" : "light"; + const primaryColor: string = + (_themeMode === "dark" + ? store.state.tempThemeColors?.dark + : store.state.tempThemeColors?.light) || + window.localStorage.getItem( + _themeMode === "dark" ? "customDarkColor" : "customLightColor", + ) || + (_themeMode === "dark" + ? store.state?.organizationData?.organizationSettings + ?.dark_mode_theme_color + : store.state?.organizationData?.organizationSettings + ?.light_mode_theme_color) || + (_themeMode === "dark" + ? store.state.defaultThemeColors?.dark + : store.state.defaultThemeColors?.light) || + "#3F7994"; + const meta = resultMetaData.value?.[0]?.[0]; + // SQL: histogram_interval in seconds. PromQL: step in µs. + const histogramIntervalMs = meta?.histogram_interval + ? meta.histogram_interval * 1000 + : meta?.step + ? meta.step / 1000 + : 0; + + // For PromQL (no time_offset), derive boundaryTime from the + // last timestamp in the new series data (LTR: fresh edge). + // Convert ms → µs to match the boundaryTime convention. + let { boundaryTime } = boundaryInfo; + if (!boundaryTime && boundaryInfo.isLTR && result.options?.series) { + for (const s of result.options.series) { + if (!s.name || !Array.isArray(s.data) || !s.data.length) + continue; + const lastPt = s.data[s.data.length - 1]; + if (!Array.isArray(lastPt)) continue; + const t = + typeof lastPt[0] === "number" + ? lastPt[0] + : new Date(lastPt[0]).getTime(); + if (!isNaN(t) && t * 1000 > boundaryTime) { + boundaryTime = t * 1000; // ms → µs + } + } + } + + // Refresh _gridRect from the live chart so the overlay tracks + // the current grid dimensions. The snapshot captured at stream + // start can be stale if y-axis labels grew wider (larger values) + // or if bottom spacing changed during streaming. + if (hasFullOverlay && previousOptionsSnapshot) { + try { + const chartInstance = chartRendererRef.value?.chart; + if (chartInstance) { + const gridModel = chartInstance + ?.getModel() + ?.getComponent("grid"); + const freshRect = + gridModel?.coordinateSystem?.getRect(); + if (freshRect) { + previousOptionsSnapshot._gridRect = { + x: freshRect.x, + y: freshRect.y, + width: freshRect.width, + height: freshRect.height, + }; + } + } + } catch { + // Keep existing _gridRect if chart isn't ready + } + } + + result.options = overlayNewDataOnOldOptions( + hasFullOverlay ? previousOptionsSnapshot : null, + result.options, + containerSize, + hasFullOverlay ? boundaryTime : undefined, + boundaryInfo.queryStart, + boundaryInfo.queryEnd, + boundaryInfo.isLTR, + primaryColor, + histogramIntervalMs, + ); + } + + panelData.value = result; + limitNumberOfSeriesWarningMessage.value = panelData.value?.extras?.limitNumberOfSeriesWarningMessage ?? ""; @@ -884,16 +1044,15 @@ export default defineComponent({ } }; - // Track if we've rendered the first chunk with actual data - let hasRenderedFirstDataChunk = ref(false); + // --- Streaming overlay state --- + // Snapshot of old panelData.options taken when streaming starts (for refresh overlay). + // Immutable once set — each chunk overlays against the same original. + let previousOptionsSnapshot: any = null; - // Create a throttled version for streaming updates (350ms throttle) - // Chunks arrive ~300-400ms apart, so 350ms ensures updates every 2-3 chunks - // This prevents excessive re-renders while showing progressive updates - const convertPanelDataThrottled = throttle(convertPanelDataCommon, 350, { - leading: true, // Call immediately on first invocation - trailing: true, // Ensure final call after throttle period - }); + // Guard flag to prevent double final render when the stream ends with a + // simultaneous data change. Both watch([data,...]) and watch(loading) fire + // in the same tick — this ensures only the first one executes the render. + let streamEndRenderPending = false; // Watch for panel schema changes to re-convert panel data watch( @@ -970,31 +1129,108 @@ export default defineComponent({ }; // Check if this is the first chunk with actual data + // SQL queries have data.value[0] as an array of hits (not an object with .result) + // PromQL queries have data.value[0] as an object with .result property const hasData = - data.value?.length > 0 && data.value[0]?.result?.length > 0; + data.value?.length > 0 && + (data.value[0]?.result?.length > 0 || + (Array.isArray(data.value[0]) && data.value[0].length > 0)); - // Use throttled version during loading (streaming), immediate version when complete - // This prevents excessive re-renders during PromQL data streaming if (loading.value) { - // First chunk with actual data: render immediately! - if (hasData && !hasRenderedFirstDataChunk.value) { - hasRenderedFirstDataChunk.value = true; - await convertPanelDataCommon(); - } else { - // Subsequent chunks: throttle to reduce re-render frequency - await convertPanelDataThrottled(); + // ---- STREAMING (chunks arriving) ---- + // Every chunk overlays against previousOptionsSnapshot (captured + // once in the loading watcher when streaming starts), or falls back + // to phantom anchors when no snapshot is available. + if (hasData) { + await convertPanelDataCommon(true); } + // else: no data yet → skip conversion → loading bar shown } else { - // Loading complete: immediate final render with full data - // Cancel any pending throttled calls and render immediately - convertPanelDataThrottled.cancel(); - hasRenderedFirstDataChunk.value = false; // Reset for next query + // ---- LOADING COMPLETE ---- + // Skip refresh-reset firings: when the executor sets state.data = [] + // at the start of a new query, this watcher fires with hasData=false + // while loading hasn't yet been flipped to true. If we converted here + // we'd assign an empty result to panelData.value, destroying the + // previous chart before the streaming first-chunk can snapshot it + // for the overlay. True zero-result completions are handled by the + // watch(loading, ...) handler below. + if (!hasData && panelData.value?.options?.series?.length > 0) { + return; + } + + // Claim the final render before awaiting so that watch(loading), + // which fires in the same tick, sees the flag and skips its render. + streamEndRenderPending = true; + + // Final render with FULL data — no overlay needed (new data is complete) await convertPanelDataCommon(); + + streamEndRenderPending = false; + + // Clear streaming state — old snapshot no longer needed + if (previousOptionsSnapshot) { + previousOptionsSnapshot = null; + } } }, { deep: true }, ); + // Watch loading to bookend each streaming session. + // Start (false→true): snapshot the current chart for the overlay. + // Every chunk overlays against this same original — the snapshot is + // immutable from here until the stream ends. + // End (true→false): do the final render without overlay (unless the + // data watcher already claimed it via streamEndRenderPending) and + // clear the snapshot so the next stream captures fresh. + watch(loading, async (newLoading, oldLoading) => { + if (oldLoading === false && newLoading === true) { + const hasOldChart = panelData.value?.options?.series?.length > 0; + if (hasOldChart) { + previousOptionsSnapshot = JSON.parse( + JSON.stringify(panelData.value.options), + ); + previousOptionsSnapshot._chartType = panelSchema.value?.type; + previousOptionsSnapshot._queryCount = + panelSchema.value?.queries?.length; + + // Capture actual grid pixel rect from the ECharts instance. + // With containLabel: true, the actual plot area differs from raw grid config. + // WARNING: getModel().getComponent() is an ECharts internal API, not public. + // Validated against echarts 5.6.0. Revisit if ECharts is upgraded. + try { + const chartInstance = chartRendererRef.value?.chart; + if (chartInstance) { + const gridModel = chartInstance?.getModel()?.getComponent("grid"); + const gridRect = gridModel?.coordinateSystem?.getRect(); + if (gridRect) { + previousOptionsSnapshot._gridRect = { + x: gridRect.x, + y: gridRect.y, + width: gridRect.width, + height: gridRect.height, + }; + } + } + } catch { + // Ignore — will fall back to grid config values in overlay + } + } + } else if (oldLoading === true && newLoading === false) { + // If the data watcher's else-branch already claimed the final render + // (simultaneous data + loading change in the same tick), skip here. + if (streamEndRenderPending) return; + + // Final render with complete data — no overlay + await convertPanelDataCommon(); + + // Clear streaming state + if (previousOptionsSnapshot) { + previousOptionsSnapshot = null; + } + } + }); + const checkIfPanelIsTimeSeries = computed(() => { return panelData.value?.extras?.isTimeSeries; }); @@ -1138,10 +1374,7 @@ export default defineComponent({ case "metric": { return ( data.value[0]?.length > 1 || - yAlias.every( - (y: any) => - getDataValue(firstRow, y) != null, - ) + yAlias.every((y: any) => getDataValue(firstRow, y) != null) ); } case "heatmap": { @@ -1189,21 +1422,27 @@ export default defineComponent({ ) { return ""; } + // A rendered chart is already on screen — suppress "No Data" even if + // the raw data buffer is momentarily empty. This prevents a flicker on + // refresh where the executor resets state.data = [] before loading + // flips to true, transiently firing this computed with empty data. + if (panelData.value?.options?.series?.length > 0) { + return ""; + } // Check if the queryType is 'promql' - else if (panelSchema.value?.queryType == "promql") { + if (panelSchema.value?.queryType == "promql") { // Check if the 'filteredData' array has elements and every item has a non-empty 'result' array return filteredData.value?.length && filteredData.value.some((item: any) => item?.result?.length) ? "" // Return an empty string if there is data : "No Data"; // Return "No Data" if there is no data - } else { - // The queryType is not 'promql' - return data.value.length && - data.value[0]?.length && - handleNoData(panelSchema.value.type) - ? "" - : "No Data"; // Return "No Data" if the 'data' array is empty, otherwise return an empty string } + // The queryType is not 'promql' + return data.value.length && + data.value[0]?.length && + handleNoData(panelSchema.value.type) + ? "" + : "No Data"; // Return "No Data" if the 'data' array is empty, otherwise return an empty string }); // when the error changes, emit the error @@ -1307,6 +1546,7 @@ export default defineComponent({ return { store, chartPanelRef, + chartRendererRef, data, loading, searchRequestTraceIds, diff --git a/web/src/components/dashboards/panels/ChartRenderer.vue b/web/src/components/dashboards/panels/ChartRenderer.vue index 5662c032cc..e1865a60f7 100644 --- a/web/src/components/dashboards/panels/ChartRenderer.vue +++ b/web/src/components/dashboards/panels/ChartRenderer.vue @@ -109,6 +109,7 @@ import { DataZoomComponent, MarkLineComponent, MarkAreaComponent, + GraphicComponent, } from "echarts/components"; import { LabelLayout, UniversalTransition } from "echarts/features"; import { CanvasRenderer, SVGRenderer } from "echarts/renderers"; @@ -136,6 +137,7 @@ import type { DataZoomComponentOption, MarkLineComponentOption, MarkAreaComponentOption, + GraphicComponentOption, } from "echarts/components"; type ECOption = ComposeOption< @@ -159,6 +161,7 @@ type ECOption = ComposeOption< | DataZoomComponentOption | MarkLineComponentOption | MarkAreaComponentOption + | GraphicComponentOption >; echarts.use([ @@ -173,6 +176,7 @@ echarts.use([ DataZoomComponent, MarkLineComponent, MarkAreaComponent, + GraphicComponent, BarChart, LineChart, CustomChart, diff --git a/web/src/composables/dashboard/usePanelPromQLExecutor.ts b/web/src/composables/dashboard/usePanelPromQLExecutor.ts index c0a5db9bb1..f084772942 100644 --- a/web/src/composables/dashboard/usePanelPromQLExecutor.ts +++ b/web/src/composables/dashboard/usePanelPromQLExecutor.ts @@ -185,6 +185,16 @@ export const usePanelPromQLExecutor = (ctx: { state.loadingProgressPercentage = res?.content?.percent ?? 0; state.isPartialData = true; } + if (res?.type === "promql_metadata") { + // Store PromQL metadata (step in µs, trace_id, etc.) + if (!state.resultMetaData[queryIndex]) { + state.resultMetaData[queryIndex] = []; + } + state.resultMetaData[queryIndex][0] = { + ...(state.resultMetaData[queryIndex]?.[0] ?? {}), + ...res.content, + }; + } if (res?.type === "promql_response") { const newData = res?.content?.results; diff --git a/web/src/composables/dashboard/usePanelSearchHandlers.spec.ts b/web/src/composables/dashboard/usePanelSearchHandlers.spec.ts index d6c789d0e3..c5aaae2734 100644 --- a/web/src/composables/dashboard/usePanelSearchHandlers.spec.ts +++ b/web/src/composables/dashboard/usePanelSearchHandlers.spec.ts @@ -899,7 +899,7 @@ describe("handleSearchResponse", () => { expect(saveCurrentStateToCache).toHaveBeenCalled(); }); - it("updates loadingProgressPercentage on event_progress", () => { + it("updates loadingProgressPercentage on event_progress", async () => { const { state, handlers } = makeHandlers(); handlers.handleSearchResponse( @@ -907,6 +907,8 @@ describe("handleSearchResponse", () => { { type: "event_progress", content: { percent: 55 } }, ); + await Promise.resolve(); + expect(state.loadingProgressPercentage).toBe(55); expect(state.isPartialData).toBe(true); }); diff --git a/web/src/composables/dashboard/usePanelSearchHandlers.ts b/web/src/composables/dashboard/usePanelSearchHandlers.ts index 8f02c76958..954badad34 100644 --- a/web/src/composables/dashboard/usePanelSearchHandlers.ts +++ b/web/src/composables/dashboard/usePanelSearchHandlers.ts @@ -54,6 +54,7 @@ export const usePanelSearchHandlers = ({ { hits: any[][]; streamingAggs: boolean; orderAsc: boolean; isLTR: boolean } > = new Map(); let flushScheduled = false; + let pendingProgressPercent: number | null = null; function scheduleFlush() { if (!flushScheduled) { @@ -100,12 +101,19 @@ export const usePanelSearchHandlers = ({ buffer.hits.length = 0; // clear buffer after flush } + + if (pendingProgressPercent !== null) { + state.loadingProgressPercentage = pendingProgressPercent; + state.isPartialData = true; + pendingProgressPercent = null; + } } function clearHitsBuffer() { hitsBuffer.clear(); chunkingLeftToRight.clear(); flushScheduled = false; + pendingProgressPercent = null; } // Low-level streaming event handlers @@ -315,8 +323,8 @@ export const usePanelSearchHandlers = ({ } if (response.type === "event_progress") { - state.loadingProgressPercentage = response?.content?.percent ?? 0; - state.isPartialData = true; + pendingProgressPercent = response?.content?.percent ?? 0; + scheduleFlush(); } } catch (error: any) { clearHitsBuffer(); diff --git a/web/src/composables/useStreamingSearch.ts b/web/src/composables/useStreamingSearch.ts index 535a67bc4e..b623583574 100644 --- a/web/src/composables/useStreamingSearch.ts +++ b/web/src/composables/useStreamingSearch.ts @@ -68,7 +68,8 @@ type StreamResponseType = | "error" | "end" | "pattern_extraction_result" - | "promql_response"; + | "promql_response" + | "promql_metadata"; const useHttpStreaming = () => { const onData = ( @@ -391,6 +392,9 @@ const useHttpStreaming = () => { case "promql_response": onData(eventTraceId, "promql_response", data); break; + case "promql_metadata": + onData(eventTraceId, "promql_metadata", data); + break; case "progress": onData(eventTraceId, "progress", data); break; @@ -688,11 +692,25 @@ const useHttpStreaming = () => { }; }; + const convertToPromQLMetadata = ( + traceId: string, + response: any, + ) => { + return { + content: { + ...response, + trace_id: response.trace_id || traceId, + }, + type: "promql_metadata", + }; + }; + const wsMapper = { search_response_metadata: convertToWsResponse, search_response_hits: convertToWsResponse, pattern_extraction_result: convertToPatternResult, promql_response: convertToPromQLResponse, + promql_metadata: convertToPromQLMetadata, progress: convertToWsEventProgress, error: convertToWsError, end: convertToWsEnd, diff --git a/web/src/plugins/logs/SearchBar.vue b/web/src/plugins/logs/SearchBar.vue index fcaf907264..3ed414b1ed 100644 --- a/web/src/plugins/logs/SearchBar.vue +++ b/web/src/plugins/logs/SearchBar.vue @@ -974,7 +974,7 @@ along with this program. If not, see . dense flat :title="t('search.cancel')" - class="q-pa-none o2-run-query-button o2-color-cancel element-box-shadow search-button-normal-border-radius" + class="q-pa-none o2-run-query-button o2-color-cancel element-box-shadow search-button-enterprise-border-radius" @click="cancelVisualizeQueries" >{{ t("search.cancel") }} @@ -1022,12 +1022,8 @@ along with this program. If not, see . : t("search.runQuery") }} - + {{ t("search.cancel") }} @@ -1146,12 +1142,8 @@ along with this program. If not, see . : t("search.runQuery") }} - + ({ convertTableData: vi.fn().mockReturnValue({ columns: [], rows: [] }) })); +vi.mock("./convertPivotTableData", () => ({ + convertPivotTableData: vi.fn().mockReturnValue({ columns: [], rows: [], pivoted: true }) +})); + vi.mock("./convertGeoMapData", () => ({ convertGeoMapData: vi.fn().mockReturnValue({ geoData: [] }) })); @@ -128,7 +132,9 @@ describe("convertPanelData", () => { mockChartPanelRef, mockHoveredSeriesState, mockAnnotations, - mockMetadata + mockMetadata, + undefined, + false, ); expect(result.chartType).toBe(type); }); @@ -627,7 +633,8 @@ describe("convertPanelData", () => { mockResultMetaData, mockMetadata, mockChartPanelStyle, - mockAnnotations + mockAnnotations, + false, ); }); @@ -874,6 +881,114 @@ describe("convertPanelData", () => { expect(result).toEqual({}); }); + it("should pass loading=true to convertMultiSQLData", async () => { + const panelSchema = { + type: "area", + queryType: "sql", + queries: [{ query: "SELECT * FROM test", fields: mockQueryFields }] + }; + + const { convertMultiSQLData } = await import("./convertSQLData"); + + await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + true + ); + + expect(convertMultiSQLData).toHaveBeenCalledWith( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + true, + ); + }); + + it("should pass loading=true to convertPromQLData with resultMetaData", async () => { + const panelSchema = { + type: "line", + queryType: "promql", + queries: [{ query: "up", fields: mockQueryFields }] + }; + + const metaData = { value: [{ step: 15 }] }; + const { convertPromQLData } = await import("./convertPromQLData"); + + await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + metaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + true + ); + + expect(convertPromQLData).toHaveBeenCalledWith( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + mockMetadata, + [{ step: 15 }], + true, + ); + }); + + it("should default loading to false when not provided", async () => { + const panelSchema = { + type: "bar", + queryType: "sql", + queries: [{ query: "SELECT * FROM test", fields: mockQueryFields }] + }; + + const { convertMultiSQLData } = await import("./convertSQLData"); + + await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + ); + + // loading defaults to false + expect(convertMultiSQLData).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + false, + ); + }); + it("should handle all parameters with null/undefined values", async () => { const panelSchema = { type: "table", @@ -895,4 +1010,194 @@ describe("convertPanelData", () => { expect(result.chartType).toBe("table"); }); }); + + describe("Table with PromQL", () => { + it("should use convertPromQLData for table with promql queryType", async () => { + const panelSchema = { + type: "table", + queryType: "promql", + queries: [{ query: "up" }] + }; + + const { convertPromQLData } = await import("./convertPromQLData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertPromQLData).toHaveBeenCalled(); + expect(result.chartType).toBe("table"); + }); + }); + + describe("GeoMap with PromQL", () => { + it("should use convertPromQLData for geomap with promql queryType", async () => { + const panelSchema = { + type: "geomap", + queryType: "promql", + queries: [{ query: "up" }] + }; + + const { convertPromQLData } = await import("./convertPromQLData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertPromQLData).toHaveBeenCalled(); + expect(result.chartType).toBe("geomap"); + }); + }); + + describe("Maps with PromQL", () => { + it("should use convertPromQLData for maps with promql queryType", async () => { + const panelSchema = { + type: "maps", + queryType: "promql", + queries: [{ query: "up" }] + }; + + const { convertPromQLData } = await import("./convertPromQLData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertPromQLData).toHaveBeenCalled(); + expect(result.chartType).toBe("maps"); + }); + }); + + describe("Pivot Table Detection", () => { + it("should use convertPivotTableData when x, y, and breakdown fields present", async () => { + const panelSchema = { + type: "table", + queries: [{ + query: "SELECT * FROM test", + fields: { + x: [{ column: "date" }], + y: [{ column: "count" }], + breakdown: [{ column: "region" }], + } + }] + }; + + const { convertPivotTableData } = await import("./convertPivotTableData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertPivotTableData).toHaveBeenCalledWith(panelSchema, mockData, mockStore); + expect(result.chartType).toBe("table"); + }); + + it("should use convertTableData when breakdown is empty (not pivot)", async () => { + const panelSchema = { + type: "table", + queries: [{ + query: "SELECT * FROM test", + fields: { + x: [{ column: "date" }], + y: [{ column: "count" }], + breakdown: [], + } + }] + }; + + const { convertTableData } = await import("./convertTableData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertTableData).toHaveBeenCalled(); + expect(result.chartType).toBe("table"); + }); + + it("should use convertTableData when fields are missing", async () => { + const panelSchema = { + type: "table", + queries: [{ query: "SELECT * FROM test" }] + }; + + const { convertTableData } = await import("./convertTableData"); + const result = await convertPanelData( + panelSchema, + mockData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(convertTableData).toHaveBeenCalled(); + expect(result.chartType).toBe("table"); + }); + }); + + describe("Custom Chart with PromQL data format", () => { + it("should detect PromQL data format for custom_chart", async () => { + const panelSchema = { + type: "custom_chart", + queryType: "promql", + queries: [{ query: "up" }] + }; + const promqlData = [{ result: [{ metric: {}, values: [[1, "1"]] }] }]; + + const { runJavaScriptCode } = await import("./convertCustomChartData"); + const result = await convertPanelData( + panelSchema, + promqlData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations + ); + + expect(runJavaScriptCode).toHaveBeenCalledWith(panelSchema, promqlData); + expect(result.chartType).toBe("custom_chart"); + }); + }); }); diff --git a/web/src/utils/dashboard/convertPanelData.ts b/web/src/utils/dashboard/convertPanelData.ts index 6cbc2d318b..ef0138a665 100644 --- a/web/src/utils/dashboard/convertPanelData.ts +++ b/web/src/utils/dashboard/convertPanelData.ts @@ -91,6 +91,8 @@ export const convertPanelData = async ( hoveredSeriesState, annotations, metadata, + resultMetaData?.value, + loading, )), }; } else { @@ -108,6 +110,7 @@ export const convertPanelData = async ( metadata, chartPanelStyle, annotations, + loading, )), }; } catch (error) { @@ -129,6 +132,8 @@ export const convertPanelData = async ( hoveredSeriesState, annotations, metadata, + resultMetaData?.value, + loading, )), }; } else { @@ -164,6 +169,8 @@ export const convertPanelData = async ( hoveredSeriesState, annotations, metadata, + resultMetaData?.value, + loading, )), }; } else { @@ -187,6 +194,8 @@ export const convertPanelData = async ( hoveredSeriesState, annotations, metadata, + resultMetaData?.value, + loading, )), }; } else { diff --git a/web/src/utils/dashboard/convertPromQLData.spec.ts b/web/src/utils/dashboard/convertPromQLData.spec.ts index d3239a398f..b8094d1145 100644 --- a/web/src/utils/dashboard/convertPromQLData.spec.ts +++ b/web/src/utils/dashboard/convertPromQLData.spec.ts @@ -4000,5 +4000,248 @@ describe("Convert PromQL Data Utils", () => { expect(typeof tooltipResult).toBe("string"); }); }); + + describe("timestamp filling and x-axis pinning", () => { + it("should fill missing timestamps using PromQL step on final render", async () => { + const panelSchema = { + id: "panel1", + type: "line", + config: { show_legends: true }, + queries: [{ config: { promql_legend: "" } }], + }; + + // Data has a gap: ts 100, 200, (missing 300), 400 + const searchQueryData = [ + { + resultType: "matrix", + result: [ + { + metric: { __name__: "test" }, + values: [ + [100, "1"], + [200, "2"], + [400, "4"], + ], + }, + ], + }, + ]; + + const metadata = { + queries: [ + { + startTime: String(50 * 1_000_000), // 50s in µs + endTime: String(450 * 1_000_000), // 450s in µs + }, + ], + }; + + const resultMetaData = [[{ step: 100_000_000 }]]; // 100s step in µs + + const result = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + metadata, + resultMetaData, + false, // loading=false → fill timestamps + ); + + expect(result.options).toBeDefined(); + // PromQL uses xAxis type "time" — data is in each series' .data array. + // The first series (non-annotation) should have more than 3 data points + // because gap filling inserts null values at missing timestamps. + const dataSeries = result.options.series.find( + (s: any) => s.name != null, + ); + expect(dataSeries).toBeDefined(); + expect(dataSeries.data.length).toBeGreaterThan(3); + }); + + it("should NOT fill missing timestamps during streaming (loading=true)", async () => { + const panelSchema = { + id: "panel1", + type: "line", + config: { show_legends: true }, + queries: [{ config: { promql_legend: "" } }], + }; + + const searchQueryData = [ + { + resultType: "matrix", + result: [ + { + metric: { __name__: "test" }, + values: [ + [100, "1"], + [200, "2"], + [400, "4"], + ], + }, + ], + }, + ]; + + const metadata = { + queries: [ + { + startTime: String(50 * 1_000_000), + endTime: String(450 * 1_000_000), + }, + ], + }; + + const resultMetaData = [[{ step: 100_000_000 }]]; + + const result = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + metadata, + resultMetaData, + true, // loading=true → do NOT fill timestamps + ); + + expect(result.options).toBeDefined(); + // During streaming, gap filling is skipped — series has only original 3 points + const dataSeries = result.options.series.find( + (s: any) => s.name != null, + ); + expect(dataSeries).toBeDefined(); + expect(dataSeries.data.length).toBe(3); + }); + + it("should pin x-axis min/max to query range during streaming (loading=true)", async () => { + const panelSchema = { + id: "panel1", + type: "line", + config: { show_legends: true }, + queries: [{ config: { promql_legend: "" } }], + }; + + const searchQueryData = [ + { + resultType: "matrix", + result: [ + { + metric: { __name__: "test" }, + values: [[1640435200, "10"]], + }, + ], + }, + ]; + + // µs timestamps + const startTimeUs = String(1640435000 * 1_000_000); + const endTimeUs = String(1640435500 * 1_000_000); + const metadata = { + queries: [{ startTime: startTimeUs, endTime: endTimeUs }], + }; + + const result = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + metadata, + undefined, // resultMetaData + true, // loading — enables x-axis pinning during streaming + ); + + expect(result.options).toBeDefined(); + // xAxis should have min and max set during streaming + expect(result.options.xAxis.min).toBeDefined(); + expect(result.options.xAxis.max).toBeDefined(); + }); + + it("should NOT pin x-axis on final render (loading=false) even with metadata", async () => { + const panelSchema = { + id: "panel1", + type: "line", + config: { show_legends: true }, + queries: [{ config: { promql_legend: "" } }], + }; + + const searchQueryData = [ + { + resultType: "matrix", + result: [ + { + metric: { __name__: "test" }, + values: [[1640435200, "10"]], + }, + ], + }, + ]; + + // µs timestamps + const startTimeUs = String(1640435000 * 1_000_000); + const endTimeUs = String(1640435500 * 1_000_000); + const metadata = { + queries: [{ startTime: startTimeUs, endTime: endTimeUs }], + }; + + const result = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + metadata, + undefined, // resultMetaData + false, // loading=false — final render, no x-axis pinning + ); + + expect(result.options).toBeDefined(); + // xAxis should NOT have min/max on final render + expect(result.options.xAxis.min).toBeUndefined(); + expect(result.options.xAxis.max).toBeUndefined(); + }); + + it("should NOT pin x-axis when metadata has no query times", async () => { + const panelSchema = { + id: "panel1", + type: "line", + config: { show_legends: true }, + queries: [{ config: { promql_legend: "" } }], + }; + + const searchQueryData = [ + { + resultType: "matrix", + result: [ + { + metric: { __name__: "test" }, + values: [[1640435200, "10"]], + }, + ], + }, + ]; + + const result = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockAnnotations, + null, // no metadata + ); + + expect(result.options).toBeDefined(); + // xAxis should NOT have min/max set + expect(result.options.xAxis.min).toBeUndefined(); + expect(result.options.xAxis.max).toBeUndefined(); + }); + }); }); }); \ No newline at end of file diff --git a/web/src/utils/dashboard/convertPromQLData.ts b/web/src/utils/dashboard/convertPromQLData.ts index b5c5fc271d..7fe6df4d0f 100644 --- a/web/src/utils/dashboard/convertPromQLData.ts +++ b/web/src/utils/dashboard/convertPromQLData.ts @@ -89,6 +89,8 @@ export const convertPromQLData = async ( hoveredSeriesState: any, annotations: any, metadata: any = null, + resultMetaData?: any, + loading?: any, ) => { // Set gridlines visibility based on config.show_gridlines (default: true) const showGridlines = @@ -231,12 +233,42 @@ export const convertPromQLData = async ( // sort the timestamp and make an array xAxisData = Array.from(xAxisData).sort(); - // Add end time from metadata to reserve full time range and prevent chart shifting during chunked data loading - if (metadata?.queries?.[0]?.endTime) { - const endTimeInSeconds = Math.floor(metadata.queries[0].endTime / 1000000); // Convert from microseconds to seconds - // Only add if end time is not already in the data - if (!xAxisData.includes(endTimeInSeconds)) { - xAxisData.push(endTimeInSeconds); + // Fill missing timestamps from queryStart to queryEnd using PromQL step. + // Only on final render (loading=false) — during streaming, overlay handles gaps. + // This ensures cancelled queries show the full time range with nulls. + if ( + !loading && + xAxisData.length >= 2 && + metadata?.queries?.[0]?.startTime && + metadata?.queries?.[0]?.endTime + ) { + const metaStep = resultMetaData?.[0]?.[0]?.step; + const stepSeconds = metaStep + ? metaStep / 1_000_000 + : (xAxisData[1] as number) - (xAxisData[0] as number); + + if (stepSeconds > 0) { + const queryStartSec = + parseInt(metadata.queries[0].startTime) / 1_000_000; + const queryEndSec = parseInt(metadata.queries[0].endTime) / 1_000_000; + + // Anchor to actual data grid — query range has fractional seconds + // but PromQL data uses integer timestamps. Snap to the data's grid. + const anchor = xAxisData[0] as number; + const gridStart = + anchor - + Math.ceil((anchor - queryStartSec) / stepSeconds) * stepSeconds; + const gridEnd = + anchor + + Math.ceil((queryEndSec - anchor) / stepSeconds) * stepSeconds; + + const existingTimestamps = new Set(xAxisData); + for (let t = gridStart; t <= gridEnd; t += stepSeconds) { + if (!existingTimestamps.has(t)) { + xAxisData.push(t); + } + } + xAxisData.sort((a: number, b: number) => a - b); } } @@ -537,6 +569,31 @@ export const convertPromQLData = async ( }, series: [], }; + // Pin x-axis range during streaming to prevent chart shifting as + // chunks arrive. On final render the data is complete so ECharts + // can auto-range with its natural padding (avoids bar clipping at + // the y-axis edge where gap-filled data can extend beyond the query + // start). + if (loading && metadata?.queries?.[0]?.startTime && metadata?.queries?.[0]?.endTime) { + const queryStartMs = metadata.queries[0].startTime / 1000; // µs to ms + const queryEndMs = metadata.queries[0].endTime / 1000; + + // Add half-step padding so edge bars aren't clipped during streaming. + // ECharts centers bars on their timestamp, so without padding the + // leftmost bar's left half extends beyond the plot boundary. + const metaStep = resultMetaData?.[0]?.[0]?.step; // µs + const halfStepMs = metaStep ? metaStep / 2000 : 0; // half step in ms + + options.xAxis.min = + store.state.timezone !== "UTC" + ? toZonedTime(queryStartMs - halfStepMs, store.state.timezone) + : new Date(queryStartMs - halfStepMs).toISOString().slice(0, -1); + options.xAxis.max = + store.state.timezone !== "UTC" + ? toZonedTime(queryEndMs + halfStepMs, store.state.timezone) + : new Date(queryEndMs + halfStepMs).toISOString().slice(0, -1); + } + // to pass grid index in gauge chart let gaugeIndex = 0; diff --git a/web/src/utils/dashboard/convertSQLData.spec.ts b/web/src/utils/dashboard/convertSQLData.spec.ts index a681c07f75..3149c131bd 100644 --- a/web/src/utils/dashboard/convertSQLData.spec.ts +++ b/web/src/utils/dashboard/convertSQLData.spec.ts @@ -370,6 +370,85 @@ describe("convertSQLData", () => { expect(Array.isArray(result.options.series)).toBe(true); expect(result.options.series.length).toBeGreaterThanOrEqual(1); }); + + it("should pass loading parameter through to convertSQLData", async () => { + const searchData = [ + [{ timestamp: "2023-01-01", value: 10 }], + ]; + + const result = await convertMultiSQLData( + mockPanelSchema, + searchData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + { value: [[{}]] }, + { queries: [{}] }, + mockChartPanelStyle, + mockAnnotations, + true, // loading = streaming + ); + + // Should not throw and should return valid options + expect(result.options).toBeDefined(); + }); + + it("should default loading to undefined when not provided", async () => { + const searchData = [ + [{ timestamp: "2023-01-01", value: 10 }], + ]; + + const result = await convertMultiSQLData( + mockPanelSchema, + searchData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + { value: [[{}]] }, + { queries: [{}] }, + mockChartPanelStyle, + mockAnnotations, + // loading omitted — should default to undefined (final render) + ); + + expect(result.options).toBeDefined(); + }); + }); + + describe("loading parameter in convertSQLData", () => { + it("should accept loading=true without error", async () => { + const result = await convertSQLData( + mockPanelSchema, + [[{ timestamp: "2023-01-01", value: 10 }]], + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + true, // loading + ); + + expect(result.options).toBeDefined(); + }); + + it("should accept loading=false without error", async () => { + const result = await convertSQLData( + mockPanelSchema, + [[{ timestamp: "2023-01-01", value: 10 }]], + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + mockResultMetaData, + mockMetadata, + mockChartPanelStyle, + mockAnnotations, + false, // loading + ); + + expect(result.options).toBeDefined(); + }); }); describe("Internal utility functions", () => { diff --git a/web/src/utils/dashboard/convertSQLData.ts b/web/src/utils/dashboard/convertSQLData.ts index f7427f1e57..c1492c87c3 100644 --- a/web/src/utils/dashboard/convertSQLData.ts +++ b/web/src/utils/dashboard/convertSQLData.ts @@ -34,6 +34,7 @@ export const convertMultiSQLData = async ( metadata: any, chartPanelStyle: any, annotations: any, + loading?: any, ) => { if (!Array.isArray(searchQueryData) || searchQueryData.length === 0) { // this sets a blank object until it loads @@ -56,6 +57,7 @@ export const convertMultiSQLData = async ( { queries: [metadata.queries[i]] }, chartPanelStyle, annotations, + loading, ), ); } @@ -110,6 +112,7 @@ export const convertSQLData = async ( metadata: any, chartPanelStyle: any, annotations: any, + loading?: any, ) => { return convertSQLChartData( panelSchema, @@ -121,6 +124,7 @@ export const convertSQLData = async ( metadata, chartPanelStyle, annotations, + loading, ); }; diff --git a/web/src/utils/dashboard/sql/convertSQLChartData.ts b/web/src/utils/dashboard/sql/convertSQLChartData.ts index cda66a9525..60b58a8ebd 100644 --- a/web/src/utils/dashboard/sql/convertSQLChartData.ts +++ b/web/src/utils/dashboard/sql/convertSQLChartData.ts @@ -46,6 +46,7 @@ export const convertSQLChartData = async ( metadata: any, chartPanelStyle: any, annotations: any, + loading?: any, ) => { // Build the full shared context (all pre-switch logic lives here) const ctx = buildSQLContext( @@ -58,6 +59,7 @@ export const convertSQLChartData = async ( metadata, chartPanelStyle, annotations, + loading, ); // Guard returned from contextBuilder (invalid/empty input) diff --git a/web/src/utils/dashboard/sql/shared/contextBuilder.ts b/web/src/utils/dashboard/sql/shared/contextBuilder.ts index b01655fc7f..4d9472752a 100644 --- a/web/src/utils/dashboard/sql/shared/contextBuilder.ts +++ b/web/src/utils/dashboard/sql/shared/contextBuilder.ts @@ -66,6 +66,7 @@ export function buildSQLContext( metadata: any, chartPanelStyle: any, annotations: any, + loading?: any, ): SQLContext | null { // Set gridlines visibility based on config.show_gridlines (default: true) const showGridlines = @@ -162,6 +163,7 @@ export function buildSQLContext( zAxisKeys, breakDownKeys, noValueConfigOption, + loading, ); // get the axis data using key diff --git a/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts b/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts index 179721ac7c..c33d9675cf 100644 --- a/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts +++ b/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts @@ -357,7 +357,7 @@ describe("fillMissingValues", () => { }, ]; - it("LTR: anchors user-end with empty string when first chunk starts at user-start", () => { + it("LTR: anchors user-end with fallback value when first chunk starts at user-start", () => { const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); // First chunk covers only the very start → LTR @@ -384,10 +384,10 @@ describe("fillMissingValues", () => { (r: any) => r.ts === new Date(userEnd).toISOString().slice(0, 19), ); expect(anchor).toBeDefined(); - expect(anchor.value).toBe(""); // empty string, NOT "fallback" + expect(anchor.value).toBe("fallback"); }); - it("RTL: anchors user-start with empty string when first chunk ends at user-end", () => { + it("RTL: anchors user-start with fallback value when first chunk ends at user-end", () => { const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); // First chunk covers only the very end → RTL @@ -413,10 +413,10 @@ describe("fillMissingValues", () => { (r: any) => r.ts === new Date(userStart).toISOString().slice(0, 19), ); expect(anchor).toBeDefined(); - expect(anchor.value).toBe(""); // empty string, NOT "fallback" + expect(anchor.value).toBe("fallback"); }); - it("LTR with breakdown: right-edge anchor is emitted per series with empty string", () => { + it("LTR with breakdown: right-edge anchor is emitted per series with noValueConfigOption", () => { const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); const chunkStart = userStart; @@ -448,12 +448,12 @@ describe("fillMissingValues", () => { ); expect(apiAnchor).toBeDefined(); expect(webAnchor).toBeDefined(); - // Anchor must use "" regardless of noValueConfigOption - expect(apiAnchor.value).toBe(""); - expect(webAnchor.value).toBe(""); + // Anchor uses noValueConfigOption + expect(apiAnchor.value).toBe(0); + expect(webAnchor.value).toBe(0); }); - it("RTL with breakdown: left-edge anchor is emitted per series with empty string", () => { + it("RTL with breakdown: left-edge anchor is emitted per series with noValueConfigOption", () => { const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); const chunkStart = userEnd - 60_000; @@ -485,8 +485,156 @@ describe("fillMissingValues", () => { ); expect(apiAnchor).toBeDefined(); expect(webAnchor).toBeDefined(); - expect(apiAnchor.value).toBe(""); - expect(webAnchor.value).toBe(""); + expect(apiAnchor.value).toBe(0); + expect(webAnchor.value).toBe(0); + }); + + // --------------------------------------------------------------------------- + // Streaming: no clamping — fill the full chunk boundary range + // --------------------------------------------------------------------------- + + it("LTR streaming: fills gaps between actual data points only", () => { + const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); + const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); + const chunkStart = userStart; + const chunkEnd = userStart + 4 * 60_000; // chunk covers minutes 0-4 + + // Data at minutes 0 and 2 — minute 1 is a gap within actual data bounds + const processedData = [ + { ts: "2024-01-15T10:00:00", value: 42 }, + { ts: "2024-01-15T10:02:00", value: 99 }, + ]; + + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeChunkMetaData(chunkStart, chunkEnd, 60), + makeRangeMetadata(userStart, userEnd), + ["ts"], + ["value"], + [], + [], + 0, // noValueConfigOption + true, // loading = streaming + ); + + // Minute 0 has data + expect(result.find((r: any) => r.ts === "2024-01-15T10:00:00").value).toBe(42); + // Minute 1 is a gap between data points — filled with noValueConfigOption + const m1 = result.find((r: any) => r.ts === "2024-01-15T10:01:00"); + expect(m1).toBeDefined(); + expect(m1.value).toBe(0); + // Minute 2 has data + expect(result.find((r: any) => r.ts === "2024-01-15T10:02:00").value).toBe(99); + // Minutes 3 and 4 are NOT filled (after actualMaxTime, overlay preserves old data) + expect(result.find((r: any) => r.ts === "2024-01-15T10:03:00")).toBeUndefined(); + expect(result.find((r: any) => r.ts === "2024-01-15T10:04:00")).toBeUndefined(); + }); + + it("RTL streaming: fills gaps within chunk range with noValueConfigOption", () => { + const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); + const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); + const chunkStart = userEnd - 2 * 60_000; // from minute 3 + const chunkEnd = userEnd; + + // Data at minute 4 only — minute 3 and minute 4:00 (userEnd) are in range + const processedData = [{ ts: "2024-01-15T10:04:00", value: 99 }]; + + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeChunkMetaData(chunkStart, chunkEnd, 60), + makeRangeMetadata(userStart, userEnd), + ["ts"], + ["value"], + [], + [], + 0, // noValueConfigOption + true, // loading = streaming + ); + + // Minute 3 should be filled with noValueConfigOption + const filledEntry = result.find( + (r: any) => r.ts === "2024-01-15T10:03:00", + ); + expect(filledEntry).toBeDefined(); + expect(filledEntry.value).toBe(0); + + // Minute 5 (userEnd) should also be filled — no clamping to actualMaxTime + const userEndEntry = result.find( + (r: any) => r.ts === "2024-01-15T10:05:00", + ); + expect(userEndEntry).toBeDefined(); + expect(userEndEntry.value).toBe(0); + }); + + it("RTL streaming: fills beyond actualMaxTime up to userEnd", () => { + const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); + const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); + const chunkStart = userEnd - 60_000; // chunk covers minute 4 + const chunkEnd = userEnd; + + // Data only at minute 3 — but userEnd is minute 5 + const processedData = [{ ts: "2024-01-15T10:03:00", value: 1 }]; + + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeChunkMetaData(chunkStart, chunkEnd, 60), + makeRangeMetadata(userStart, userEnd), + ["ts"], + ["value"], + [], + [], + null, + true, // loading + ); + + // Minutes 4 and 5 should be filled (beyond actualMaxTime=minute 3) + const m4 = result.find((r: any) => r.ts === "2024-01-15T10:04:00"); + const m5 = result.find((r: any) => r.ts === "2024-01-15T10:05:00"); + expect(m4).toBeDefined(); + expect(m4.value).toBeNull(); + expect(m5).toBeDefined(); + expect(m5.value).toBeNull(); + }); + + it("LTR streaming: clamps both edges to actual data bounds", () => { + const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); + const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); + const chunkStart = userStart; + const chunkEnd = userStart + 5 * 60_000; // chunk covers minutes 0-5 + + // Data at minutes 2 and 3 — minutes 0,1 before and 4,5 after actual data + const processedData = [ + { ts: "2024-01-15T10:02:00", value: 1 }, + { ts: "2024-01-15T10:03:00", value: 2 }, + ]; + + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeChunkMetaData(chunkStart, chunkEnd, 60), + makeRangeMetadata(userStart, userEnd), + ["ts"], + ["value"], + [], + [], + null, + true, // loading + ); + + // Minutes 0 and 1 NOT filled (before actualMinTime — overlay preserves old data) + expect(result.find((r: any) => r.ts === "2024-01-15T10:00:00")).toBeUndefined(); + expect(result.find((r: any) => r.ts === "2024-01-15T10:01:00")).toBeUndefined(); + + // Minutes 2 and 3 have real data + expect(result.find((r: any) => r.ts === "2024-01-15T10:02:00")?.value).toBe(1); + expect(result.find((r: any) => r.ts === "2024-01-15T10:03:00")?.value).toBe(2); + + // Minutes 4 and 5 NOT filled (after actualMaxTime — overlay preserves old data) + expect(result.find((r: any) => r.ts === "2024-01-15T10:04:00")).toBeUndefined(); + expect(result.find((r: any) => r.ts === "2024-01-15T10:05:00")).toBeUndefined(); }); it("LTR (invalid time_offset): no crash, returns filled data including user-end anchor", () => { @@ -513,4 +661,178 @@ describe("fillMissingValues", () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThan(0); }); + + // --------------------------------------------------------------------------- + // Additional chart type support + // --------------------------------------------------------------------------- + + it("fills data for area-stacked chart type", () => { + const processedData = [{ ts: "2024-01-15T10:00:00", value: 100 }]; + const result = fillMissingValues( + processedData, + makePanelSchema("area-stacked"), + makeResultMetaData(60), + makeMetadata(), + ["ts"], + ["value"], + [], + [], + null, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ ts: "2024-01-15T10:00:00", value: 100 }); + }); + + it("fills data for scatter chart type", () => { + const processedData = [{ ts: "2024-01-15T10:00:00", value: 55 }]; + const result = fillMissingValues( + processedData, + makePanelSchema("scatter"), + makeResultMetaData(60), + makeMetadata(), + ["ts"], + ["value"], + [], + [], + null, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ ts: "2024-01-15T10:00:00", value: 55 }); + }); + + // --------------------------------------------------------------------------- + // Multiple y-axis and z-axis keys + // --------------------------------------------------------------------------- + + it("fills null entries for multiple y-axis keys", () => { + const processedData = [{ ts: "2024-01-15T09:00:00", v1: 1, v2: 2 }]; + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeResultMetaData(60), + makeMetadata(), + ["ts"], + ["v1", "v2"], + [], + [], + 0, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + ts: "2024-01-15T10:00:00", + v1: 0, + v2: 0, + }); + }); + + it("includes z-axis keys in the null entry key set", () => { + const processedData = [{ ts: "2024-01-15T09:00:00", y: 1, z: 2 }]; + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeResultMetaData(60), + makeMetadata(), + ["ts"], + ["y"], + ["z"], + [], + null, + ); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + ts: "2024-01-15T10:00:00", + y: null, + z: null, + }); + }); + + // --------------------------------------------------------------------------- + // Multiple resultMetaData entries (chunk coverage union) + // --------------------------------------------------------------------------- + + it("handles multiple resultMetaData entries: uses first histogram_interval", () => { + // Only the first resultMetaData's histogram_interval is used (line 56-58) + const multiMeta = [ + { histogram_interval: 60 }, + { histogram_interval: 120 }, + ]; + const processedData = [{ ts: "2024-01-15T10:00:00", value: 42 }]; + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + multiMeta, + makeMetadata(), + ["ts"], + ["value"], + [], + [], + null, + ); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe(42); + }); + + // --------------------------------------------------------------------------- + // LTR streaming: single data point (actualMin === actualMax) + // --------------------------------------------------------------------------- + + it("LTR streaming: single data point clamped to one entry", () => { + const userStart = Date.UTC(2024, 0, 15, 10, 0, 0); + const userEnd = Date.UTC(2024, 0, 15, 10, 5, 0); + const chunkStart = userStart; + const chunkEnd = userStart + 3 * 60_000; + + const processedData = [{ ts: "2024-01-15T10:02:00", value: 7 }]; + + const result = fillMissingValues( + processedData, + makePanelSchema("line"), + makeChunkMetaData(chunkStart, chunkEnd, 60), + makeRangeMetadata(userStart, userEnd), + ["ts"], + ["value"], + [], + [], + 0, + true, // loading + ); + + // Only the single data point should be returned (actualMin === actualMax) + expect(result).toHaveLength(1); + expect(result[0].value).toBe(7); + }); + + // --------------------------------------------------------------------------- + // xAxisKeys contains the time key in addition to another field + // --------------------------------------------------------------------------- + + it("uses xAxisKeysWithoutTimeStamp[0] as uniqueKey when present", () => { + const processedData = [ + { ts: "2024-01-15T10:00:00", category: "a", value: 10 }, + { ts: "2024-01-15T10:00:00", category: "b", value: 20 }, + ]; + const result = fillMissingValues( + processedData, + makePanelSchema("bar"), + makeResultMetaData(60), + makeMetadata(), + ["ts", "category"], // two x-axis keys, "ts" is time, "category" is uniqueKey + ["value"], + [], + [], + null, + ); + + // Two entries (one per unique category value) + expect(result).toHaveLength(2); + const catA = result.find((r: any) => r.category === "a"); + const catB = result.find((r: any) => r.category === "b"); + expect(catA).toBeDefined(); + expect(catB).toBeDefined(); + }); }); diff --git a/web/src/utils/dashboard/sqlMissingValueFiller.ts b/web/src/utils/dashboard/sqlMissingValueFiller.ts index 825923dab0..7392edcc3b 100644 --- a/web/src/utils/dashboard/sqlMissingValueFiller.ts +++ b/web/src/utils/dashboard/sqlMissingValueFiller.ts @@ -20,6 +20,9 @@ import { isTimeSeries } from "./dateTimeUtils"; import { getDataValue } from "./aliasUtils"; import { detectChunkingDirection } from "./chunkingDirection"; +const formatUtc = (date: Date) => + format(toZonedTime(date, "UTC"), "yyyy-MM-dd'T'HH:mm:ss"); + /** * Fills in missing time-series data points between the start and end time * based on the histogram interval, inserting null/no-value entries where data @@ -47,6 +50,7 @@ export const fillMissingValues = ( zAxisKeys: string[], breakDownKeys: string[], noValueConfigOption: any, + loading?: any, ): any[] => { // Get the interval in minutes const interval = resultMetaData?.map( @@ -106,13 +110,10 @@ export const fillMissingValues = ( // Use metadata endTime for the user's selected end time. const metaDataEndTime = metadata?.queries[0]?.endTime?.toString() ?? 0; const endTime = new Date(parseInt(metaDataEndTime) / 1000); - const formattedUserEnd = format( - toZonedTime(endTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); + const formattedUserEnd = formatUtc(endTime); // Detect chunking direction from first resultMetaData entry. - const isLeftToRight = + const isLTR = detectChunkingDirection( resultMetaData?.[0]?.time_offset?.start_time ?? 0, resultMetaData?.[0]?.time_offset?.end_time ?? 0, @@ -123,20 +124,21 @@ export const fillMissingValues = ( let binnedFillStart: Date; let endTimeForFill: string; - if (isLeftToRight) { - // LTR: fill from user's start to latest chunk's end + if (!loading) { + // Final render or cancel: fill the full user query range + binnedFillStart = binnedDate; + endTimeForFill = formattedUserEnd; + } else if (isLTR) { + // LTR streaming: fill from user's start to latest chunk's end binnedFillStart = binnedDate; const lastChunkEndTime = resultMetaData?.[resultMetaData.length - 1]?.time_offset?.end_time ?? 0; const fillEndTime = lastChunkEndTime ? new Date(lastChunkEndTime / 1000) : endTime; - endTimeForFill = format( - toZonedTime(fillEndTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); + endTimeForFill = formatUtc(fillEndTime); } else { - // RTL: fill from earliest chunk's start to user's end (existing behavior) + // RTL streaming: fill from earliest chunk's start to user's end const resultMetaStartTime = resultMetaData?.[resultMetaData.length - 1]?.time_offset?.start_time ?? 0; const fillStartTime = resultMetaStartTime @@ -146,96 +148,46 @@ export const fillMissingValues = ( endTimeForFill = formattedUserEnd; } - // Build map from processedData for O(1) lookup + // Build map from processedData for O(1) lookup, and track actual data + // time range so we only fill gaps between real data points. const hasBreakdown = xAxisKeysWithoutTimeStamp.length > 0 || breakdownAxisKeysWithoutTimeStamp.length > 0; const searchDataMap = new Map(); + let actualMinTime: string | null = null; + let actualMaxTime: string | null = null; processedData?.forEach((d: any) => { + const timeVal = `${getDataValue(d, timeKey)}`; const key = hasBreakdown - ? `${getDataValue(d, timeKey)}-${getDataValue(d, uniqueKey)}` - : `${getDataValue(d, timeKey)}`; + ? `${timeVal}-${getDataValue(d, uniqueKey)}` + : timeVal; searchDataMap.set(key, d); + if (!actualMinTime || timeVal < actualMinTime) actualMinTime = timeVal; + if (!actualMaxTime || timeVal > actualMaxTime) actualMaxTime = timeVal; }); - const filledData: any = []; - - // Count unique time slots up to 3. We only need to know if there are < 3 - // (phantom anchor needed) or >= 3 (ECharts can derive interval from data). - // Break early once 3 distinct slots are found to avoid iterating all rows. - const uniqueTimeSlots = new Set(); - for (const d of processedData) { - uniqueTimeSlots.add(getDataValue(d, timeKey)); - if (uniqueTimeSlots.size >= 3) break; - } - const uniqueTimeSlotCount = uniqueTimeSlots.size; - - // RTL: insert an empty-string anchor at the user's selected start time when the fill - // loop doesn't yet cover it. This pins the ECharts x-axis left edge to the - // user's query range from the very first chunk. - if (!isLeftToRight && binnedFillStart > binnedDate) { - const anchorFormattedTime = format( - toZonedTime(binnedDate, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); - const anchorTimes = [anchorFormattedTime]; - - // Also insert a phantom point one interval after the user's selected start - // time when data is sparse (< 3 unique time slots). This gives ECharts a - // known 30s consecutive gap at the left edge of the axis so it sizes bars - // correctly instead of using the huge gap from user-start to first chunk - // (~hours/days). Only added when it falls strictly before the first real - // data point (binnedFillStart), otherwise it would overlap real data. - // Once there are >= 3 real time slots ECharts can derive the interval from - // the data itself and the phantom is no longer needed. - if (uniqueTimeSlotCount < 3) { - const nearAnchorTime = new Date(binnedDate.getTime() + interval * 1000); - // Only add the near-anchor if it's strictly before the first real data - if (nearAnchorTime < binnedFillStart) { - const nearAnchorFormattedTime = format( - toZonedTime(nearAnchorTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); - anchorTimes.push(nearAnchorFormattedTime); + if (loading) { + // LTR streaming: clamp both edges to actual data bounds. Timestamps + // before actualMinTime or after actualMaxTime have no new data yet — + // the overlay preserves previously rendered chart values for those. + // RTL streaming: no clamping — the fill extends from chunkStart to + // userEnd so the "newer" (right) side gets noValue entries. + if (isLTR) { + if (actualMinTime && actualMinTime > formatUtc(binnedFillStart)) { + binnedFillStart = new Date(actualMinTime + "Z"); + } + if (actualMaxTime && actualMaxTime < endTimeForFill) { + endTimeForFill = actualMaxTime; } } - // Anchor/phantom entries use empty string (not noValueConfigOption) so - // ECharts renders them as gaps rather than plotted points at the configured value. - if (!hasBreakdown) { - anchorTimes.forEach((t) => { - const anchorEntry: any = { [timeKey]: t }; - keys.forEach((key) => { - if (key !== timeKey) anchorEntry[key] = ""; - }); - filledData.push(anchorEntry); - }); - } else { - anchorTimes.forEach((t) => { - uniqueXAxisValues.forEach((uniqueValue: any) => { - const anchorEntry: any = { - [timeKey]: t, - [uniqueKey]: uniqueValue, - }; - keys.forEach((key) => { - if (key !== timeKey && key !== uniqueKey) { - anchorEntry[key] = ""; - } - }); - filledData.push(anchorEntry); - }); - }); - } } - // Fill slot-by-slot in chronological order from binnedFillStart - // (resultMetaData start) to endTimeForFill (user's end). - // Only covers the range where streaming data has been received. + const filledData: any = []; + + // Fill slot-by-slot in chronological order, only between actual data points. let currentTime = binnedFillStart; - let currentFormattedTime = format( - toZonedTime(currentTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); + let currentFormattedTime = formatUtc(currentTime); while (currentFormattedTime <= endTimeForFill) { if (!hasBreakdown) { @@ -272,70 +224,7 @@ export const fillMissingValues = ( } currentTime = new Date(currentTime.getTime() + intervalMillis); - currentFormattedTime = format( - toZonedTime(currentTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); - } - - // LTR: insert an empty-string anchor at the user's selected end time when the fill - // loop doesn't yet cover it. This pins the ECharts x-axis right edge to the - // user's query range from the very first chunk. - if (isLeftToRight) { - const binnedUserEnd = dateBin(interval, endTime, origin); - const formattedBinnedUserEnd = format( - toZonedTime(binnedUserEnd, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); - - if (endTimeForFill < formattedBinnedUserEnd) { - const anchorTimes: string[] = []; - - // Insert a phantom point one interval before the user's selected end - // time when data is sparse (< 3 unique time slots). This gives ECharts a - // known consecutive gap at the right edge so it sizes bars correctly. - if (uniqueTimeSlotCount < 3) { - const nearAnchorTime = new Date( - binnedUserEnd.getTime() - interval * 1000, - ); - const formattedNearAnchor = format( - toZonedTime(nearAnchorTime, "UTC"), - "yyyy-MM-dd'T'HH:mm:ss", - ); - if (formattedNearAnchor > endTimeForFill) { - anchorTimes.push(formattedNearAnchor); - } - } - - anchorTimes.push(formattedBinnedUserEnd); - - // Anchor/phantom entries use empty string (not noValueConfigOption) so - // ECharts renders them as gaps rather than plotted points at the configured value. - if (!hasBreakdown) { - anchorTimes.forEach((t) => { - const anchorEntry: any = { [timeKey]: t }; - keys.forEach((key) => { - if (key !== timeKey) anchorEntry[key] = ""; - }); - filledData.push(anchorEntry); - }); - } else { - anchorTimes.forEach((t) => { - uniqueXAxisValues.forEach((uniqueValue: any) => { - const anchorEntry: any = { - [timeKey]: t, - [uniqueKey]: uniqueValue, - }; - keys.forEach((key) => { - if (key !== timeKey && key !== uniqueKey) { - anchorEntry[key] = ""; - } - }); - filledData.push(anchorEntry); - }); - }); - } - } + currentFormattedTime = formatUtc(currentTime); } return filledData; diff --git a/web/src/utils/dashboard/sqlTimeSeriesConverter.ts b/web/src/utils/dashboard/sqlTimeSeriesConverter.ts index 041c357cd9..cbfc41fc2a 100644 --- a/web/src/utils/dashboard/sqlTimeSeriesConverter.ts +++ b/web/src/utils/dashboard/sqlTimeSeriesConverter.ts @@ -147,6 +147,15 @@ export const applyAutoSQLTimeSeries = ( options.xAxis[0].data = []; + // Pin x-axis range to the user's full query range so anchors are unnecessary. + const queryStartMs = parseInt(metadata?.queries[0]?.startTime?.toString() ?? "0") / 1000; + const queryEndMs = parseInt(metadata?.queries[0]?.endTime?.toString() ?? "0") / 1000; + const timeGap = metadata?.queries[0]?.timeRangeGap?.seconds ?? 0; + if (queryStartMs > 0 && queryEndMs > 0) { + options.xAxis[0].min = toZonedTime(queryStartMs + timeGap * 1000, store.state.timezone); + options.xAxis[0].max = toZonedTime(queryEndMs + timeGap * 1000, store.state.timezone); + } + options.tooltip.formatter = function (name: any) { // show tooltip for hovered panel only for other we only need axis so just return empty string @@ -374,6 +383,16 @@ export const applyCustomSQLTimeSeries = ( } options.xAxis[0].data = []; + + // Pin x-axis range to the user's full query range so anchors are unnecessary. + const queryStartMs = parseInt(metadata?.queries[0]?.startTime?.toString() ?? "0") / 1000; + const queryEndMs = parseInt(metadata?.queries[0]?.endTime?.toString() ?? "0") / 1000; + const timeGap = metadata?.queries[0]?.timeRangeGap?.seconds ?? 0; + if (queryStartMs > 0 && queryEndMs > 0) { + options.xAxis[0].min = toZonedTime(queryStartMs + timeGap * 1000, store.state.timezone); + options.xAxis[0].max = toZonedTime(queryEndMs + timeGap * 1000, store.state.timezone); + } + options.tooltip.formatter = function (name: any) { try { // if ( diff --git a/web/src/utils/dashboard/streaming/index.spec.ts b/web/src/utils/dashboard/streaming/index.spec.ts new file mode 100644 index 0000000000..e192b024e9 --- /dev/null +++ b/web/src/utils/dashboard/streaming/index.spec.ts @@ -0,0 +1,186 @@ +// 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, it, expect } from "vitest"; +import { isOverlayEligible } from "./index"; + +describe("isOverlayEligible", () => { + const validOldOptions = { + series: [{ name: "A", data: [[1, 2]], type: "line" }], + }; + + describe("eligible chart types", () => { + const eligibleTypes = [ + "line", + "area", + "area-stacked", + "bar", + "stacked", + "h-bar", + "h-stacked", + "scatter", + ]; + + eligibleTypes.forEach((type) => { + it(`returns true for ${type} chart type`, () => { + const panelSchema = { type, queries: [{}] }; + expect(isOverlayEligible(panelSchema, validOldOptions)).toBe(true); + }); + }); + }); + + describe("ineligible chart types", () => { + const ineligibleTypes = [ + "pie", + "donut", + "metric", + "gauge", + "heatmap", + "geomap", + "maps", + "table", + "sankey", + "custom_chart", + ]; + + ineligibleTypes.forEach((type) => { + it(`returns false for ${type} chart type`, () => { + const panelSchema = { type, queries: [{}] }; + expect(isOverlayEligible(panelSchema, validOldOptions)).toBe(false); + }); + }); + }); + + describe("old options validation", () => { + it("returns false when oldOptions is null", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, null)).toBe(false); + }); + + it("returns false when oldOptions is undefined", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, undefined)).toBe(false); + }); + + it("returns false when oldOptions has empty series", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, { series: [] })).toBe(false); + }); + + it("returns false when oldOptions has no series property", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, {})).toBe(false); + }); + }); + + describe("chart type change detection", () => { + it("returns false when chart type has changed", () => { + const panelSchema = { type: "bar", queries: [{}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _chartType: "line", + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(false); + }); + + it("returns true when chart type matches", () => { + const panelSchema = { type: "line", queries: [{}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _chartType: "line", + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(true); + }); + + it("returns true when _chartType is not set (no prior metadata)", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, validOldOptions)).toBe(true); + }); + }); + + describe("query count change detection", () => { + it("returns false when query count has changed", () => { + const panelSchema = { type: "line", queries: [{}, {}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _queryCount: 1, + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(false); + }); + + it("returns true when query count matches", () => { + const panelSchema = { type: "line", queries: [{}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _queryCount: 1, + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(true); + }); + + it("returns true when _queryCount is undefined (no prior metadata)", () => { + const panelSchema = { type: "line", queries: [{}] }; + expect(isOverlayEligible(panelSchema, validOldOptions)).toBe(true); + }); + + it("returns true when _queryCount is 0 and queries is empty", () => { + const panelSchema = { type: "line", queries: [] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _queryCount: 0, + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(true); + }); + + it("returns false when _queryCount is 0 but queries has entries", () => { + const panelSchema = { type: "line", queries: [{}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _queryCount: 0, + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(false); + }); + }); + + describe("combined guard conditions", () => { + it("returns false when both chart type and query count changed", () => { + const panelSchema = { type: "bar", queries: [{}, {}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _chartType: "line", + _queryCount: 1, + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(false); + }); + + it("returns true when _chartType is empty string (falsy, guard skipped)", () => { + const panelSchema = { type: "line", queries: [{}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + _chartType: "", + }; + // "" is falsy → `oldOptions._chartType &&` short-circuits → guard skipped → true + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(true); + }); + + it("returns true for eligible type with series and no prior metadata at all", () => { + const panelSchema = { type: "h-stacked", queries: [{}, {}] }; + const oldOptions = { + series: [{ name: "A", data: [[1, 2]] }], + // No _chartType, no _queryCount + }; + expect(isOverlayEligible(panelSchema, oldOptions)).toBe(true); + }); + }); +}); diff --git a/web/src/utils/dashboard/streaming/index.ts b/web/src/utils/dashboard/streaming/index.ts new file mode 100644 index 0000000000..2915966dea --- /dev/null +++ b/web/src/utils/dashboard/streaming/index.ts @@ -0,0 +1,59 @@ +// Copyright 2023 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 { overlayNewDataOnOldOptions } from "./overlayNewDataOnOldOptions"; + +/** + * Determine if overlay is eligible for the current panel configuration. + * Overlay is only appropriate for time-series charts with [timestamp, value] data format. + * Ineligible types (pie, donut, metric, gauge) have no timestamps to merge on. + */ +export function isOverlayEligible( + panelSchema: any, + oldOptions: any, +): boolean { + // Chart type must support time-series overlay + const eligibleTypes = new Set([ + "line", + "area", + "area-stacked", + "bar", + "stacked", + "h-bar", + "h-stacked", + "scatter", + ]); + if (!eligibleTypes.has(panelSchema.type)) return false; + + // No old data to overlay on + if (!oldOptions?.series?.length) return false; + + // Chart type must not have changed + if (oldOptions._chartType && oldOptions._chartType !== panelSchema.type) { + return false; + } + + // Query count must not have changed + if ( + oldOptions._queryCount !== undefined && + oldOptions._queryCount !== panelSchema.queries.length + ) { + return false; + } + + return true; +} + +export { overlayNewDataOnOldOptions }; diff --git a/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.spec.ts b/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.spec.ts new file mode 100644 index 0000000000..af1d018bdd --- /dev/null +++ b/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.spec.ts @@ -0,0 +1,1176 @@ +// 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, it, expect } from "vitest"; +import { overlayNewDataOnOldOptions } from "./overlayNewDataOnOldOptions"; + +// Helpers: timestamps in ms, boundary/query times in µs (function divides by 1000) +const ts = (min: number) => new Date(Date.UTC(2024, 0, 1, 0, min, 0)).getTime(); +const tsDate = (min: number) => new Date(Date.UTC(2024, 0, 1, 0, min, 0)); +const toMicro = (ms: number) => ms * 1000; +const INTERVAL_MS = 60_000; // 1 minute +const toNum = (v: any): number => + typeof v === "number" ? v : v instanceof Date ? v.getTime() : new Date(v).getTime(); + +function makeSeries( + name: string, + data: [Date, number | null][], + extra?: Record, +) { + return { name, data, type: "line", ...extra }; +} + +function makeOldSeries( + name: string, + data: [number, number | null][], + extra?: Record, +) { + // Old options after JSON clone have numeric timestamps (or ISO strings) + return { name, data, type: "line", ...extra }; +} + +function makeContainerSize(width = 800, height = 400) { + return { width, height }; +} + +describe("overlayNewDataOnOldOptions", () => { + describe("edge cases and fallbacks", () => { + it("returns oldOptions when newOptions has no series", () => { + const old = { series: [makeOldSeries("A", [[ts(0), 1]])] }; + const result = overlayNewDataOnOldOptions(old, { series: [] }); + expect(result).toBe(old); + }); + + it("returns oldOptions when newOptions series is undefined", () => { + const old = { series: [makeOldSeries("A", [[ts(0), 1]])] }; + const result = overlayNewDataOnOldOptions(old, {}); + expect(result).toBe(old); + }); + + it("returns newOptions when oldOptions is null", () => { + const newOpts = { series: [makeSeries("A", [[tsDate(0), 1]])] }; + const result = overlayNewDataOnOldOptions(null, newOpts); + expect(result).toBe(newOpts); + }); + + it("returns newOptions when oldOptions has no series", () => { + const newOpts = { series: [makeSeries("A", [[tsDate(0), 1]])] }; + const result = overlayNewDataOnOldOptions({ series: [] }, newOpts); + expect(result).toBe(newOpts); + }); + + it("returns newOptions when both old and new are null", () => { + const result = overlayNewDataOnOldOptions(null, null); + expect(result).toBeNull(); + }); + }); + + describe("first load (no old data) — phantom points", () => { + it("adds phantom point before first data point (LTR)", () => { + const newOpts = { + series: [makeSeries("A", [[tsDate(5), 10], [tsDate(6), 20]])], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), // queryStart + toMicro(ts(10)), // queryEnd + true, // isLTR + undefined, + INTERVAL_MS, // histogramIntervalMs + ); + + // Should have phantom at ts(4) prepended AND phantom at ts(7) appended + // Original 2 + leading phantom + trailing phantom = 4 + expect(result.series[0].data.length).toBe(4); + const phantomTs = result.series[0].data[0][0].getTime(); + expect(phantomTs).toBe(ts(4)); // 5min - 1min = 4min + expect(result.series[0].data[0][1]).toBeNull(); + }); + + it("adds phantom point after last data point (LTR)", () => { + const newOpts = { + series: [makeSeries("A", [[tsDate(5), 10], [tsDate(6), 20]])], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), // queryStart + toMicro(ts(10)), // queryEnd + true, + undefined, + INTERVAL_MS, + ); + + // Should have phantom at ts(7) appended + expect(result.series[0].data.length).toBe(4); // original 2 + 2 phantoms + const lastIdx = result.series[0].data.length - 1; + const phantomTs = result.series[0].data[lastIdx][0].getTime(); + expect(phantomTs).toBe(ts(7)); // 6min + 1min = 7min + expect(result.series[0].data[lastIdx][1]).toBeNull(); + }); + + it("does not add phantom before if it would be before queryStart", () => { + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), // queryStart = same as first data point + toMicro(ts(10)), + true, + undefined, + INTERVAL_MS, + ); + + // Phantom at ts(-1) would be before queryStart — not added + // Only trailing phantom added + expect(result.series[0].data.length).toBe(2); + expect(result.series[0].data[0][0].getTime()).toBe(ts(0)); + }); + + it("does not add phantom after if it would be after queryEnd", () => { + const newOpts = { + series: [makeSeries("A", [[tsDate(10), 10]])], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), + toMicro(ts(10)), // queryEnd = same as last data point + true, + undefined, + INTERVAL_MS, + ); + + // Phantom at ts(11) would be after queryEnd — not added + // Only leading phantom added + expect(result.series[0].data.length).toBe(2); + expect(result.series[0].data[1][0].getTime()).toBe(ts(10)); + }); + + it("skips phantom for series with null name", () => { + const newOpts = { + series: [{ name: null, data: [[tsDate(5), 10]], type: "line" }], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), + toMicro(ts(10)), + true, + undefined, + INTERVAL_MS, + ); + expect(result.series[0].data.length).toBe(1); // unchanged + }); + + it("skips phantom for series with non-array data points", () => { + const newOpts = { + series: [{ name: "A", data: [100, 200], type: "line" }], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), + toMicro(ts(10)), + true, + undefined, + INTERVAL_MS, + ); + expect(result.series[0].data.length).toBe(2); // unchanged + }); + + it("skips phantom when histogramIntervalMs is 0", () => { + const newOpts = { + series: [makeSeries("A", [[tsDate(5), 10]])], + }; + const result = overlayNewDataOnOldOptions( + { series: [] }, + newOpts, + undefined, + undefined, + toMicro(ts(0)), + toMicro(ts(10)), + true, + undefined, + 0, // no interval + ); + expect(result.series[0].data.length).toBe(1); // unchanged + }); + }); + + describe("LTR overlay — merge old data after new", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + + it("appends stale old data after new data's last timestamp", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [ + [ts(0), 1], [ts(1), 2], [ts(2), 3], + [ts(6), 6], [ts(7), 7], [ts(8), 8], + ]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [ + [tsDate(0), 10], [tsDate(1), 20], [tsDate(2), 30], + ]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + // New data: ts(0..2) + phantom at ts(3) + old stale: ts(6), ts(7), ts(8) + const seriesA = result.series[0]; + // New data preserved + expect(seriesA.data[0][1]).toBe(10); + expect(seriesA.data[1][1]).toBe(20); + expect(seriesA.data[2][1]).toBe(30); + // Stale old data appended + const staleValues = seriesA.data + .filter((p: any) => p[1] !== null && p[1] > 5) + .filter((p: any) => typeof p[1] === "number" && p[1] !== 10 && p[1] !== 20 && p[1] !== 30); + expect(staleValues.length).toBe(3); + }); + + it("inserts phantom point at lastNewTime + interval", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(3), 3], [ts(5), 5]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(0), 10], [tsDate(1), 20]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // Phantom at ts(2) = ts(1) + 60s. Old has no data at ts(2), so phantom value = null + const phantom = seriesA.data.find( + (p: any) => { + const t = p[0] instanceof Date ? p[0].getTime() : p[0]; + return t === ts(2); + }, + ); + expect(phantom).toBeDefined(); + expect(phantom[1]).toBeNull(); + }); + + it("uses old data value for phantom when old has data at that timestamp", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(2), 99], [ts(5), 5]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(0), 10], [tsDate(1), 20]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // Phantom at ts(2) = ts(1) + 60s. Old has value 99 at ts(2) + const phantom = seriesA.data.find( + (p: any) => { + const t = p[0] instanceof Date ? p[0].getTime() : p[0]; + return t === ts(2); + }, + ); + expect(phantom).toBeDefined(); + expect(phantom[1]).toBe(99); + }); + + it("does not append old data beyond queryEnd", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(5), 5], [ts(11), 11]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(0), 10]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // ts(11) is beyond queryEnd — should not appear + const beyondEnd = seriesA.data.find((p: any) => { + const t = p[0] instanceof Date ? p[0].getTime() : p[0]; + return t === ts(11); + }); + expect(beyondEnd).toBeUndefined(); + }); + }); + + describe("RTL overlay — merge old data before new", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + + it("prepends stale old data before new data's first timestamp", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [ + [ts(0), 1], [ts(1), 2], [ts(2), 3], + [ts(8), 8], [ts(9), 9], + ]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [ + [tsDate(8), 80], [tsDate(9), 90], [tsDate(10), 100], + ]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // Old stale data ts(0..2) should be prepended + const staleValues = seriesA.data.filter((p: any) => { + const val = p[1]; + return val === 1 || val === 2 || val === 3; + }); + expect(staleValues.length).toBe(3); + }); + + it("inserts phantom point at firstNewTime - interval (RTL)", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(5), 5]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(8), 80], [tsDate(9), 90]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // Phantom at ts(7) = ts(8) - 60s + const phantom = seriesA.data.find( + (p: any) => { + const t = p[0] instanceof Date ? p[0].getTime() : p[0]; + return t === ts(7); + }, + ); + expect(phantom).toBeDefined(); + }); + + it("does not prepend old data before queryStart", () => { + const qStart = toMicro(ts(2)); + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 0], [ts(1), 1], [ts(3), 3]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(8), 80]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, qStart, queryEnd, false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // ts(0), ts(1) are before queryStart — should not appear + const beforeStart = seriesA.data.filter((p: any) => { + const t = p[0] instanceof Date ? p[0].getTime() : p[0]; + return t < ts(2); + }); + expect(beforeStart.length).toBe(0); + }); + }); + + describe("stale series — series in old but not in new", () => { + it("adds old-only series with reduced opacity", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1]]), + makeOldSeries("B", [[ts(0), 2]], { lineStyle: { width: 2 } }), + ], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + + expect(result.series.length).toBe(2); + const staleSeries = result.series.find((s: any) => s.name === "B"); + expect(staleSeries).toBeDefined(); + expect(staleSeries.lineStyle.opacity).toBe(0.3); + expect(staleSeries.itemStyle.opacity).toBe(0.3); + }); + + it("preserves areaStyle with reduced opacity on stale series", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1]], { areaStyle: { color: "blue" } }), + ], + }; + const newOpts = { + series: [makeSeries("B", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + + const staleSeries = result.series.find((s: any) => s.name === "A"); + expect(staleSeries.areaStyle.opacity).toBe(0.3); + expect(staleSeries.areaStyle.color).toBe("blue"); + }); + + it("filters stale series data to query range", () => { + const queryStart = toMicro(ts(2)); + const queryEnd = toMicro(ts(8)); + const oldOpts = { + series: [ + makeOldSeries("OLD", [ + [ts(0), 0], [ts(1), 1], // before range + [ts(3), 3], [ts(5), 5], // in range + [ts(9), 9], [ts(11), 11], // after range + ]), + ], + }; + const newOpts = { + series: [makeSeries("NEW", [[tsDate(3), 30]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + undefined, queryStart, queryEnd, true, + ); + + const staleSeries = result.series.find((s: any) => s.name === "OLD"); + // Only ts(3) and ts(5) are within [ts(2), ts(8)] + expect(staleSeries.data.length).toBe(2); + }); + + it("skips stale series with null name", () => { + const oldOpts = { + series: [ + { name: null, data: [[ts(0), 1]], type: "line" }, + ], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + // Only series A, null-named should not be added + expect(result.series.length).toBe(1); + expect(result.series[0].name).toBe("A"); + }); + }); + + describe("yAxis nameGap preservation", () => { + it("preserves larger old nameGap", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + yAxis: { nameGap: 50 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + yAxis: { nameGap: 30 }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.yAxis.nameGap).toBe(50); + }); + + it("uses new nameGap when it is larger", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + yAxis: { nameGap: 20 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + yAxis: { nameGap: 40 }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.yAxis.nameGap).toBe(40); + }); + }); + + describe("grid.bottom preservation", () => { + it("preserves larger old grid.bottom", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: { bottom: 80 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: { bottom: 50 }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.grid.bottom).toBe(80); + }); + + it("uses new grid.bottom when it is larger", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: { bottom: 30 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: { bottom: 60 }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.grid.bottom).toBe(60); + }); + + it("handles grid as array", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: [{ bottom: 100 }], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: [{ bottom: 40 }], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.grid[0].bottom).toBe(100); + }); + }); + + describe("legend recalculation", () => { + it("updates legend data to include all series names", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1]]), + makeOldSeries("B", [[ts(0), 2]]), + ], + legend: { data: ["A", "B"] }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + legend: { data: ["A"] }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + // B is stale and added, so legend should include both + expect(result.legend.data).toContain("A"); + expect(result.legend.data).toContain("B"); + }); + + it("handles legend as array", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1]]), + makeOldSeries("B", [[ts(0), 2]]), + ], + legend: [{ data: ["A", "B"] }], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + legend: [{ data: ["A"] }], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.legend[0].data).toContain("A"); + expect(result.legend[0].data).toContain("B"); + }); + }); + + describe("graphic overlay rendering", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const container = makeContainerSize(); + + it("adds graphic overlay for LTR streaming", () => { + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, container, + boundary, queryStart, queryEnd, true, "#5156BE", + ); + + expect(result.graphic).toBeDefined(); + expect(result.graphic.length).toBe(1); + expect(result.graphic[0].type).toBe("rect"); + expect(result.graphic[0].style.fill).toBe("#5156BE"); + expect(result.graphic[0].style.opacity).toBe(0.05); + expect(result.graphic[0].silent).toBe(true); + // LTR: overlay on left side + expect(result.graphic[0].left).toBe(50); // plotLeft + }); + + it("adds graphic overlay for RTL streaming", () => { + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, container, + boundary, queryStart, queryEnd, false, "#5156BE", + ); + + expect(result.graphic).toBeDefined(); + expect(result.graphic[0].type).toBe("rect"); + // RTL: overlay on right side + expect(result.graphic[0].left).toBeGreaterThan(50); + }); + + it("uses fallback grid config when _gridRect is not available", () => { + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: { left: 40, right: 20, top: 10, bottom: 50 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: { left: 40, right: 20, top: 10, bottom: 50 }, + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, container, + boundary, queryStart, queryEnd, true, + ); + + expect(result.graphic).toBeDefined(); + // Uses fallback: plotLeft = grid.left = 40 + expect(result.graphic[0].left).toBe(40); + }); + + it("uses default grey color when primaryColor is not provided", () => { + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, container, + boundary, queryStart, queryEnd, true, + ); + + expect(result.graphic[0].style.fill).toBe("#808080"); + }); + + it("does not add graphic when containerSize is 0", () => { + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, { width: 0, height: 0 }, + boundary, queryStart, queryEnd, true, + ); + + expect(result.graphic).toBeUndefined(); + }); + + it("does not add graphic when boundary is 0", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, container, + 0, queryStart, queryEnd, true, + ); + + expect(result.graphic).toBeUndefined(); + }); + }); + + describe("shallow copy preservation", () => { + it("preserves function references from newOptions (formatters)", () => { + const formatter = (val: any) => `${val}%`; + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + yAxis: { axisLabel: { formatter } }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + expect(result.yAxis.axisLabel.formatter).toBe(formatter); + }); + + it("does not mutate original newOptions series data", () => { + const originalData: [Date, number][] = [[tsDate(0), 10]]; + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1], [ts(5), 5]])], + }; + const newOpts = { + series: [makeSeries("A", [...originalData])], + }; + + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(5)), queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + // Original newOpts series data should not be mutated + expect(newOpts.series[0].data.length).toBe(1); + }); + }); + + describe("null values in series data", () => { + it("preserves genuine null y-values in new data during LTR overlay", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1], [ts(1), 2], [ts(5), 5]])], + }; + const newOpts = { + series: [ + makeSeries("A", [ + [tsDate(0), 10], + [tsDate(1), null], // genuine null — should be preserved + ]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(2)), toMicro(ts(0)), toMicro(ts(10)), + true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + // The null y-value at ts(1) should be preserved, not replaced with old data + const pointAt1 = seriesA.data.find( + (d: any) => toNum(d[0]) === ts(1), + ); + expect(pointAt1).toBeDefined(); + expect(pointAt1[1]).toBeNull(); + }); + + it("preserves genuine null y-values in new data during RTL overlay", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1], [ts(5), 5], [ts(10), 10]])], + }; + const newOpts = { + series: [ + makeSeries("A", [ + [tsDate(5), null], // genuine null + [tsDate(10), 100], + ]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(5)), toMicro(ts(0)), toMicro(ts(10)), + false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + const pointAt5 = seriesA.data.find( + (d: any) => toNum(d[0]) === ts(5), + ); + expect(pointAt5).toBeDefined(); + expect(pointAt5[1]).toBeNull(); + }); + + it("handles series where all new data points have null values", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1], [ts(5), 5]])], + }; + const newOpts = { + series: [ + makeSeries("A", [ + [tsDate(0), null], + [tsDate(1), null], + ]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(2)), toMicro(ts(0)), toMicro(ts(10)), + true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + expect(seriesA).toBeDefined(); + // Both null values should be preserved + const newPoints = seriesA.data.filter( + (d: any) => { + const t = toNum(d[0]); + return t === ts(0) || t === ts(1); + }, + ); + expect(newPoints).toHaveLength(2); + expect(newPoints[0][1]).toBeNull(); + expect(newPoints[1][1]).toBeNull(); + }); + + it("handles old series with null y-values in stale data", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), null], [ts(5), 5], [ts(10), null]])], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(5), 50], [tsDate(10), 100]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(5)), toMicro(ts(0)), toMicro(ts(10)), + false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + // Old stale point at ts(0) with null value should be prepended + const stalePoint = seriesA.data.find( + (d: any) => toNum(d[0]) === ts(0), + ); + expect(stalePoint).toBeDefined(); + expect(stalePoint[1]).toBeNull(); + }); + }); + + describe("series with null name (annotation series)", () => { + it("skips annotation series during merge", () => { + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(5), 5]]), + { name: null, data: [[ts(0), 100]], type: "line" }, + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(0), 10]]), + { name: null, data: [[tsDate(0), 200]], type: "line" }, + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(5)), toMicro(ts(0)), toMicro(ts(10)), true, undefined, INTERVAL_MS, + ); + + // Annotation series should pass through unchanged + const annotationSeries = result.series.filter((s: any) => s.name == null); + expect(annotationSeries.length).toBe(1); + expect(annotationSeries[0].data[0][1]).toBe(200); + }); + }); + + describe("grid with string values (parseInt fallback)", () => { + it("parses grid.top as string via parseInt for graphic overlay", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: { left: 40, right: 20, top: "15px", bottom: 50 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: { left: 40, right: 20, top: "15px", bottom: 50 }, + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, makeContainerSize(), + boundary, queryStart, queryEnd, true, + ); + + expect(result.graphic).toBeDefined(); + // top parsed as parseInt("15px") = 15 + expect(result.graphic[0].top).toBe(15); + }); + }); + + describe("multiple series overlay", () => { + it("overlays multiple series independently in LTR", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(5), 5], [ts(8), 8]]), + makeOldSeries("B", [[ts(0), 10], [ts(5), 50], [ts(9), 90]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(0), 100], [tsDate(1), 200]]), + makeSeries("B", [[tsDate(0), 1000], [tsDate(2), 2000]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + const seriesB = result.series.find((s: any) => s.name === "B"); + + // A: new data at ts(0),ts(1) + phantom + stale old after ts(1) + expect(seriesA.data[0][1]).toBe(100); + expect(seriesA.data[1][1]).toBe(200); + + // B: new data at ts(0),ts(2) + phantom + stale old after ts(2) + expect(seriesB.data[0][1]).toBe(1000); + expect(seriesB.data[1][1]).toBe(2000); + }); + }); + + describe("RTL phantom with old data value", () => { + it("uses old data value for RTL phantom when old has data at phantom timestamp", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(4), 44], [ts(7), 7]]), + ], + }; + const newOpts = { + series: [ + makeSeries("A", [[tsDate(5), 50], [tsDate(6), 60]]), + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, false, undefined, INTERVAL_MS, + ); + + const seriesA = result.series[0]; + // Phantom at ts(4) = ts(5) - 60s. Old has value 44 at ts(4) + const phantom = seriesA.data.find( + (p: any) => toNum(p[0]) === ts(4), + ); + expect(phantom).toBeDefined(); + expect(phantom[1]).toBe(44); + }); + }); + + describe("graphic overlay edge cases", () => { + it("does not add graphic when freshFraction is 0 (LTR, boundary at queryStart)", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(0)); // boundary at start → freshFraction = 0 + + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, makeContainerSize(), + boundary, queryStart, queryEnd, true, "#5156BE", // LTR + ); + + // LTR: freshFraction = (boundaryMs - queryStartMs) / totalRange = 0 → no graphic + expect(result.graphic).toBeUndefined(); + }); + + it("does not add graphic when containerSize is not provided", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + toMicro(ts(5)), toMicro(ts(0)), toMicro(ts(10)), true, "#5156BE", + ); + + expect(result.graphic).toBeUndefined(); + }); + + it("clamps overlay to plot rect boundaries", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(9)); // near-full LTR coverage + + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + _gridRect: { x: 50, y: 10, width: 700, height: 350 }, + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, makeContainerSize(), + boundary, queryStart, queryEnd, true, "#5156BE", + ); + + expect(result.graphic).toBeDefined(); + // LTR: overlay width = 90% of plotWidth ≈ 630 + expect(result.graphic[0].left).toBe(50); // clamped to plotLeft + expect(result.graphic[0].shape.width).toBeLessThanOrEqual(700); + }); + }); + + describe("non-array data points in series", () => { + it("skips overlay for series with plain value data (not [timestamp, value])", () => { + const queryStart = toMicro(ts(0)); + const queryEnd = toMicro(ts(10)); + const boundary = toMicro(ts(5)); + + const oldOpts = { + series: [ + makeOldSeries("A", [[ts(0), 1], [ts(5), 5]]), + ], + }; + const newOpts = { + series: [ + { name: "A", data: [10, 20, 30], type: "bar" }, // plain values + ], + }; + + const result = overlayNewDataOnOldOptions( + oldOpts, newOpts, undefined, + boundary, queryStart, queryEnd, true, undefined, INTERVAL_MS, + ); + + const seriesA = result.series.find((s: any) => s.name === "A"); + // Data should remain as-is (no merge attempted) + expect(seriesA.data).toEqual([10, 20, 30]); + }); + }); + + describe("yAxis and grid edge cases", () => { + it("handles missing yAxis in both old and new options", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + // Should not crash and result should have no yAxis modifications + expect(result).toBeDefined(); + }); + + it("handles grid.bottom as array with multiple entries", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + grid: [{ bottom: 100 }, { bottom: 50 }], + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + grid: [{ bottom: 40 }, { bottom: 60 }], + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + // Only first grid entry is compared; old (100) > new (40) → preserved + expect(result.grid[0].bottom).toBe(100); + // Second entry preserved from new + expect(result.grid[1].bottom).toBe(60); + }); + + it("handles yAxis nameGap when old has non-numeric nameGap", () => { + const oldOpts = { + series: [makeOldSeries("A", [[ts(0), 1]])], + yAxis: { nameGap: "50" }, // string, not number + }; + const newOpts = { + series: [makeSeries("A", [[tsDate(0), 10]])], + yAxis: { nameGap: 30 }, + }; + + const result = overlayNewDataOnOldOptions(oldOpts, newOpts); + // typeof "50" !== "number" → falls back to 0, new (30) > 0 → uses new + expect(result.yAxis.nameGap).toBe(30); + }); + }); +}); diff --git a/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.ts b/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.ts new file mode 100644 index 0000000000..08e6dece75 --- /dev/null +++ b/web/src/utils/dashboard/streaming/overlayNewDataOnOldOptions.ts @@ -0,0 +1,443 @@ +// Copyright 2023 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 . + +/** + * Extract a numeric timestamp (ms) from a data point's x-value. + * After JSON.parse(JSON.stringify()), Date objects become ISO strings. + * We normalize to numeric ms for reliable Set comparison. + */ +function toNumericTime(val: any): number { + if (typeof val === "number") return val; + if (val instanceof Date) return val.getTime(); + if (typeof val === "string") return new Date(val).getTime(); + return NaN; +} + +/** + * Overlay new chunk options on top of old rendered options. + * New data takes priority, old data fills remaining time gaps. + * + * @param oldOptions - Snapshot of old rendered chart (ISO string timestamps after JSON clone) + * @param newOptions - Complete options built from new accumulated data (Date objects) + * @param containerSize - Chart container dimensions for graphic overlay positioning + * @param boundaryTime - Boundary (µs) separating fresh from stale data. RTL: last + * chunk's start_time (fresh data is to the RIGHT of boundary). LTR: last chunk's end_time + * (fresh data is to the LEFT of boundary). + * @param queryStartTime - User-selected query start (µs), used as the left edge anchor. + * @param queryEndTime - User-selected query end (µs), used as the right edge anchor. + * @param isLTR - true for LTR streaming (overlay on LEFT/fresh), false for RTL (overlay on RIGHT/fresh). + * @param primaryColor - hex color string for the overlay (e.g. '#5156BE'). Falls back to grey if omitted. + * @param histogramIntervalMs - histogram bucket interval in milliseconds (from resultMetaData). + * Used for phantom point placement instead of deriving from data points. + * @returns Merged options with new data overlaid on old + */ +export function overlayNewDataOnOldOptions( + oldOptions: any, + newOptions: any, + containerSize?: { width: number; height: number }, + boundaryTime?: number, + queryStartTime?: number, + queryEndTime?: number, + isLTR?: boolean, + primaryColor?: string, + histogramIntervalMs?: number, +): any { + if (!newOptions?.series?.length) return oldOptions ?? newOptions; + + // When there is no old data (first load / no cache), add phantom + // anchor points so the chart shows the full time range and correct + // bar width even when only partial streaming data has arrived. + if (!oldOptions?.series?.length) { + const qStartMs = queryStartTime ? queryStartTime / 1000 : 0; + const qEndMs = queryEndTime ? queryEndTime / 1000 : 0; + const intervalMs = histogramIntervalMs ?? 0; + + if (qStartMs > 0 && qEndMs > 0) { + for (const series of newOptions.series) { + if ( + series.name == null || + !Array.isArray(series.data) || + !series.data.length + ) + continue; + const firstPoint = series.data[0]; + if (!Array.isArray(firstPoint) || firstPoint.length < 2) continue; + + const firstTs = toNumericTime(series.data[0]?.[0]); + const lastTs = toNumericTime( + series.data[series.data.length - 1]?.[0], + ); + + // Only add interval phantoms (one bucket before/after data edges) + // to establish the correct min-interval for bar width. + // We do NOT add range anchors — xAxis.min/max already pins the + // chart range, and null anchors still skew min-interval calculation. + if (!isNaN(firstTs) && intervalMs > 0) { + const phantomTs = firstTs - intervalMs; + if (phantomTs >= qStartMs) { + series.data.unshift([new Date(phantomTs), null]); + } + } + + if (!isNaN(lastTs) && intervalMs > 0) { + const phantomTs = lastTs + intervalMs; + if (phantomTs <= qEndMs) { + series.data.push([new Date(phantomTs), null]); + } + } + } + } + return newOptions; + } + + // Shallow-spread newOptions so functions (yAxis.axisLabel.formatter, tooltip.formatter, etc.) + // are preserved by reference. Only deep-clone series & legend since we mutate their data. + // JSON.parse(JSON.stringify()) strips functions — using it on the full object caused + // y-axis formatter loss and unformatted numbers during streaming. + const merged: any = { ...newOptions }; + merged.series = newOptions.series.map((series: any) => { + if (Array.isArray(series?.data)) { + return { ...series, data: series.data.slice() }; + } + return { ...series }; + }); + if (newOptions.legend) { + merged.legend = Array.isArray(newOptions.legend) + ? newOptions.legend.map((legendItem: any) => ({ ...legendItem })) + : { ...newOptions.legend }; + } + + // Preserve the maximum yAxis nameGap seen across streaming chunks. + // nameGap is computed from the longest formatted Y-value in the current partial data. + // Early chunks have smaller Y values ΓåÆ shorter strings ΓåÆ smaller nameGap, causing the + // Y-axis name label (e.g. "Metric_Name") to overlap tick labels mid-stream. + // By taking max(old, new) we ensure nameGap only grows, never shrinks, during streaming. + if (oldOptions.yAxis && newOptions.yAxis) { + const oldNameGap = + typeof oldOptions.yAxis.nameGap === "number" + ? oldOptions.yAxis.nameGap + : 0; + const newNameGap = + typeof newOptions.yAxis?.nameGap === "number" + ? newOptions.yAxis.nameGap + : 0; + if (oldNameGap > newNameGap) { + merged.yAxis = { ...newOptions.yAxis, nameGap: oldNameGap }; + } + } + + // Preserve the maximum grid.bottom seen across streaming chunks. + // For non-time-series categorical charts with rotated x-axis labels, grid.bottom + // includes `additionalBottomSpace` which is derived from the longest x-axis category + // label string in the current partial data. Early chunks may have shorter category + // names ΓåÆ smaller additionalBottomSpace ΓåÆ smaller grid.bottom. As more data arrives + // with longer category labels, grid.bottom grows to its correct final value, causing + // the plot area to jump. Taking max(old, new) prevents it from shrinking mid-stream. + const oldGrid = oldOptions.grid?.[0] ?? oldOptions.grid; + const newGrid = newOptions.grid?.[0] ?? newOptions.grid; + if (oldGrid && newGrid) { + const oldBottom = typeof oldGrid.bottom === "number" ? oldGrid.bottom : 0; + const newBottom = typeof newGrid.bottom === "number" ? newGrid.bottom : 0; + if (oldBottom > newBottom) { + const mergedGrid = { ...newGrid, bottom: oldBottom }; + merged.grid = Array.isArray(newOptions.grid) + ? [mergedGrid, ...newOptions.grid.slice(1)] + : mergedGrid; + } + } + + // Convert boundary values from µs to ms once for all series. + const queryStartMs = queryStartTime ? queryStartTime / 1000 : 0; + const queryEndMs = queryEndTime ? queryEndTime / 1000 : 0; + + // Boundary-based range splitting: use the four values (queryStart, queryEnd, + // boundaryTime, isLTR) to cleanly separate fresh data (from new chunks) and + // stale data (from old chart). No per-point null replacement — genuine nulls + // in new data are preserved, and old data only fills the stale range. + for (const newSeries of merged.series) { + // Skip annotation series (no name — undefined/null, not empty string) + if (newSeries.name == null) continue; + + const oldSeries = oldOptions.series.find( + (s: any) => s.name === newSeries.name, + ); + if (!oldSeries?.data?.length || !Array.isArray(oldSeries.data)) continue; + + // Skip non-time-series data (plain values, not [timestamp, value] pairs) + const firstPoint = newSeries.data?.[0]; + if (!Array.isArray(firstPoint) || firstPoint.length < 2) continue; + + // Build old data lookup by timestamp for phantom and stale data filtering. + const oldDataByTime = new Map(); + for (const oldPoint of oldSeries.data) { + if (Array.isArray(oldPoint) && oldPoint.length >= 2) { + const t = toNumericTime(oldPoint[0]); + if (!isNaN(t)) { + oldDataByTime.set(t, oldPoint); + } + } + } + + const intervalMs = histogramIntervalMs ?? 0; + + if (isLTR) { + // LTR: new data covers [queryStart → chunkEnd]. + const lastNewTime = toNumericTime( + newSeries.data[newSeries.data.length - 1]?.[0], + ); + + if (!isNaN(lastNewTime)) { + // Compute phantom timestamp (one interval after last new point). + const phantomTime = + intervalMs > 0 ? lastNewTime + intervalMs : 0; + const phantomInRange = phantomTime > 0 && phantomTime <= queryEndMs; + + // Collect stale old data AFTER last new timestamp, + // excluding the phantom timestamp to avoid duplicates. + const staleOldData: any[] = []; + let phantomOldValue: any = null; + + oldDataByTime.forEach((oldPoint, t) => { + if (phantomInRange && t === phantomTime) { + // Reserve this point's value for the phantom + phantomOldValue = oldPoint[1]; + } else if (t > lastNewTime && t <= queryEndMs) { + staleOldData.push(oldPoint); + } + }); + + // Add phantom with old data value for visual continuity & bar width. + // Falls back to null when old data has no value at that timestamp + // (null still contributes the timestamp for bar width derivation). + if (phantomInRange) { + newSeries.data.push([ + new Date(phantomTime), + phantomOldValue ?? null, + ]); + } + + // Append remaining stale old data + if (staleOldData.length) { + newSeries.data = [...newSeries.data, ...staleOldData]; + } + } + } else { + // RTL: new data covers [chunkStart → queryEnd]. + const firstNewTime = toNumericTime(newSeries.data[0]?.[0]); + + if (!isNaN(firstNewTime)) { + // Compute phantom timestamp (one interval before first new point). + const phantomTime = + intervalMs > 0 ? firstNewTime - intervalMs : 0; + const phantomInRange = phantomTime > 0 && phantomTime >= queryStartMs; + + // Collect stale old data BEFORE first new timestamp, + // excluding the phantom timestamp to avoid duplicates. + const staleOldData: any[] = []; + let phantomOldValue: any = null; + + oldDataByTime.forEach((oldPoint, t) => { + if (phantomInRange && t === phantomTime) { + phantomOldValue = oldPoint[1]; + } else if (t < firstNewTime && t >= queryStartMs) { + staleOldData.push(oldPoint); + } + }); + + // Add phantom with old data value for visual continuity & bar width. + if (phantomInRange) { + newSeries.data = [ + [new Date(phantomTime), phantomOldValue ?? null], + ...newSeries.data, + ]; + } + + // Prepend remaining stale old data + if (staleOldData.length) { + newSeries.data = [...staleOldData, ...newSeries.data]; + } + } + } + } + + // For series in OLD that don't exist in NEW yet: + // Keep them visible with reduced opacity so user knows they're stale. + // Filter their data to the new time range so they don't stretch the x-axis + // (e.g. old 1-week series shouldn't expand a 1-day chart). + const hasValidQueryRange = queryStartMs > 0 && queryEndMs > 0; + for (const oldSeries of oldOptions.series) { + if (oldSeries.name == null) continue; + const existsInNew = merged.series.some( + (s: any) => s.name === oldSeries.name, + ); + if (!existsInNew) { + const cloned = { + ...oldSeries, + data: Array.isArray(oldSeries.data) + ? oldSeries.data.slice() + : oldSeries.data, + }; + + // Filter old data points to the user's query time range + if (hasValidQueryRange && Array.isArray(cloned.data)) { + cloned.data = cloned.data.filter((point: any) => { + if (Array.isArray(point) && point.length >= 2) { + const t = toNumericTime(point[0]); + return !isNaN(t) && t >= queryStartMs && t <= queryEndMs; + } + return false; + }); + } + + merged.series.push({ + ...cloned, + lineStyle: { + ...(oldSeries.lineStyle || {}), + opacity: 0.3, + }, + itemStyle: { + ...(oldSeries.itemStyle || {}), + opacity: 0.3, + }, + ...(oldSeries.areaStyle + ? { + areaStyle: { + ...oldSeries.areaStyle, + opacity: 0.3, + }, + } + : {}), + }); + } + } + + // Recalculate legend to include all visible series + if (merged.legend) { + const allNames = merged.series + .filter((s: any) => s.name) + .map((s: any) => s.name); + if (Array.isArray(merged.legend)) { + merged.legend.forEach((l: any) => { + if (l.data) l.data = allNames; + }); + } else if (merged.legend.data) { + merged.legend.data = allNames; + } + } + + // Add a subtle overlay on the FRESH portion of the chart during streaming. + // Only covers the time range where new data has already arrived, so the user + // can see which part of the chart is fresh vs stale. + // + // Uses ECharts graphic component which renders directly on the canvas. + // This function is only called during streaming; the final render bypasses it, + // so the overlay disappears automatically when streaming completes. + // + // IMPORTANT: Must use `left/top + shape: { width, height }` instead of + // `left/right/top/bottom` positioning because ChartRenderer uses `notMerge: true` + // in setOption, and ECharts doesn't resolve right/bottom layout with notMerge. + if (containerSize && containerSize.width > 0 && containerSize.height > 0) { + // Prefer the actual grid pixel rect captured from the ECharts instance. + // With containLabel: true, the raw grid config values don't match the + // actual plot area — ECharts internally adjusts for axis label widths. + const gridRect = oldOptions._gridRect; + + let plotLeft: number; + let plotTop: number; + let plotWidth: number; + let plotHeight: number; + + if (gridRect) { + plotLeft = gridRect.x; + plotTop = gridRect.y; + plotWidth = gridRect.width; + plotHeight = gridRect.height; + } else { + // Fallback: estimate from raw grid config (less accurate with containLabel) + const grid = merged.grid?.[0] || merged.grid || {}; + const gridLeft = typeof grid.left === "number" ? grid.left : 30; + const gridRight = typeof grid.right === "number" ? grid.right : 20; + const gridTopVal = + typeof grid.top === "number" ? grid.top : parseInt(grid.top) || 10; + const gridBottom = typeof grid.bottom === "number" ? grid.bottom : 50; + + plotLeft = gridLeft; + plotTop = gridTopVal; + plotWidth = containerSize.width - gridLeft - gridRight; + plotHeight = containerSize.height - gridTopVal - gridBottom; + } + + if (plotWidth > 0 && plotHeight > 0) { + const boundaryMs = boundaryTime ? boundaryTime / 1000 : 0; + const totalRange = queryEndMs - queryStartMs; + const hasValidRange = boundaryMs > 0 && totalRange > 0; + + if (hasValidRange) { + // Compute the fresh fraction (portion where new data has arrived) and + // overlay it on that edge. LTR chunks arrive earliest-first, so fresh + // data is on the LEFT up to boundaryTime. RTL chunks arrive newest-first, + // so fresh data is on the RIGHT from boundaryTime to queryEnd. + const freshFraction = Math.max( + 0, + Math.min( + 1, + (isLTR ? boundaryMs - queryStartMs : queryEndMs - boundaryMs) / + totalRange, + ), + ); + const rawOverlayWidth = freshFraction * plotWidth; + const rawOverlayLeft = isLTR + ? plotLeft + : plotLeft + plotWidth - rawOverlayWidth; + + // Clamp to plot rect so the overlay never bleeds onto y-axis labels + // (this can happen when _gridRect is stale or when fallback grid + // coordinates underestimate the axis-label width). + const plotRight = plotLeft + plotWidth; + const overlayLeft = Math.max( + plotLeft, + Math.min(plotRight, rawOverlayLeft), + ); + const overlayRight = Math.max( + plotLeft, + Math.min(plotRight, rawOverlayLeft + rawOverlayWidth), + ); + const overlayWidth = overlayRight - overlayLeft; + + if (overlayWidth > 1 && freshFraction > 0) { + merged.graphic = [ + { + type: "rect", + left: overlayLeft, + top: plotTop, + shape: { + width: overlayWidth, + height: plotHeight, + }, + style: { + fill: primaryColor ?? "#808080", + opacity: 0.05, // 5% opacity + }, + silent: true, + z: 0, + }, + ]; + } + } + } + } + + return merged; +}