mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
232 lines
8.2 KiB
TypeScript
232 lines
8.2 KiB
TypeScript
// 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 { toZonedTime } from "date-fns-tz";
|
|
import { dateBin } from "@/utils/dashboard/datetimeStartPoint";
|
|
import { format } from "date-fns";
|
|
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
|
|
* is absent. Returns a deep copy of processedData unchanged when the panel
|
|
* type or metadata does not require gap-filling.
|
|
*
|
|
* @param {any[]} processedData - The already-processed (top_results filtered) data array.
|
|
* @param {any} panelSchema - The panel schema object.
|
|
* @param {any} resultMetaData - Array of result metadata objects (histogram_interval, etc.).
|
|
* @param {any} metadata - Query metadata (startTime, endTime, queries, etc.).
|
|
* @param {string[]} xAxisKeys - Array of x-axis key aliases.
|
|
* @param {string[]} yAxisKeys - Array of y-axis key aliases.
|
|
* @param {string[]} zAxisKeys - Array of z-axis key aliases.
|
|
* @param {string[]} breakDownKeys - Array of breakdown key aliases.
|
|
* @param {any} noValueConfigOption - The value to use for missing data points.
|
|
* @returns {any[]} - The data array with gaps filled in.
|
|
*/
|
|
export const fillMissingValues = (
|
|
processedData: any[],
|
|
panelSchema: any,
|
|
resultMetaData: any,
|
|
metadata: any,
|
|
xAxisKeys: string[],
|
|
yAxisKeys: string[],
|
|
zAxisKeys: string[],
|
|
breakDownKeys: string[],
|
|
noValueConfigOption: any,
|
|
loading?: any,
|
|
): any[] => {
|
|
// Get the interval in minutes
|
|
const interval = resultMetaData?.map(
|
|
(it: any) => it?.histogram_interval,
|
|
)?.[0];
|
|
|
|
if (
|
|
!interval ||
|
|
!metadata.queries ||
|
|
!["area-stacked", "line", "area", "bar", "stacked", "scatter"].includes(
|
|
panelSchema.type,
|
|
)
|
|
) {
|
|
return JSON.parse(JSON.stringify(processedData));
|
|
}
|
|
|
|
// Extract and process metaDataStartTime
|
|
const metaDataStartTime = metadata?.queries[0]?.startTime?.toString() ?? 0;
|
|
const startTime = new Date(parseInt(metaDataStartTime) / 1000);
|
|
|
|
// Calculate the binnedDate
|
|
const origin = new Date(Date.UTC(2001, 0, 1, 0, 0, 0, 0));
|
|
const binnedDate = dateBin(interval, startTime, origin);
|
|
|
|
// Identify the time-based key
|
|
const searchQueryDataFirstEntry = processedData[0];
|
|
|
|
const keys = [...xAxisKeys, ...yAxisKeys, ...zAxisKeys, ...breakDownKeys];
|
|
const timeBasedKey = keys?.find((key) =>
|
|
isTimeSeries([getDataValue(searchQueryDataFirstEntry, key)]),
|
|
);
|
|
|
|
if (!timeBasedKey) {
|
|
return JSON.parse(JSON.stringify(processedData));
|
|
}
|
|
|
|
const xAxisKeysWithoutTimeStamp = xAxisKeys.filter(
|
|
(key: any) => key !== timeBasedKey,
|
|
);
|
|
const breakdownAxisKeysWithoutTimeStamp = breakDownKeys.filter(
|
|
(key: any) => key !== timeBasedKey,
|
|
);
|
|
|
|
const timeKey = timeBasedKey;
|
|
const uniqueKey =
|
|
xAxisKeysWithoutTimeStamp[0] !== undefined
|
|
? xAxisKeysWithoutTimeStamp[0]
|
|
: breakdownAxisKeysWithoutTimeStamp[0];
|
|
|
|
// Create a set of unique xAxis values
|
|
const uniqueXAxisValues = new Set(
|
|
processedData.map((d: any) => getDataValue(d, uniqueKey)),
|
|
);
|
|
|
|
const intervalMillis = interval * 1000;
|
|
|
|
// 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 = formatUtc(endTime);
|
|
|
|
// Detect chunking direction from first resultMetaData entry.
|
|
const isLTR =
|
|
detectChunkingDirection(
|
|
resultMetaData?.[0]?.time_offset?.start_time ?? 0,
|
|
resultMetaData?.[0]?.time_offset?.end_time ?? 0,
|
|
parseInt(metaDataStartTime),
|
|
parseInt(metaDataEndTime),
|
|
) ?? false;
|
|
|
|
let binnedFillStart: Date;
|
|
let endTimeForFill: string;
|
|
|
|
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 = formatUtc(fillEndTime);
|
|
} else {
|
|
// 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
|
|
? new Date(resultMetaStartTime / 1000)
|
|
: binnedDate;
|
|
binnedFillStart = dateBin(interval, fillStartTime, origin);
|
|
endTimeForFill = formattedUserEnd;
|
|
}
|
|
|
|
// 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
|
|
? `${timeVal}-${getDataValue(d, uniqueKey)}`
|
|
: timeVal;
|
|
searchDataMap.set(key, d);
|
|
if (!actualMinTime || timeVal < actualMinTime) actualMinTime = timeVal;
|
|
if (!actualMaxTime || timeVal > actualMaxTime) actualMaxTime = timeVal;
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
const filledData: any = [];
|
|
|
|
// Fill slot-by-slot in chronological order, only between actual data points.
|
|
let currentTime = binnedFillStart;
|
|
let currentFormattedTime = formatUtc(currentTime);
|
|
|
|
while (currentFormattedTime <= endTimeForFill) {
|
|
if (!hasBreakdown) {
|
|
const key = `${currentFormattedTime}`;
|
|
const currentData = searchDataMap.get(key);
|
|
if (currentData) {
|
|
filledData.push(currentData);
|
|
} else {
|
|
const nullEntry: any = { [timeKey]: currentFormattedTime };
|
|
keys.forEach((key) => {
|
|
if (key !== timeKey) nullEntry[key] = noValueConfigOption;
|
|
});
|
|
filledData.push(nullEntry);
|
|
}
|
|
} else {
|
|
uniqueXAxisValues.forEach((uniqueValue: any) => {
|
|
const key = `${currentFormattedTime}-${uniqueValue}`;
|
|
const currentData = searchDataMap.get(key);
|
|
if (currentData) {
|
|
filledData.push(currentData);
|
|
} else {
|
|
const nullEntry: any = {
|
|
[timeKey]: currentFormattedTime,
|
|
[uniqueKey]: uniqueValue,
|
|
};
|
|
keys.forEach((key) => {
|
|
if (key !== timeKey && key !== uniqueKey) {
|
|
nullEntry[key] = noValueConfigOption;
|
|
}
|
|
});
|
|
filledData.push(nullEntry);
|
|
}
|
|
});
|
|
}
|
|
|
|
currentTime = new Date(currentTime.getTime() + intervalMillis);
|
|
currentFormattedTime = formatUtc(currentTime);
|
|
}
|
|
|
|
return filledData;
|
|
};
|