fix: dashboard shows no data while chart is in rendering state #11205 (#11332)

This commit is contained in:
Abhay Padamani
2026-04-27 11:23:05 +00:00
committed by GitHub
parent 20ed7ee5de
commit 3cff28ce45
22 changed files with 3293 additions and 224 deletions
@@ -103,6 +103,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<ChartRenderer
v-else
ref="chartRendererRef"
:data="
panelSchema.queryType === 'promql' ||
(panelData.chartType != 'geomap' &&
@@ -356,12 +357,16 @@ import useNotifications from "@/composables/useNotifications";
import { validateSQLPanelFields } from "@/utils/dashboard/panelValidation";
import { useAnnotationsData } from "@/composables/dashboard/useAnnotationsData";
import LoadingProgress from "@/components/common/LoadingProgress.vue";
import { throttle } from "lodash-es";
import {
usePanelAlertCreation,
usePanelDownload,
} from "@/composables/dashboard/usePanelActions";
import { usePanelDrilldown } from "@/composables/dashboard/usePanelDrilldown";
import {
overlayNewDataOnOldOptions,
isOverlayEligible,
} from "@/utils/dashboard/streaming";
import { detectChunkingDirection } from "@/utils/dashboard/chunkingDirection";
const ChartRenderer = defineAsyncComponent(() => {
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,
@@ -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,
@@ -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;
@@ -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);
});
@@ -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();
+19 -1
View File
@@ -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,
+4 -12
View File
@@ -974,7 +974,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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") }}</q-btn
>
@@ -1022,12 +1022,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: t("search.runQuery")
}}
</q-btn>
<q-separator
v-if="visualizeSearchRequestTraceIds.length === 0"
class="tw:h-[29px] tw:w-[1px]"
/>
<q-separator class="tw:h-[29px] tw:w-[1px]" />
<q-btn-dropdown
v-if="visualizeSearchRequestTraceIds.length === 0"
flat
class="tw:h-[29px] search-button-dropdown"
:class="[
@@ -1097,7 +1093,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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") }}</q-btn
>
@@ -1146,12 +1142,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: t("search.runQuery")
}}
</q-btn>
<q-separator
v-if="visualizeSearchRequestTraceIds.length === 0"
class="tw:h-[29px] tw:w-[1px]"
/>
<q-separator class="tw:h-[29px] tw:w-[1px]" />
<q-btn-dropdown
v-if="visualizeSearchRequestTraceIds.length === 0"
flat
class="tw:h-[29px] search-button-dropdown"
:class="[
@@ -29,6 +29,10 @@ vi.mock("./convertTableData", () => ({
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");
});
});
});
@@ -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 {
@@ -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();
});
});
});
});
+63 -6
View File
@@ -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;
@@ -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", () => {
@@ -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,
);
};
@@ -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)
@@ -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
@@ -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();
});
});
+40 -151
View File
@@ -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<string>();
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;
@@ -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 (
@@ -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 <http://www.gnu.org/licenses/>.
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);
});
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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 };
File diff suppressed because it is too large Load Diff
@@ -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 <http://www.gnu.org/licenses/>.
/**
* 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<number, any>();
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;
}