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