mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user