refactor: dashboard long files (#10680)

This commit is contained in:
ktx-vaidehi
2026-03-05 19:37:14 +05:30
committed by GitHub
parent 4ff58d0510
commit 108cc3b2e1
73 changed files with 11672 additions and 5242 deletions
@@ -349,10 +349,8 @@ import { generateDurationLabel } from "../../utils/date";
import { onBeforeMount } from "vue";
import { useLoading } from "@/composables/useLoading";
import useNotifications from "@/composables/useNotifications";
import {
getUTCTimestampFromZonedTimestamp,
validateSQLPanelFields,
} from "@/utils/dashboard/convertDataIntoUnitValue";
import { validateSQLPanelFields } from "@/utils/dashboard/panelValidation";
import { getUTCTimestampFromZonedTimestamp } from "@/utils/dashboard/dateTimeUtils";
import { useAnnotationsData } from "@/composables/dashboard/useAnnotationsData";
import { event } from "quasar";
import searchService from "@/services/search";
@@ -147,7 +147,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="panelTimePickerRef"
v-model="pickerValue"
:auto-apply-dashboard="true"
:hide-relative-timezone="true"
:hide-relative-timezone="true"
data-test="dashboard-config-panel-time-picker"
/>
<q-tooltip
@@ -1011,18 +1011,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-input
v-if="
[
'area',
'bar',
'line',
'h-bar',
'h-stacked',
'scatter',
'area-stacked',
'stacked',
].includes(dashboardPanelData.data.type) && !promqlMode
"
v-if="shouldShowTopResultsConfig(dashboardPanelData, promqlMode)"
v-model.number="dashboardPanelData.data.config.top_results"
:min="0"
@update:model-value="
@@ -1073,18 +1062,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="row items-center"
v-if="
[
'area',
'bar',
'line',
'h-bar',
'h-stacked',
'scatter',
'area-stacked',
'stacked',
].includes(dashboardPanelData.data.type) && !promqlMode
"
v-if="shouldShowTopResultsConfig(dashboardPanelData, promqlMode)"
>
<q-toggle
v-model="dashboardPanelData.data.config.top_results_others"
@@ -1176,11 +1154,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-toggle
v-if="
['area', 'line', 'area-stacked'].includes(
dashboardPanelData.data.type,
)
"
v-if="shouldShowAreaLineStyleConfig(dashboardPanelData)"
v-model="dashboardPanelData.data.config.connect_nulls"
:label="t('dashboard.connectNullValues')"
data-test="dashboard-config-connect-null-values"
@@ -1211,11 +1185,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-input
v-if="
['area', 'line', 'area-stacked', 'bar', 'stacked'].includes(
dashboardPanelData.data.type,
) && !promqlMode
"
v-if="shouldShowNoValueReplacement(dashboardPanelData, promqlMode)"
v-model="dashboardPanelData.data.config.no_value_replacement"
:label="t('dashboard.noValueReplacement')"
color="input-border"
@@ -1373,16 +1343,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</q-input>
<q-input
v-if="
dashboardPanelData.data.type != 'gauge' &&
dashboardPanelData.data.type != 'metric' &&
dashboardPanelData.data.type != 'geomap' &&
dashboardPanelData.data.type != 'table' &&
dashboardPanelData.data.type != 'pie' &&
dashboardPanelData.data.type != 'donut' &&
dashboardPanelData.data.type != 'sankey' &&
dashboardPanelData.data.type != 'maps'
"
v-if="shouldShowAxisConfig(dashboardPanelData)"
v-model.number="dashboardPanelData.data.config.axis_width"
:label="t('common.axisWidth')"
color="input-border"
@@ -1406,16 +1367,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-toggle
v-if="
dashboardPanelData.data.type != 'gauge' &&
dashboardPanelData.data.type != 'metric' &&
dashboardPanelData.data.type != 'geomap' &&
dashboardPanelData.data.type != 'table' &&
dashboardPanelData.data.type != 'pie' &&
dashboardPanelData.data.type != 'donut' &&
dashboardPanelData.data.type != 'sankey' &&
dashboardPanelData.data.type != 'maps'
"
v-if="shouldShowAxisConfig(dashboardPanelData)"
v-model="dashboardPanelData.data.config.axis_border_show"
:label="t('dashboard.showBorder')"
data-test="dashboard-config-axis-border"
@@ -1432,18 +1384,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
style="width: 100%; display: flex; gap: 16px"
v-if="
[
'area',
'area-stacked',
'bar',
'h-bar',
'line',
'scatter',
'stacked',
'h-stacked',
].includes(dashboardPanelData.data.type)
"
v-if="shouldShowCartesianAxisConfig(dashboardPanelData)"
>
<q-input
v-model.number="dashboardPanelData.data.config.y_axis_min"
@@ -1531,18 +1472,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-select
v-if="
[
'area',
'area-stacked',
'bar',
'h-bar',
'line',
'scatter',
'stacked',
'h-stacked',
].includes(dashboardPanelData.data.type)
"
v-if="shouldShowCartesianAxisConfig(dashboardPanelData)"
borderless
v-model="dashboardPanelData.data.config.label_option.position"
:options="labelPositionOptions"
@@ -1585,18 +1515,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-input
v-if="
[
'area',
'area-stacked',
'bar',
'h-bar',
'line',
'scatter',
'stacked',
'h-stacked',
].includes(dashboardPanelData.data.type)
"
v-if="shouldShowCartesianAxisConfig(dashboardPanelData)"
v-model.number="dashboardPanelData.data.config.label_option.rotate"
:label="t('dashboard.labelRotate')"
color="input-border"
@@ -1621,16 +1540,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
style="width: 100%; display: flex; gap: 16px"
v-if="
[
'area',
'area-stacked',
'bar',
'line',
'scatter',
'stacked',
].includes(dashboardPanelData.data.type)
"
v-if="shouldShowAxisLabelConfig(dashboardPanelData)"
>
<q-input
v-model.number="dashboardPanelData.data.config.axis_label_rotate"
@@ -1730,11 +1640,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-select
v-if="
['area', 'area-stacked', 'line'].includes(
dashboardPanelData.data.type,
)
"
v-if="shouldShowAreaLineStyleConfig(dashboardPanelData)"
borderless
v-model="dashboardPanelData.data.config.show_symbol"
:options="showSymbol"
@@ -1770,11 +1676,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-select
v-if="
['area', 'area-stacked', 'line'].includes(
dashboardPanelData.data.type,
)
"
v-if="shouldShowAreaLineStyleConfig(dashboardPanelData)"
borderless
v-model="dashboardPanelData.data.config.line_interpolation"
:options="lineInterpolationOptions"
@@ -1809,15 +1711,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<q-input
v-if="
!promqlMode &&
!dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].customQuery &&
['area', 'area-stacked', 'line'].includes(
dashboardPanelData.data.type,
)
"
v-if="shouldShowLineThickness(dashboardPanelData, promqlMode)"
v-model.number="dashboardPanelData.data.config.line_thickness"
:value="1.5"
:min="0"
@@ -1844,9 +1738,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<Drilldown
v-if="
!['html', 'markdown', 'geomap', 'maps'].includes(
dashboardPanelData.data.type,
) && dashboardPanelDataPageKey !== 'logs'
shouldShowDrilldown(dashboardPanelData, dashboardPanelDataPageKey)
"
:variablesData="variablesData"
/>
@@ -1856,35 +1748,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="space"></div>
<MarkLineConfig
v-if="
[
'area',
'area-stacked',
'bar',
'h-bar',
'line',
'scatter',
'stacked',
'h-stacked',
].includes(dashboardPanelData.data.type)
"
v-if="shouldShowCartesianAxisConfig(dashboardPanelData)"
/>
</div>
<div
v-if="
[
'area',
'bar',
'line',
'h-bar',
'h-stacked',
'scatter',
'area-stacked',
'stacked',
].includes(dashboardPanelData.data.type) &&
!promqlMode &&
dashboardPanelDataPageKey !== 'logs'
shouldShowTimeShift(
dashboardPanelData,
promqlMode,
dashboardPanelDataPageKey,
)
"
>
<div class="flex items-center q-mr-sm">
@@ -2009,6 +1883,15 @@ import {
shouldShowLegendHeightUnitContainer,
shouldApplyChartAlign,
shouldShowGridlines,
shouldShowTopResultsConfig,
shouldShowAreaLineStyleConfig,
shouldShowNoValueReplacement,
shouldShowAxisConfig,
shouldShowCartesianAxisConfig,
shouldShowAxisLabelConfig,
shouldShowLineThickness,
shouldShowDrilldown,
shouldShowTimeShift,
} from "@/utils/dashboard/configUtils";
export default defineComponent({
@@ -2797,6 +2680,15 @@ export default defineComponent({
shouldShowLegendHeightUnitContainer,
shouldApplyChartAlign,
shouldShowGridlines,
shouldShowTopResultsConfig,
shouldShowAreaLineStyleConfig,
shouldShowNoValueReplacement,
shouldShowAxisConfig,
shouldShowCartesianAxisConfig,
shouldShowAxisLabelConfig,
shouldShowLineThickness,
shouldShowDrilldown,
shouldShowTimeShift,
// Panel default time configuration (v4.0)
useDefaultTime,
panelTimeRange,
@@ -25,7 +25,7 @@ vi.mock("@/composables/useNotifications", () => ({
}),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.mock("@/utils/dashboard/panelValidation", () => ({
findFirstValidMappedValue: vi.fn().mockReturnValue(null),
}));
@@ -34,7 +34,7 @@ vi.mock("@/composables/useNotifications", () => ({
}),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.mock("@/utils/dashboard/panelValidation", () => ({
findFirstValidMappedValue: vi.fn().mockReturnValue(null),
}));
@@ -147,7 +147,7 @@ import useNotifications from "@/composables/useNotifications";
import { useStickyColumns } from "@/composables/useStickyColumns";
import { exportFile, copyToClipboard, useQuasar } from "quasar";
import { defineComponent, ref, watch, computed } from "vue";
import { findFirstValidMappedValue } from "@/utils/dashboard/convertDataIntoUnitValue";
import { findFirstValidMappedValue } from "@/utils/dashboard/panelValidation";
import { useStore } from "vuex";
import { getColorForTable } from "@/utils/dashboard/colorPalette";
import JsonFieldRenderer from "./JsonFieldRenderer.vue";
@@ -4,7 +4,7 @@ import useNotifications from "../useNotifications";
import { getDashboard } from "@/utils/commons";
import { useStore } from "vuex";
import { fromZonedTime } from "date-fns-tz";
import { getUTCTimestampFromZonedTimestamp } from "@/utils/dashboard/convertDataIntoUnitValue";
import { getUTCTimestampFromZonedTimestamp } from "@/utils/dashboard/dateTimeUtils";
export const useAnnotationsData = (
organization: string,
@@ -102,7 +102,7 @@ vi.mock("./usePanelCache", () => ({
})),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
convertOffsetToSeconds: vi.fn(() => ({ seconds: 3600, periodAsStr: "1h" })),
}));
@@ -42,7 +42,7 @@ import {
import { SELECT_ALL_VALUE } from "@/utils/dashboard/constants";
import { usePanelCache } from "./usePanelCache";
import { isEqual, omit } from "lodash-es";
import { convertOffsetToSeconds } from "@/utils/dashboard/convertDataIntoUnitValue";
import { convertOffsetToSeconds } from "@/utils/dashboard/dateTimeUtils";
import { useAnnotations } from "./useAnnotations";
import useHttpStreamingSearch from "../useStreamingSearch";
import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall";
@@ -17,6 +17,7 @@ import queryService from "@/services/search";
import * as zincutils from "@/utils/zincutils";
import * as sqlUtils from "@/utils/query/sqlUtils";
import * as convertDataIntoUnitValue from "@/utils/dashboard/convertDataIntoUnitValue";
import * as panelValidation from "@/utils/dashboard/panelValidation";
// Mock Vue lifecycle hooks to avoid warnings
vi.mock("vue", async () => {
@@ -37,6 +38,7 @@ vi.mock("@/services/search");
vi.mock("@/utils/zincutils");
vi.mock("@/utils/query/sqlUtils");
vi.mock("@/utils/dashboard/convertDataIntoUnitValue");
vi.mock("@/utils/dashboard/panelValidation");
// Mock SQL parser
const mockParser = {
@@ -96,7 +98,7 @@ describe("useDashboardPanel", () => {
]);
// Mock validatePanel
vi.mocked(convertDataIntoUnitValue.validatePanel).mockImplementation(
vi.mocked(panelValidation.validatePanel).mockImplementation(
() => {},
);
@@ -438,7 +440,7 @@ describe("useDashboardPanel", () => {
panel.validatePanel(errors, true);
}).not.toThrow();
expect(convertDataIntoUnitValue.validatePanel).toHaveBeenCalled();
expect(panelValidation.validatePanel).toHaveBeenCalled();
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ import { useStore } from "vuex";
import useNotifications from "./useNotifications";
import { b64EncodeUnicode, isStreamingEnabled } from "@/utils/zincutils";
import { extractFields, getStreamNameFromQuery } from "@/utils/query/sqlUtils";
import { validatePanel } from "@/utils/dashboard/convertDataIntoUnitValue";
import { validatePanel } from "@/utils/dashboard/panelValidation";
import useStreams from "./useStreams";
import useValuesWebSocket from "./dashboard/useValuesWebSocket";
import queryService from "@/services/search";
@@ -0,0 +1,215 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import {
getContrastColor,
applySeriesColorMappings,
} from "@/utils/dashboard/chartColorUtils";
vi.mock("@/utils/dashboard/colorPalette", () => ({
getColorPalette: vi.fn(() => [
"#FF5733",
"#33FF57",
"#3357FF",
"#F333FF",
"#33F3FF",
]),
}));
describe("chartColorUtils", () => {
describe("getContrastColor", () => {
it("returns white for empty background in light theme", () => {
expect(getContrastColor("", false)).toBe("#000000");
});
it("returns white for empty background in dark theme", () => {
expect(getContrastColor("", true)).toBe("#FFFFFF");
});
it("returns black text for light hex background in light theme", () => {
// #FFFFFF is white -> luminance ~1.0 > 0.5 => black text
expect(getContrastColor("#FFFFFF", false)).toBe("#000000");
});
it("returns white text for dark hex background in light theme", () => {
// #000000 is black -> luminance ~0 < 0.5 => white text
expect(getContrastColor("#000000", false)).toBe("#FFFFFF");
});
it("returns white text for dark hex background in dark theme", () => {
// #000000 luminance ~0 <= 0.8 => white text
expect(getContrastColor("#000000", true)).toBe("#FFFFFF");
});
it("returns black text for very light hex background in dark theme", () => {
// #FFFFFF luminance ~1.0 > 0.8 => black text
expect(getContrastColor("#FFFFFF", true)).toBe("#000000");
});
it("handles rgb format", () => {
// rgb(255,255,255) -> luminance ~1.0 > 0.5 => black
expect(getContrastColor("rgb(255,255,255)", false)).toBe("#000000");
});
it("handles rgb format with dark color", () => {
// rgb(0,0,0) -> luminance 0 <= 0.5 => white
expect(getContrastColor("rgb(0,0,0)", false)).toBe("#FFFFFF");
});
it("handles rgba format", () => {
// rgba(255,255,255,0.5) -> luminance ~1.0 > 0.5 => black
expect(getContrastColor("rgba(255,255,255,0.5)", false)).toBe("#000000");
});
it("handles rgb with spaces stripped", () => {
// rgb(128,128,128) -> medium grey
const result = getContrastColor("rgb(128, 128, 128)", false);
expect(result).toMatch(/^#(000000|FFFFFF)$/);
});
it("returns fallback for unknown color format", () => {
// Unknown format falls back to {r:255,g:255,b:255} (white)
// luminance ~1.0 > 0.5 => black in light theme
expect(getContrastColor("invalidcolor", false)).toBe("#000000");
});
it("handles medium brightness background", () => {
// #808080 grey: luminance = (0.299*128 + 0.587*128 + 0.114*128)/255 ≈ 0.502
// light theme: > 0.5 => black
expect(getContrastColor("#808080", false)).toBe("#000000");
});
});
describe("applySeriesColorMappings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does nothing when series is not an array", () => {
const series = null as any;
applySeriesColorMappings(series, [{ value: "A", color: "#FF0000" }], "light");
// no error thrown
});
it("does nothing when colorBySeries is empty", () => {
const series = [{ name: "A", color: "#123456" }];
applySeriesColorMappings(series, [], "light");
expect(series[0].color).toBe("#123456");
});
it("does nothing when colorBySeries is null", () => {
const series = [{ name: "A", color: "#123456" }];
applySeriesColorMappings(series, null, "light");
expect(series[0].color).toBe("#123456");
});
it("does nothing when colorBySeries is undefined", () => {
const series = [{ name: "A", color: "#123456" }];
applySeriesColorMappings(series, undefined, "light");
expect(series[0].color).toBe("#123456");
});
it("enforces configured colors for mapped series (color property)", () => {
const series = [
{ name: "A", color: "#OLD111" },
{ name: "B", color: "#OLD222" },
];
const colorBySeries = [{ value: "A", color: "#FF0000" }];
applySeriesColorMappings(series, colorBySeries, "light");
expect(series[0].color).toBe("#FF0000");
});
it("enforces configured colors for mapped series (itemStyle.color)", () => {
const series = [
{ name: "A", itemStyle: { color: "#OLD111" } },
];
const colorBySeries = [{ value: "A", color: "#FF0000" }];
applySeriesColorMappings(series, colorBySeries, "light");
expect(series[0].itemStyle.color).toBe("#FF0000");
});
it("does not modify unmapped series color", () => {
const series = [
{ name: "A", color: "#FF0000" },
{ name: "B", color: "#33FF57" }, // palette color but not configured
];
const colorBySeries = [{ value: "A", color: "#FF0000" }];
applySeriesColorMappings(series, colorBySeries, "light");
// B's color doesn't conflict with configured colors (#FF0000) so stays unchanged
// Wait - B has color "#33FF57" which is in the palette but NOT in configuredColors
expect(series[1].color).toBe("#33FF57");
});
it("reassigns auto-generated colors that collide with configured colors", () => {
// Series B has the same color as the configured color for A
const series = [
{ name: "A", color: "#FF5733" },
{ name: "B", color: "#FF5733" }, // same as A's configured color -> collides
];
const colorBySeries = [{ value: "A", color: "#FF5733" }];
applySeriesColorMappings(series, colorBySeries, "light");
// A gets its configured color
expect(series[0].color).toBe("#FF5733");
// B should get a different color since #FF5733 is now in configuredColors
expect(series[1].color).not.toBe("#FF5733");
});
it("skips mappings with null color", () => {
const series = [{ name: "A", color: "#123456" }];
const colorBySeries = [{ value: "A", color: null }];
applySeriesColorMappings(series, colorBySeries, "light");
// Mapping skipped because color is null, so no configured mapping for A
expect(series[0].color).toBe("#123456");
});
it("skips mappings with missing value", () => {
const series = [{ name: "A", color: "#123456" }];
const colorBySeries = [{ value: "", color: "#FF0000" }];
applySeriesColorMappings(series, colorBySeries, "light");
// "" is falsy so mapping is skipped
expect(series[0].color).toBe("#123456");
});
it("handles multiple configured mappings", () => {
const series = [
{ name: "A", color: "#AAAAAA" },
{ name: "B", color: "#BBBBBB" },
{ name: "C", color: "#CCCCCC" },
];
const colorBySeries = [
{ value: "A", color: "#FF0000" },
{ value: "B", color: "#00FF00" },
];
applySeriesColorMappings(series, colorBySeries, "light");
expect(series[0].color).toBe("#FF0000");
expect(series[1].color).toBe("#00FF00");
});
it("uses HSL fallback when palette is exhausted", async () => {
const { getColorPalette } = await import("@/utils/dashboard/colorPalette") as any;
getColorPalette.mockReturnValueOnce([]); // empty palette to force HSL fallback
const series = [
{ name: "A", color: "#FF5733" }, // configured color
{ name: "B", color: "#FF5733" }, // collides -> needs reassignment via HSL
];
const colorBySeries = [{ value: "A", color: "#FF5733" }];
applySeriesColorMappings(series, colorBySeries, "light");
// B gets an HSL color since palette is empty
expect(series[1].color).toMatch(/^hsl\(/);
});
});
});
+141
View File
@@ -0,0 +1,141 @@
import { getColorPalette } from "./colorPalette";
/**
* Applies user-configured series-to-color mappings to the given series list and
* ensures auto-generated colors do not conflict with configured colors.
*
* - Enforces configured colors for mapped series names
* - Tracks colors already in use (configured + existing on series)
* - Reassigns auto-generated colors that collide with configured ones to the
* next available color from the theme palette; if exhausted, uses an HSL fallback
*
* This function mutates the provided `series` array in place.
*/
export const applySeriesColorMappings = (
series: any[],
colorBySeries:
| Array<{ value: string; color: string | null }>
| undefined
| null,
theme: string,
): void => {
if (!Array.isArray(series) || !colorBySeries?.length) return;
const configuredSeriesToColor = new Map<string, string>();
const configuredColors = new Set<string>();
for (const mapping of colorBySeries) {
if (mapping?.value && mapping?.color) {
configuredSeriesToColor.set(String(mapping.value), String(mapping.color));
configuredColors.add(String(mapping.color));
}
}
if (configuredSeriesToColor.size === 0) return;
const usedColors = new Set<string>(configuredColors);
const getSeriesColor = (s: any): string | undefined =>
s?.color ?? s?.itemStyle?.color;
const setSeriesColor = (s: any, clr: string) => {
if (!s) return;
if (s.color !== undefined) s.color = clr;
if (s.itemStyle?.color !== undefined) s.itemStyle.color = clr;
};
// Enforce configured colors and collect currently used colors
series.forEach((s: any) => {
const mapped = configuredSeriesToColor.get(s?.name);
if (mapped) {
setSeriesColor(s, mapped);
usedColors.add(mapped);
} else {
const current = getSeriesColor(s);
if (current) usedColors.add(current);
}
});
// Generate a unique non-conflicting color
const generateUniqueColor = (
used: Set<string>,
themeName: string,
): string => {
const palette = getColorPalette(themeName);
for (const c of palette) {
if (!used.has(c)) return c;
}
const hue = (used.size * 137.508) % 360;
const saturation = 70 + (used.size % 20);
const lightness = 45 + (used.size % 20);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
// Reassign auto-generated colors that collide with configured colors
series.forEach((s: any) => {
if (configuredSeriesToColor.has(s?.name)) return;
const current = getSeriesColor(s);
if (current && configuredColors.has(current)) {
const next = generateUniqueColor(usedColors, theme);
usedColors.add(next);
setSeriesColor(s, next);
}
});
};
// Modify the getContrastColor function to consider theme
export const getContrastColor = (
backgroundColor: string,
isDarkTheme: boolean,
): string => {
// If no background color, return based on theme
if (!backgroundColor) {
return isDarkTheme ? "#FFFFFF" : "#000000";
}
// Normalize input (support hex, rgb, rgba)
const normalizeColor = (
color: string,
): { r: number; g: number; b: number } => {
// Remove spaces and convert to lowercase
color = color.replace(/\s/g, "").toLowerCase();
// Hex color
if (color.startsWith("#")) {
const hex = color.replace("#", "");
return {
r: parseInt(hex.substr(0, 2), 16),
g: parseInt(hex.substr(2, 2), 16),
b: parseInt(hex.substr(4, 2), 16),
};
}
// RGB or RGBA
if (color.startsWith("rgb")) {
const matches = color.match(/rgba?\((\d+),(\d+),(\d+)(?:,([.\d]+))?\)/);
if (matches) {
return {
r: parseInt(matches[1], 10),
g: parseInt(matches[2], 10),
b: parseInt(matches[3], 10),
};
}
}
// Fallback
return { r: 255, g: 255, b: 255 };
};
// Normalize the background color
const { r, g, b } = normalizeColor(backgroundColor);
// Calculate relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return black or white based on luminance and theme
if (isDarkTheme) {
// In dark theme, prefer white text unless background is very light
return luminance > 0.8 ? "#000000" : "#FFFFFF";
} else {
// In light theme, prefer black text unless background is very dark
return luminance > 0.5 ? "#000000" : "#FFFFFF";
}
};
@@ -0,0 +1,233 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import {
calculateWidthText,
calculateOptimalFontSize,
calculateDynamicNameGap,
calculateRotatedLabelBottomSpace,
} from "@/utils/dashboard/chartDimensionUtils";
// Mock DOM APIs used by calculateWidthText
const mockSpan = {
style: {} as any,
innerHTML: "",
clientWidth: 0,
remove: vi.fn(),
};
describe("chartDimensionUtils", () => {
beforeEach(() => {
mockSpan.clientWidth = 0;
mockSpan.remove.mockClear();
vi.spyOn(document, "createElement").mockReturnValue(mockSpan as any);
vi.spyOn(document.body, "appendChild").mockImplementation(() => mockSpan as any);
});
describe("calculateWidthText", () => {
it("returns 0 for empty string", () => {
expect(calculateWidthText("")).toBe(0);
});
it("returns 0 for null/falsy text", () => {
expect(calculateWidthText(null as any)).toBe(0);
});
it("calls document.createElement with span", () => {
mockSpan.clientWidth = 50;
calculateWidthText("Hello");
expect(document.createElement).toHaveBeenCalledWith("span");
});
it("appends span to body and removes it", () => {
mockSpan.clientWidth = 50;
calculateWidthText("Hello");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockSpan.remove).toHaveBeenCalled();
});
it("returns Math.ceil of clientWidth", () => {
mockSpan.clientWidth = 49.3;
const result = calculateWidthText("Hello");
expect(result).toBe(50);
});
it("uses default fontSize of 12px when not provided", () => {
mockSpan.clientWidth = 100;
calculateWidthText("Hello");
expect(mockSpan.style.fontSize).toBe("12px");
});
it("uses provided fontSize", () => {
mockSpan.clientWidth = 100;
calculateWidthText("Hello", "16px");
expect(mockSpan.style.fontSize).toBe("16px");
});
it("sets span innerHTML to text", () => {
mockSpan.clientWidth = 100;
calculateWidthText("TestText");
expect(mockSpan.innerHTML).toBe("TestText");
});
});
describe("calculateOptimalFontSize", () => {
it("finds the largest font size that fits canvas width", () => {
// Mock: smaller fonts have smaller widths
let callCount = 0;
vi.spyOn(document, "createElement").mockReturnValue({
...mockSpan,
get clientWidth() {
// Simulate: width = fontSize * 5 (approximation)
callCount++;
return parseInt(mockSpan.style.fontSize || "12") * 5;
},
} as any);
const result = calculateOptimalFontSize("Hello", 200);
// With width = fontSize * 5, max fontSize where fontSize * 5 <= 200 is 40
// But this depends on the binary search logic
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThanOrEqual(90);
});
it("returns a value between 1 and 90", () => {
mockSpan.clientWidth = 0; // Text always fits
const result = calculateOptimalFontSize("Hi", 1000);
expect(result).toBeGreaterThanOrEqual(1);
expect(result).toBeLessThanOrEqual(90);
});
it("returns 1 when text never fits", () => {
// Mock: text always too wide
vi.spyOn(document, "createElement").mockReturnValue({
...mockSpan,
clientWidth: 9999,
remove: vi.fn(),
} as any);
const result = calculateOptimalFontSize("VeryLongText", 10);
expect(result).toBe(1);
});
});
describe("calculateDynamicNameGap", () => {
it("returns defaultNameGap when rotation is 0", () => {
expect(calculateDynamicNameGap(0)).toBe(25);
});
it("returns custom defaultNameGap when rotation is 0", () => {
expect(calculateDynamicNameGap(0, 120, 12, 30, 10)).toBe(30);
});
it("returns at least the defaultNameGap for any rotation", () => {
const result = calculateDynamicNameGap(45);
expect(result).toBeGreaterThanOrEqual(25);
});
it("returns larger gap for 90 degree rotation", () => {
const at0 = calculateDynamicNameGap(0);
const at90 = calculateDynamicNameGap(90);
expect(at90).toBeGreaterThan(at0);
});
it("calculates correctly for 45 degree rotation with default params", () => {
// verticalHeight = 120 * sin(45°) + 12 * cos(45°)
// = 120 * 0.7071 + 12 * 0.7071 ≈ 84.85 + 8.49 ≈ 93.34
// calculatedNameGap = ceil(93.34 + 10 + 8) = ceil(111.34) = 112
// max(112, 25) = 112
const result = calculateDynamicNameGap(45);
expect(result).toBeCloseTo(112, 0);
});
it("uses absolute value of negative rotation", () => {
const positive = calculateDynamicNameGap(45);
const negative = calculateDynamicNameGap(-45);
expect(positive).toBe(negative);
});
it("calculates correctly for 90 degree rotation", () => {
// verticalHeight = 120 * sin(90°) + 12 * cos(90°) = 120 * 1 + 12 * 0 = 120
// calculatedNameGap = ceil(120 + 10 + 8) = 138
// max(138, 25) = 138
const result = calculateDynamicNameGap(90);
expect(result).toBe(138);
});
it("respects custom label width and font size", () => {
// rotate=90, labelWidth=50, fontSize=10
// verticalHeight = 50 * sin(90°) + 10 * cos(90°) = 50 + 0 = 50
// calculatedNameGap = ceil(50 + 10 + 8) = 68
const result = calculateDynamicNameGap(90, 50, 10, 25, 10);
expect(result).toBe(68);
});
it("respects custom axisLabelMargin", () => {
// rotate=90, labelWidth=120, fontSize=12, defaultNameGap=25, axisLabelMargin=20
// verticalHeight = 120 + 0 = 120
// calculatedNameGap = ceil(120 + 20 + 8) = 148
const result = calculateDynamicNameGap(90, 120, 12, 25, 20);
expect(result).toBe(148);
});
});
describe("calculateRotatedLabelBottomSpace", () => {
it("returns 0 when rotation is 0", () => {
expect(calculateRotatedLabelBottomSpace(0)).toBe(0);
});
it("returns 10 for rotated labels without axis name", () => {
expect(calculateRotatedLabelBottomSpace(45, 120, 12, false)).toBe(10);
});
it("returns 10 for any non-zero rotation without axis name", () => {
expect(calculateRotatedLabelBottomSpace(90, 120, 12, false)).toBe(10);
});
it("calculates extra space when hasAxisName is true", () => {
// rotate=90, labelWidth=120, fontSize=12, hasAxisName=true, nameGap=0
// verticalHeight = 120 + 0 = 120
// If nameGap is 0 (falsy), uses verticalHeight + 10 = 130
// totalNeededSpace = 130 + 20 = 150
// result = max(0, ceil(150 - 40)) = 110
const result = calculateRotatedLabelBottomSpace(90, 120, 12, true, 0);
expect(result).toBe(110);
});
it("uses nameGap when provided and hasAxisName is true", () => {
// rotate=45, labelWidth=120, fontSize=12, hasAxisName=true, nameGap=100
// totalNeededSpace = 100 + 20 = 120
// result = max(0, ceil(120 - 40)) = 80
const result = calculateRotatedLabelBottomSpace(45, 120, 12, true, 100);
expect(result).toBe(80);
});
it("returns 0 when calculated space is less than base (40px)", () => {
// rotate=45, small nameGap
// totalNeededSpace = 5 + 20 = 25
// result = max(0, ceil(25 - 40)) = max(0, -15) = 0
const result = calculateRotatedLabelBottomSpace(45, 120, 12, true, 5);
expect(result).toBe(0);
});
it("uses absolute value of negative rotation", () => {
const positive = calculateRotatedLabelBottomSpace(45);
const negative = calculateRotatedLabelBottomSpace(-45);
expect(positive).toBe(negative);
});
});
});
@@ -0,0 +1,148 @@
/**
* Calculates the width of a given text.
* Useful to calculate nameGap for the left axis
*
* @param {string} text - The text to calculate the width of.
* @param {string} fontSize - The font size of the text.
* @return {number} The width of the text in pixels.
*/
export const calculateWidthText = (
text: string,
fontSize: string = "12px",
): number => {
if (!text) return 0;
const span = document.createElement("span");
document.body.appendChild(span);
span.style.font = "sans-serif";
span.style.fontSize = fontSize || "12px";
span.style.height = "auto";
span.style.width = "auto";
span.style.top = "0px";
span.style.position = "absolute";
span.style.whiteSpace = "no-wrap";
span.innerHTML = text;
const width = Math.ceil(span.clientWidth);
span.remove();
return width;
};
/**
* Calculates the optimal font size for a given text that fits the canvas width.
* @param text - The text to calculate the font size for.
* @param canvasWidth - canvas width in pixels
* @returns {number} - The optimal font size in pixels.
*/
export const calculateOptimalFontSize = (text: string, canvasWidth: number) => {
let minFontSize = 1; // Start with the smallest font size
let maxFontSize = 90; // Set a maximum possible font size
let optimalFontSize = minFontSize;
while (minFontSize <= maxFontSize) {
const midFontSize = Math.floor((minFontSize + maxFontSize) / 2);
const textWidth = calculateWidthText(text, `${midFontSize}px`);
if (textWidth > canvasWidth) {
maxFontSize = midFontSize - 1; // Text is too wide, reduce font size
} else {
optimalFontSize = midFontSize; // Text fits, but we try larger
minFontSize = midFontSize + 1;
}
}
return optimalFontSize; // Return the largest font size that fits
};
/**
* Calculates a dynamic nameGap for the x-axis based on label rotation.
* When labels are rotated, we need more space between the labels and the axis name.
*
* @param {number} rotate - The rotation angle of the labels in degrees (0-90).
* @param {number} labelWidth - The maximum width of truncated labels in pixels (default: 120).
* @param {number} fontSize - The font size of the labels in pixels (default: 12).
* @param {number} defaultNameGap - The default nameGap to use when rotation is 0 (default: 25).
* @param {number} axisLabelMargin - The margin between axis and labels (default: 10).
* @return {number} The calculated nameGap value.
*/
export const calculateDynamicNameGap = (
rotate: number = 0,
labelWidth: number = 120,
fontSize: number = 12,
defaultNameGap: number = 25,
axisLabelMargin: number = 10,
): number => {
// If no rotation, return the default nameGap
if (rotate === 0) {
return defaultNameGap;
}
// Convert rotation to radians
const rotationInRadians = (Math.abs(rotate) * Math.PI) / 180;
// Calculate the vertical height occupied by rotated label (Section Height)
// When a label of width W is rotated by angle ╬╕:
// - The vertical height = W * sin(╬╕) + fontSize * cos(╬╕)
const verticalHeight =
labelWidth * Math.sin(rotationInRadians) +
fontSize * Math.cos(rotationInRadians);
// Calculate nameGap: vertical height + axis label margin + small buffer (8px)
// The buffer ensures there's slight spacing between longest label tip and axis name
const calculatedNameGap = Math.ceil(verticalHeight + axisLabelMargin + 8);
// Return the maximum of calculated and default to ensure minimum spacing
return Math.max(calculatedNameGap, defaultNameGap);
};
/**
* Calculates the additional bottom spacing needed for rotated x-axis labels.
* This ensures rotated labels don't overlap with legends or get cut off.
*
* @param {number} rotate - The rotation angle of the labels in degrees (0-90).
* @param {number} labelWidth - The maximum width of truncated labels in pixels (default: 120).
* @param {number} fontSize - The font size of the labels in pixels (default: 12).
* @param {boolean} hasAxisName - Whether the axis has a name/title (default: false).
* @param {number} nameGap - The gap between axis and its name (optional).
* @return {number} The additional spacing needed in pixels.
*/
export const calculateRotatedLabelBottomSpace = (
rotate: number = 0,
labelWidth: number = 120,
fontSize: number = 12,
hasAxisName: boolean = false,
nameGap: number = 0,
): number => {
// If no rotation, no additional space needed
if (rotate === 0) {
return 0;
}
// Convert rotation to radians
const rotationInRadians = (Math.abs(rotate) * Math.PI) / 180;
// Calculate the vertical height occupied by rotated label
const verticalHeight =
labelWidth * Math.sin(rotationInRadians) +
fontSize * Math.cos(rotationInRadians);
if (hasAxisName) {
// If there's an axis name, nameGap already covers the label height.
// We just need to add enough space for the axis name itself (~20px)
// and some buffer.
const axisNameEstimatedHeight = 20;
const totalNeededSpace = (nameGap || verticalHeight + 10) + axisNameEstimatedHeight;
// Default bottom spacing in charts is typically ~35-50px.
// Only add if totalNeeded exceeds a reasonable base (e.g., 40px)
return Math.max(0, Math.ceil(totalNeededSpace - 40));
} else {
// Without axis name, containLabel: true in grid config handles the space automatically
// for rotated labels. No additional bottom spacing is needed because:
// 1. containLabel: true makes ECharts automatically expand grid to fit labels
// 2. The grid.bottom value already provides base spacing
// 3. Adding extra space here causes unnecessary whitespace
return 10; // Let ECharts handle it with containLabel
}
};
+296
View File
@@ -24,6 +24,15 @@ import {
shouldShowLegendHeight,
shouldShowLegendWidthUnitContainer,
shouldShowLegendHeightUnitContainer,
shouldShowNoValueReplacement,
shouldShowAreaLineStyleConfig,
shouldShowTopResultsConfig,
shouldShowAxisConfig,
shouldShowCartesianAxisConfig,
shouldShowAxisLabelConfig,
shouldShowLineThickness,
shouldShowDrilldown,
shouldShowTimeShift,
} from "./configUtils";
describe("configUtils", () => {
@@ -1124,4 +1133,291 @@ describe("configUtils", () => {
expect(shouldShowLegendHeight(dashboardPanelData)).toBe(true);
});
});
describe("shouldShowNoValueReplacement", () => {
it("should return true for area chart in non-promql mode", () => {
const data = { data: { type: "area" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(true);
});
it("should return true for line chart in non-promql mode", () => {
const data = { data: { type: "line" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(true);
});
it("should return true for area-stacked chart in non-promql mode", () => {
const data = { data: { type: "area-stacked" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(true);
});
it("should return true for bar chart in non-promql mode", () => {
const data = { data: { type: "bar" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(true);
});
it("should return true for stacked chart in non-promql mode", () => {
const data = { data: { type: "stacked" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(true);
});
it("should return false when promqlMode is true", () => {
const data = { data: { type: "area" } };
expect(shouldShowNoValueReplacement(data, true)).toBe(false);
});
it("should return false for pie chart even in non-promql mode", () => {
const data = { data: { type: "pie" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(false);
});
it("should return false for table chart", () => {
const data = { data: { type: "table" } };
expect(shouldShowNoValueReplacement(data, false)).toBe(false);
});
});
describe("shouldShowAreaLineStyleConfig", () => {
it("should return true for area chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "area" } })).toBe(true);
});
it("should return true for line chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "line" } })).toBe(true);
});
it("should return true for area-stacked chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "area-stacked" } })).toBe(true);
});
it("should return false for bar chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "bar" } })).toBe(false);
});
it("should return false for pie chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "pie" } })).toBe(false);
});
it("should return false for scatter chart", () => {
expect(shouldShowAreaLineStyleConfig({ data: { type: "scatter" } })).toBe(false);
});
});
describe("shouldShowTopResultsConfig", () => {
it("should return true for area chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "area" } }, false)).toBe(true);
});
it("should return true for bar chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "bar" } }, false)).toBe(true);
});
it("should return true for h-bar chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "h-bar" } }, false)).toBe(true);
});
it("should return true for scatter chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "scatter" } }, false)).toBe(true);
});
it("should return true for stacked chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "stacked" } }, false)).toBe(true);
});
it("should return true for h-stacked chart in non-promql mode", () => {
expect(shouldShowTopResultsConfig({ data: { type: "h-stacked" } }, false)).toBe(true);
});
it("should return false when promqlMode is true", () => {
expect(shouldShowTopResultsConfig({ data: { type: "area" } }, true)).toBe(false);
});
it("should return false for pie chart", () => {
expect(shouldShowTopResultsConfig({ data: { type: "pie" } }, false)).toBe(false);
});
it("should return false for table chart", () => {
expect(shouldShowTopResultsConfig({ data: { type: "table" } }, false)).toBe(false);
});
});
describe("shouldShowAxisConfig", () => {
it("should return true for line chart", () => {
expect(shouldShowAxisConfig({ data: { type: "line" } })).toBe(true);
});
it("should return true for bar chart", () => {
expect(shouldShowAxisConfig({ data: { type: "bar" } })).toBe(true);
});
it("should return true for area chart", () => {
expect(shouldShowAxisConfig({ data: { type: "area" } })).toBe(true);
});
it("should return true for heatmap chart", () => {
expect(shouldShowAxisConfig({ data: { type: "heatmap" } })).toBe(true);
});
it("should return false for gauge chart", () => {
expect(shouldShowAxisConfig({ data: { type: "gauge" } })).toBe(false);
});
it("should return false for metric chart", () => {
expect(shouldShowAxisConfig({ data: { type: "metric" } })).toBe(false);
});
it("should return false for geomap", () => {
expect(shouldShowAxisConfig({ data: { type: "geomap" } })).toBe(false);
});
it("should return false for table", () => {
expect(shouldShowAxisConfig({ data: { type: "table" } })).toBe(false);
});
it("should return false for pie chart", () => {
expect(shouldShowAxisConfig({ data: { type: "pie" } })).toBe(false);
});
it("should return false for donut chart", () => {
expect(shouldShowAxisConfig({ data: { type: "donut" } })).toBe(false);
});
it("should return false for sankey", () => {
expect(shouldShowAxisConfig({ data: { type: "sankey" } })).toBe(false);
});
it("should return false for maps", () => {
expect(shouldShowAxisConfig({ data: { type: "maps" } })).toBe(false);
});
});
describe("shouldShowCartesianAxisConfig", () => {
const cartesianTypes = ["area", "area-stacked", "bar", "h-bar", "line", "scatter", "stacked", "h-stacked"];
const nonCartesianTypes = ["pie", "donut", "table", "heatmap", "metric", "gauge", "geomap", "sankey", "maps"];
cartesianTypes.forEach((type) => {
it(`should return true for ${type} chart`, () => {
expect(shouldShowCartesianAxisConfig({ data: { type } })).toBe(true);
});
});
nonCartesianTypes.forEach((type) => {
it(`should return false for ${type} chart`, () => {
expect(shouldShowCartesianAxisConfig({ data: { type } })).toBe(false);
});
});
});
describe("shouldShowAxisLabelConfig", () => {
const supportedTypes = ["area", "area-stacked", "bar", "line", "scatter", "stacked"];
const unsupportedTypes = ["h-bar", "h-stacked", "pie", "donut", "table", "heatmap", "gauge", "metric"];
supportedTypes.forEach((type) => {
it(`should return true for ${type} chart`, () => {
expect(shouldShowAxisLabelConfig({ data: { type } })).toBe(true);
});
});
unsupportedTypes.forEach((type) => {
it(`should return false for ${type} chart`, () => {
expect(shouldShowAxisLabelConfig({ data: { type } })).toBe(false);
});
});
});
describe("shouldShowLineThickness", () => {
const makeData = (type: string, customQuery = false) => ({
data: {
type,
queries: [{ customQuery }],
},
layout: { currentQueryIndex: 0 },
});
it("should return true for area chart in non-promql mode without customQuery", () => {
expect(shouldShowLineThickness(makeData("area"), false)).toBe(true);
});
it("should return true for area-stacked chart in non-promql mode without customQuery", () => {
expect(shouldShowLineThickness(makeData("area-stacked"), false)).toBe(true);
});
it("should return true for line chart in non-promql mode without customQuery", () => {
expect(shouldShowLineThickness(makeData("line"), false)).toBe(true);
});
it("should return false when promqlMode is true", () => {
expect(shouldShowLineThickness(makeData("area"), true)).toBe(false);
});
it("should return false when customQuery is true", () => {
expect(shouldShowLineThickness(makeData("area", true), false)).toBe(false);
});
it("should return false for bar chart", () => {
expect(shouldShowLineThickness(makeData("bar"), false)).toBe(false);
});
it("should return false for scatter chart", () => {
expect(shouldShowLineThickness(makeData("scatter"), false)).toBe(false);
});
});
describe("shouldShowDrilldown", () => {
it("should return true for line chart in non-logs context", () => {
expect(shouldShowDrilldown({ data: { type: "line" } }, "dashboards")).toBe(true);
});
it("should return true for bar chart in non-logs context", () => {
expect(shouldShowDrilldown({ data: { type: "bar" } }, "dashboards")).toBe(true);
});
it("should return true for pie chart in non-logs context", () => {
expect(shouldShowDrilldown({ data: { type: "pie" } }, "dashboards")).toBe(true);
});
it("should return false for html panel", () => {
expect(shouldShowDrilldown({ data: { type: "html" } }, "dashboards")).toBe(false);
});
it("should return false for markdown panel", () => {
expect(shouldShowDrilldown({ data: { type: "markdown" } }, "dashboards")).toBe(false);
});
it("should return false for geomap panel", () => {
expect(shouldShowDrilldown({ data: { type: "geomap" } }, "dashboards")).toBe(false);
});
it("should return false for maps panel", () => {
expect(shouldShowDrilldown({ data: { type: "maps" } }, "dashboards")).toBe(false);
});
it("should return false when pageKey is logs", () => {
expect(shouldShowDrilldown({ data: { type: "line" } }, "logs")).toBe(false);
});
});
describe("shouldShowTimeShift", () => {
const timeShiftTypes = ["area", "bar", "line", "h-bar", "h-stacked", "scatter", "area-stacked", "stacked"];
const nonTimeShiftTypes = ["pie", "donut", "table", "metric", "gauge"];
timeShiftTypes.forEach((type) => {
it(`should return true for ${type} in non-promql mode with non-logs pageKey`, () => {
expect(shouldShowTimeShift({ data: { type } }, false, "dashboards")).toBe(true);
});
});
nonTimeShiftTypes.forEach((type) => {
it(`should return false for ${type}`, () => {
expect(shouldShowTimeShift({ data: { type } }, false, "dashboards")).toBe(false);
});
});
it("should return false when promqlMode is true", () => {
expect(shouldShowTimeShift({ data: { type: "area" } }, true, "dashboards")).toBe(false);
});
it("should return false when pageKey is logs", () => {
expect(shouldShowTimeShift({ data: { type: "area" } }, false, "logs")).toBe(false);
});
});
});
+175
View File
@@ -158,3 +158,178 @@ export const shouldShowLegendHeightUnitContainer = (
): boolean => {
return shouldShowLegendHeight(dashboardPanelData);
};
/**
* Check if no value replacement input should be displayed
* @param dashboardPanelData - The dashboard panel data
* @param promqlMode - Whether promql mode is active
* @returns boolean indicating if no value replacement input should be displayed
*/
export const shouldShowNoValueReplacement = (
dashboardPanelData: any,
promqlMode: boolean,
): boolean => {
return (
["area", "line", "area-stacked", "bar", "stacked"].includes(
dashboardPanelData.data.type,
) && !promqlMode
);
};
/**
* Check if connect nulls toggle, show symbol select, and line interpolation select should be displayed
* Used by: connect_nulls toggle, show_symbol select, line_interpolation select
* @param dashboardPanelData - The dashboard panel data
* @returns boolean indicating if area/line style config controls should be displayed
*/
export const shouldShowAreaLineStyleConfig = (
dashboardPanelData: any,
): boolean => {
return ["area", "line", "area-stacked"].includes(
dashboardPanelData.data.type,
);
};
/**
* Check if top results config (input and others toggle) should be displayed
* Used by: top_results input, top_results_others toggle
* @param dashboardPanelData - The dashboard panel data
* @param promqlMode - Whether promql mode is active
* @returns boolean indicating if top results config should be displayed
*/
export const shouldShowTopResultsConfig = (
dashboardPanelData: any,
promqlMode: boolean,
): boolean => {
return (
[
"area",
"bar",
"line",
"h-bar",
"h-stacked",
"scatter",
"area-stacked",
"stacked",
].includes(dashboardPanelData.data.type) && !promqlMode
);
};
/**
* Check if axis width input and axis border toggle should be displayed
* Used by: axis_width input, axis_border_show toggle
* @param dashboardPanelData - The dashboard panel data
* @returns boolean indicating if axis config controls should be displayed
*/
export const shouldShowAxisConfig = (dashboardPanelData: any): boolean => {
return (
dashboardPanelData.data.type != "gauge" &&
dashboardPanelData.data.type != "metric" &&
dashboardPanelData.data.type != "geomap" &&
dashboardPanelData.data.type != "table" &&
dashboardPanelData.data.type != "pie" &&
dashboardPanelData.data.type != "donut" &&
dashboardPanelData.data.type != "sankey" &&
dashboardPanelData.data.type != "maps"
);
};
/**
* Check if cartesian chart axis/label/markline config should be displayed
* Used by: y_axis_min/max div, label_option position select, label_option rotate input, MarkLineConfig
* @param dashboardPanelData - The dashboard panel data
* @returns boolean indicating if cartesian axis config should be displayed
*/
export const shouldShowCartesianAxisConfig = (
dashboardPanelData: any,
): boolean => {
return [
"area",
"area-stacked",
"bar",
"h-bar",
"line",
"scatter",
"stacked",
"h-stacked",
].includes(dashboardPanelData.data.type);
};
/**
* Check if axis label rotate and truncate inputs should be displayed
* Used by: axis_label_rotate input, axis_label_truncate_width input (wrapper div)
* @param dashboardPanelData - The dashboard panel data
* @returns boolean indicating if axis label config should be displayed
*/
export const shouldShowAxisLabelConfig = (dashboardPanelData: any): boolean => {
return ["area", "area-stacked", "bar", "line", "scatter", "stacked"].includes(
dashboardPanelData.data.type,
);
};
/**
* Check if line thickness input should be displayed
* Used by: line_thickness input
* @param dashboardPanelData - The dashboard panel data
* @param promqlMode - Whether promql mode is active
* @returns boolean indicating if line thickness input should be displayed
*/
export const shouldShowLineThickness = (
dashboardPanelData: any,
promqlMode: boolean,
): boolean => {
return (
!promqlMode &&
!dashboardPanelData.data.queries[
dashboardPanelData.layout.currentQueryIndex
].customQuery &&
["area", "area-stacked", "line"].includes(dashboardPanelData.data.type)
);
};
/**
* Check if Drilldown component should be displayed
* Used by: Drilldown component
* @param dashboardPanelData - The dashboard panel data
* @param dashboardPanelDataPageKey - The page key context
* @returns boolean indicating if Drilldown should be displayed
*/
export const shouldShowDrilldown = (
dashboardPanelData: any,
dashboardPanelDataPageKey: string,
): boolean => {
return (
!["html", "markdown", "geomap", "maps"].includes(
dashboardPanelData.data.type,
) && dashboardPanelDataPageKey !== "logs"
);
};
/**
* Check if time shift comparison section should be displayed
* Used by: time shift wrapper div (comparisonAgainst section)
* @param dashboardPanelData - The dashboard panel data
* @param promqlMode - Whether promql mode is active
* @param dashboardPanelDataPageKey - The page key context
* @returns boolean indicating if time shift section should be displayed
*/
export const shouldShowTimeShift = (
dashboardPanelData: any,
promqlMode: boolean,
dashboardPanelDataPageKey: string,
): boolean => {
return (
[
"area",
"bar",
"line",
"h-bar",
"h-stacked",
"scatter",
"area-stacked",
"stacked",
].includes(dashboardPanelData.data.type) &&
!promqlMode &&
dashboardPanelDataPageKey !== "logs"
);
};
@@ -17,20 +17,28 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import {
getUnitValue,
formatUnitValue,
} from "@/utils/dashboard/convertDataIntoUnitValue";
import {
isTimeSeries,
isTimeStamp,
formatDate,
convertOffsetToSeconds,
getUTCTimestampFromZonedTimestamp,
} from "@/utils/dashboard/dateTimeUtils";
import {
getContrastColor,
applySeriesColorMappings,
} from "@/utils/dashboard/chartColorUtils";
import {
calculateOptimalFontSize,
calculateWidthText,
applySeriesColorMappings,
convertOffsetToSeconds,
} from "@/utils/dashboard/chartDimensionUtils";
import {
findFirstValidMappedValue,
validatePanel,
validateDashboardJson,
validateSQLPanelFields,
getUTCTimestampFromZonedTimestamp
} from "@/utils/dashboard/convertDataIntoUnitValue";
} from "@/utils/dashboard/panelValidation";
// Mock vuex store for logsUtils
const mockVuexStore = {
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
convertPromQLData,
getPropsByChartTypeForSeries
} from "./convertPromQLData";
import { convertPromQLData } from "./convertPromQLData";
import { getPropsByChartTypeForSeries } from "./promqlChartSeriesProps";
// Mock dependencies
vi.mock("./convertDataIntoUnitValue", () => ({
calculateOptimalFontSize: vi.fn(() => 14),
calculateWidthText: vi.fn((text) => (text?.length || 0) * 8),
formatDate: vi.fn((date) => "2023-12-25 10:00:00"),
formatUnitValue: vi.fn((value) => `${value.value}${value.unit}`),
getContrastColor: vi.fn(() => "#FFFFFF"),
applySeriesColorMappings: vi.fn(),
getUnitValue: vi.fn((value) => ({ value: value?.toString() || "0", unit: "" })),
calculateRightLegendWidth: vi.fn(() => 120),
calculateBottomLegendHeight: vi.fn((legendCount, chartWidth, series, maxHeight, legendConfig, gridConfig, chartHeight) => {
@@ -22,10 +15,24 @@ vi.mock("./convertDataIntoUnitValue", () => ({
}
return 80;
}),
}));
vi.mock("./chartDimensionUtils", () => ({
calculateOptimalFontSize: vi.fn(() => 14),
calculateWidthText: vi.fn((text) => (text?.length || 0) * 8),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
}));
vi.mock("./chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
applySeriesColorMappings: vi.fn(),
}));
vi.mock("./dateTimeUtils", () => ({
formatDate: vi.fn((date) => "2023-12-25 10:00:00"),
}));
vi.mock("date-fns-tz", () => ({
toZonedTime: vi.fn((date) => new Date(date)),
}));
+11 -178
View File
@@ -14,15 +14,19 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
calculateOptimalFontSize,
formatDate,
formatUnitValue,
getContrastColor,
applySeriesColorMappings,
getUnitValue,
} from "./convertDataIntoUnitValue";
import {
applySeriesColorMappings,
getContrastColor,
} from "./chartColorUtils";
import {
calculateOptimalFontSize,
calculateDynamicNameGap,
calculateRotatedLabelBottomSpace,
} from "./convertDataIntoUnitValue";
} from "./chartDimensionUtils";
import { formatDate } from "./dateTimeUtils";
import { toZonedTime } from "date-fns-tz";
import { calculateGridPositions } from "./calculateGridForSubPlot";
import {
@@ -36,6 +40,8 @@ import {
calculateRightLegendWidth,
} from "./legendConfiguration";
import { convertPromQLChartData } from "./promql/convertPromQLChartData";
import { getPromqlLegendName, getLegendPosition } from "./promql/shared/legendBuilder";
import { getPropsByChartTypeForSeries } from "./promqlChartSeriesProps";
let moment: any;
let momentInitialized = false;
@@ -1154,177 +1160,4 @@ export const convertPromQLData = async (
};
};
const calculateWidthText = (text: string): number => {
if (!text) return 0;
const span = document.createElement("span");
document.body.appendChild(span);
span.style.font = "sans-serif";
span.style.fontSize = "12px";
span.style.height = "auto";
span.style.width = "auto";
span.style.top = "0px";
span.style.position = "absolute";
span.style.whiteSpace = "no-wrap";
span.innerHTML = text;
const width = Math.ceil(span.clientWidth);
span.remove();
return width;
};
/**
* Retrieves the legend name for a given metric and label.
*
* @param {any} metric - The metric object containing the values for the legend name placeholders.
* @param {string} label - The label template for the legend name. If null or empty, the metric object will be converted to a JSON string and returned.
* @return {string} The legend name with the placeholders replaced by the corresponding values from the metric object.
*/
const getPromqlLegendName = (metric: any, label: string) => {
if (label) {
let template = label || "";
const placeholders = template.match(/\{([^}]+)\}/g);
// Step 2: Iterate through each placeholder
placeholders?.forEach(function (placeholder: any) {
// Step 3: Extract the key from the placeholder
const key = placeholder.replace("{", "").replace("}", "");
// Step 4: Retrieve the corresponding value from the JSON object
const value = metric[key];
// Step 5: Replace the placeholder with the value in the template
if (value) {
template = template.replace(placeholder, value);
}
});
return template;
} else {
return JSON.stringify(metric);
}
};
/**
* Determines the position of the legend based on the provided legendPosition.
*
* @param {string} legendPosition - The desired position of the legend. Possible values are "bottom" or "right".
* @return {string} The position of the legend. Possible values are "horizontal" or "vertical".
*/
const getLegendPosition = (legendPosition: string) => {
switch (legendPosition) {
case "bottom":
return "horizontal";
case "right":
return "vertical";
default:
return "horizontal";
}
};
/**
* Returns the props object based on the given chart type.
*
* @param {string} type - The chart type.
* @return {object} The props object for the given chart type.
*/
export const getPropsByChartTypeForSeries = (type: string) => {
switch (type) {
case "bar":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "line":
return {
type: "line",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "scatter":
return {
type: "scatter",
emphasis: { focus: "series" },
symbolSize: 5,
};
case "pie":
return {
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "donut":
return {
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "h-bar":
return {
type: "bar",
orientation: "h",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "area":
return {
type: "line",
emphasis: { focus: "series" },
areaStyle: {},
lineStyle: { width: 1.5 },
};
case "stacked":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "area-stacked":
return {
type: "line",
stack: "Total",
areaStyle: {},
emphasis: {
focus: "series",
},
lineStyle: { width: 1.5 },
};
case "gauge":
return {
type: "gauge",
startAngle: 205,
endAngle: -25,
pointer: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisLabel: {
show: false,
},
};
case "metric":
return {
type: "custom",
coordinateSystem: "polar",
};
case "h-stacked":
return {
type: "bar",
emphasis: { focus: "series" },
orientation: "h",
lineStyle: { width: 1.5 },
};
default:
return {
type: "bar",
};
}
};
+16 -7
View File
@@ -36,15 +36,8 @@ vi.mock("date-fns", () => ({
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
calculateOptimalFontSize: vi.fn(() => 12),
calculateWidthText: vi.fn(() => 100),
formatDate: vi.fn(() => "2024-01-01"),
formatUnitValue: vi.fn(() => ({ value: "100", unit: "" })),
getContrastColor: vi.fn(() => "#000000"),
applySeriesColorMappings: vi.fn((series) => series),
getUnitValue: vi.fn(() => ({ value: "100", unit: "" })),
isTimeSeries: vi.fn(() => false),
isTimeStamp: vi.fn(() => false),
calculateBottomLegendHeight: vi.fn((legendCount, chartWidth, series, maxHeight, legendConfig, gridConfig, chartHeight) => {
if (legendConfig && gridConfig && chartHeight) {
legendConfig.top = chartHeight - 90;
@@ -61,10 +54,26 @@ vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
gridSpace: { left: 60, right: 160, top: 60, bottom: 90 }
})),
calculatePieChartRadius: vi.fn(() => 150),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateOptimalFontSize: vi.fn(() => 12),
calculateWidthText: vi.fn(() => 100),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#000000"),
applySeriesColorMappings: vi.fn((series) => series),
}));
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
formatDate: vi.fn(() => "2024-01-01"),
isTimeSeries: vi.fn(() => false),
isTimeStamp: vi.fn(() => false),
}));
vi.mock("@/utils/dashboard/calculateGridForSubPlot", () => ({
calculateGridPositions: vi.fn(() => ({
gridArray: [
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { convertTableData } from "@/utils/dashboard/convertTableData";
import { isTimeSeries, isTimeStamp } from "@/utils/dashboard/convertDataIntoUnitValue";
import { isTimeSeries, isTimeStamp } from "@/utils/dashboard/dateTimeUtils";
// Mock external dependencies
vi.mock("date-fns-tz", () => ({
@@ -23,6 +23,11 @@ vi.mock("date-fns-tz", () => ({
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((unitValue) => unitValue?.value || "0"),
getUnitValue: vi.fn((value, unit, customUnit, decimals) => ({ value: value?.toString() || "0", unit: unit || "" })),
}));
vi.mock("@/utils/dashboard/panelValidation", () => ({
findFirstValidMappedValue: vi.fn((value, mappings, type) => {
if (mappings && mappings.length > 0) {
const mapping = mappings.find((m: any) => m.from === value);
@@ -30,9 +35,10 @@ vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
}
return null;
}),
}));
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
formatDate: vi.fn((date) => "2024-01-01 12:00:00"),
formatUnitValue: vi.fn((unitValue) => unitValue?.value || "0"),
getUnitValue: vi.fn((value, unit, customUnit, decimals) => ({ value: value?.toString() || "0", unit: unit || "" })),
isTimeSeries: vi.fn(() => false),
isTimeStamp: vi.fn(() => false),
}));
+5 -3
View File
@@ -15,13 +15,15 @@
import { toZonedTime } from "date-fns-tz";
import {
findFirstValidMappedValue,
formatDate,
formatUnitValue,
getUnitValue,
} from "./convertDataIntoUnitValue";
import { findFirstValidMappedValue } from "./panelValidation";
import {
formatDate,
isTimeSeries,
isTimeStamp,
} from "./convertDataIntoUnitValue";
} from "./dateTimeUtils";
import { getDataValue } from "./aliasUtils";
// Build a lookup map for value mappings to avoid repeated searches
@@ -0,0 +1,264 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import {
formatDate,
isTimeSeries,
isTimeStamp,
convertOffsetToSeconds,
getUTCTimestampFromZonedTimestamp,
} from "@/utils/dashboard/dateTimeUtils";
vi.mock("quasar", async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
date: {
subtractFromDate: vi.fn((dateInput: any, subtractObj: any) => {
const result = new Date(dateInput);
if (subtractObj.seconds) result.setSeconds(result.getSeconds() - subtractObj.seconds);
if (subtractObj.minutes) result.setMinutes(result.getMinutes() - subtractObj.minutes);
if (subtractObj.hours) result.setHours(result.getHours() - subtractObj.hours);
if (subtractObj.days) result.setDate(result.getDate() - subtractObj.days);
if (subtractObj.months) result.setMonth(result.getMonth() - subtractObj.months);
return result;
}),
},
};
});
describe("dateTimeUtils", () => {
describe("formatDate", () => {
it("formats a date correctly", () => {
const date = new Date(2024, 0, 15, 10, 30, 45); // Jan 15, 2024 10:30:45
expect(formatDate(date)).toBe("2024-01-15 10:30:45");
});
it("pads single-digit month and day", () => {
const date = new Date(2024, 2, 5, 9, 5, 3); // Mar 5, 2024 09:05:03
expect(formatDate(date)).toBe("2024-03-05 09:05:03");
});
it("handles midnight (00:00:00)", () => {
const date = new Date(2024, 11, 31, 0, 0, 0); // Dec 31, 2024
expect(formatDate(date)).toBe("2024-12-31 00:00:00");
});
it("handles end of day (23:59:59)", () => {
const date = new Date(2024, 0, 1, 23, 59, 59);
expect(formatDate(date)).toBe("2024-01-01 23:59:59");
});
it("includes full year", () => {
const date = new Date(2025, 5, 20, 12, 0, 0);
const result = formatDate(date);
expect(result.startsWith("2025-")).toBe(true);
});
});
describe("isTimeSeries", () => {
it("returns true for valid ISO 8601 timestamp array", () => {
const sample = ["2024-01-15T10:30:45", "2024-01-15T11:00:00"];
expect(isTimeSeries(sample)).toBe(true);
});
it("returns false for array with non-ISO values", () => {
const sample = ["not-a-date", "2024-01-15T10:30:45"];
expect(isTimeSeries(sample)).toBe(false);
});
it("returns false for plain date strings without time", () => {
const sample = ["2024-01-15"];
expect(isTimeSeries(sample)).toBe(false);
});
it("returns true for single valid ISO timestamp", () => {
const sample = ["2024-01-15T00:00:00"];
expect(isTimeSeries(sample)).toBe(true);
});
it("returns false for empty string in array", () => {
const sample = [""];
expect(isTimeSeries(sample)).toBe(false);
});
it("returns false for numeric values", () => {
const sample = [1234567890123456];
expect(isTimeSeries(sample)).toBe(false);
});
it("handles array with mixed valid/invalid values", () => {
const sample = ["2024-01-15T10:30:45", "invalid"];
expect(isTimeSeries(sample)).toBe(false);
});
it("returns true for array with all valid ISO timestamps", () => {
const sample = [
"2024-01-15T10:30:45",
"2024-02-20T15:45:00",
"2024-12-31T23:59:59",
];
expect(isTimeSeries(sample)).toBe(true);
});
});
describe("isTimeStamp", () => {
const validMicroseconds = ["1234567890123456", "9876543210654321"];
const invalidValues = ["123", "hello", "12345678901234567"];
it("returns false when treatAsNonTimestamp is true", () => {
expect(isTimeStamp(validMicroseconds, true)).toBe(false);
});
it("returns true for 16-digit values when treatAsNonTimestamp is false", () => {
expect(isTimeStamp(validMicroseconds, false)).toBe(true);
});
it("returns false for non-16-digit values when treatAsNonTimestamp is false", () => {
expect(isTimeStamp(invalidValues, false)).toBe(false);
});
it("returns true for 16-digit values when treatAsNonTimestamp is null", () => {
expect(isTimeStamp(validMicroseconds, null)).toBe(true);
});
it("returns true for 16-digit values when treatAsNonTimestamp is undefined", () => {
expect(isTimeStamp(validMicroseconds, undefined)).toBe(true);
});
it("returns false for non-16-digit values when treatAsNonTimestamp is null", () => {
expect(isTimeStamp(["12345"], null)).toBe(false);
});
it("returns undefined for unknown treatAsNonTimestamp value", () => {
// The function has no return for other values (e.g., "auto")
expect(isTimeStamp(validMicroseconds, "auto")).toBeUndefined();
});
it("handles numeric 16-digit values by converting to string", () => {
const sample = [1234567890123456];
expect(isTimeStamp(sample, false)).toBe(true);
});
it("checks all values in sample (all must be 16-digit)", () => {
const mixed = ["1234567890123456", "12345"]; // second is not 16 digits
expect(isTimeStamp(mixed, false)).toBe(false);
});
});
describe("convertOffsetToSeconds", () => {
const endTimestamp = new Date("2024-01-15T12:00:00Z").getTime();
it("converts seconds offset correctly", () => {
const result = convertOffsetToSeconds("30s", endTimestamp);
expect(result.seconds).toBe(30000); // 30 seconds in ms
expect(result.periodAsStr).toBe("30 Seconds ago");
});
it("converts minutes offset correctly", () => {
const result = convertOffsetToSeconds("5m", endTimestamp);
expect(result.seconds).toBe(300000); // 5 minutes in ms
expect(result.periodAsStr).toBe("5 Minutes ago");
});
it("converts hours offset correctly", () => {
const result = convertOffsetToSeconds("2h", endTimestamp);
expect(result.seconds).toBe(7200000); // 2 hours in ms
expect(result.periodAsStr).toBe("2 Hours ago");
});
it("converts days offset correctly", () => {
const result = convertOffsetToSeconds("1d", endTimestamp);
expect(result.seconds).toBe(86400000); // 1 day in ms
expect(result.periodAsStr).toBe("1 Days ago");
});
it("converts weeks offset correctly (7 days)", () => {
const result = convertOffsetToSeconds("1w", endTimestamp);
expect(result.seconds).toBe(604800000); // 7 days in ms
expect(result.periodAsStr).toBe("1 Weeks ago");
});
it("converts months offset correctly", () => {
const result = convertOffsetToSeconds("1M", endTimestamp);
expect(result.seconds).toBeGreaterThan(0);
expect(result.periodAsStr).toBe("1 Months ago");
});
it("returns 0 seconds for invalid unit", () => {
const result = convertOffsetToSeconds("5x", endTimestamp);
expect(result.seconds).toBe(0);
expect(result.periodAsStr).toBe("");
});
it("returns 0 seconds for NaN numeric part", () => {
const result = convertOffsetToSeconds("xs", endTimestamp);
expect(result.seconds).toBe(0);
expect(result.periodAsStr).toBe("");
});
it("handles 2-week offset (14 days)", () => {
const result = convertOffsetToSeconds("2w", endTimestamp);
expect(result.seconds).toBe(1209600000); // 14 days in ms
expect(result.periodAsStr).toBe("2 Weeks ago");
});
});
describe("getUTCTimestampFromZonedTimestamp", () => {
it("returns null for falsy timestampMs", () => {
expect(getUTCTimestampFromZonedTimestamp(0, "UTC")).toBeNull();
});
it("returns null for null timestampMs", () => {
expect(getUTCTimestampFromZonedTimestamp(null as any, "UTC")).toBeNull();
});
it("converts UTC timestamp and returns microseconds", () => {
// 2024-01-15T12:00:00Z in milliseconds
const ts = new Date("2024-01-15T12:00:00Z").getTime();
const result = getUTCTimestampFromZonedTimestamp(ts, "UTC");
// Result should be in microseconds range (much larger than ms)
expect(result).toBeGreaterThan(ts);
// Should be approximately ts * 1000 (within 24h of timezone offset)
const approxExpected = Math.trunc(ts * 1000);
expect(Math.abs((result as number) - approxExpected)).toBeLessThan(
24 * 60 * 60 * 1000 * 1000, // 24h in microseconds
);
});
it("returns a number (microseconds)", () => {
const ts = new Date("2024-01-15T12:00:00Z").getTime();
const result = getUTCTimestampFromZonedTimestamp(ts, "UTC");
expect(typeof result).toBe("number");
// Microseconds should be much larger than milliseconds
expect(result).toBeGreaterThan(ts);
});
it("returns integer (Math.trunc)", () => {
const ts = new Date("2024-01-15T12:00:00.500Z").getTime();
const result = getUTCTimestampFromZonedTimestamp(ts, "UTC");
expect(Number.isInteger(result)).toBe(true);
});
it("handles different timezones", () => {
const ts = new Date("2024-01-15T12:00:00Z").getTime();
const utcResult = getUTCTimestampFromZonedTimestamp(ts, "UTC");
const nyResult = getUTCTimestampFromZonedTimestamp(ts, "America/New_York");
// Results should differ for different timezones
expect(utcResult).not.toBe(nyResult);
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { date } from "quasar";
import { fromZonedTime } from "date-fns-tz";
/**
* Formats the given date into a string in the format "YY-MM-DD HH:MM:SS".
*
* @param {any} date - The date to be formatted.
* @return {string} The formatted date string.
*/
export const formatDate = (date: any) => {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// Check if the sample is time series
export const isTimeSeries = (sample: any) => {
const iso8601Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;
return sample.every((value: any) => {
return iso8601Pattern.test(value);
});
};
// Check if the sample is timestamp (16 digit microseconds)
/**
* Checks if the sample is timestamp (16 digit microseconds) based on treatAsNonTimestamp object.
* treatAsNonTimestamp: { value: true | false | null | undefined | "auto" }
* - If treatAsNonTimestamp.value === true (not timestamp field): Return false
* - If treatAsNonTimestamp.value === false (timestamp field): Check if all values are 16 digits, return true if they are
* - If treatAsNonTimestamp.value === null or undefined (auto mode): Check if all values are 16 digits, return true if they are
* - Otherwise: Return false
*/
export const isTimeStamp = (sample: any, treatAsNonTimestamp: any) => {
const microsecondsPattern = /^\d{16}$/;
// If treatAsNonTimestamp is true (not timestamp field), return false
if (treatAsNonTimestamp === true) {
return false;
}
// If treatAsNonTimestamp is false (timestamp field), check if all values are 16 digit numbers
if (treatAsNonTimestamp === false) {
return sample.every((value: any) =>
microsecondsPattern.test(value?.toString()),
);
}
// If treatAsNonTimestamp is null or undefined, check if all values are 16 digits
if (treatAsNonTimestamp === null || treatAsNonTimestamp === undefined) {
return sample.every((value: any) =>
microsecondsPattern.test(value?.toString()),
);
}
};
export function convertOffsetToSeconds(
offset: string,
endISOTimestamp: number,
) {
try {
const periodValue = parseInt(offset.slice(0, -1)); // Extract the numeric part
const period = offset.slice(-1); // Extract the last character (unit)
if (isNaN(periodValue)) {
return {
seconds: 0,
periodAsStr: "",
};
}
const subtractObject: any = {};
let periodAsStr = periodValue.toString();
switch (period) {
case "s": // Seconds
subtractObject.seconds = periodValue;
periodAsStr += " Seconds ago";
break;
case "m": // Minutes
subtractObject.minutes = periodValue;
periodAsStr += " Minutes ago";
break;
case "h": // Hours
subtractObject.hours = periodValue;
periodAsStr += " Hours ago";
break;
case "d": // Days
subtractObject.days = periodValue;
periodAsStr += " Days ago";
break;
case "w": // Weeks
subtractObject.days = periodValue * 7;
periodAsStr += " Weeks ago";
break;
case "M": // Months (approximate, using 30 days per month)
subtractObject.months = periodValue;
periodAsStr += " Months ago";
break;
default:
return {
seconds: 0,
periodAsStr: "",
};
}
// subtract period from endISOTimestamp
const startTimeStamp = date.subtractFromDate(
endISOTimestamp,
subtractObject,
);
// return difference of seconds between endISOTimestamp and startTimeStamp
return {
seconds: endISOTimestamp - startTimeStamp.getTime(),
periodAsStr: periodAsStr,
};
} catch (error) {
console.log(error);
return {
seconds: 0,
periodAsStr: "",
};
}
}
// Function to convert chart timestamp (timezone-adjusted) back to UTC
export const getUTCTimestampFromZonedTimestamp = (
timestampMs: number,
currentTimeZone: string,
) => {
if (!timestampMs) return null;
// Use fromZonedTime to convert from currentTimeZone back to UTC
const zonedDate = new Date(timestampMs);
const utcDate = fromZonedTime(zonedDate, currentTimeZone);
const utcMs = utcDate.getTime();
return Math.trunc(utcMs * 1000); // milliseconds to microseconds
};
@@ -33,7 +33,7 @@ import {
} from "./legendConfiguration";
// Mock calculateWidthText
vi.mock("./convertDataIntoUnitValue", () => ({
vi.mock("./chartDimensionUtils", () => ({
calculateWidthText: vi.fn((text: string) => {
// Simple mock: return text length * 8 as approximate width
return (text?.length || 0) * 8;
@@ -13,7 +13,7 @@
// 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 { calculateWidthText } from "./convertDataIntoUnitValue";
import { calculateWidthText } from "./chartDimensionUtils";
// Constants for better maintainability
const LEGEND_CONSTANTS = {
@@ -992,7 +992,7 @@ export const applyPieDonutLegendPositioning = (
* @param {any[]} seriesData - Series data for legend calculations
* @returns {object} Object containing grid properties and available dimensions
*/
const calculatePieChartContainer = (
export const calculatePieChartContainer = (
panelSchema: any,
chartWidth: number,
chartHeight: number,
@@ -0,0 +1,284 @@
// 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 { describe, expect, it, vi } from "vitest";
import {
findFirstValidMappedValue,
validateDashboardJson,
} from "@/utils/dashboard/panelValidation";
vi.mock("@/utils/dashboard/convertDashboardSchemaVersion", () => ({
CURRENT_DASHBOARD_SCHEMA_VERSION: "v3",
}));
vi.mock(
"@/components/dashboards/addPanel/dynamicFunction/functionValidation.json",
() => ({
default: [],
}),
);
describe("panelValidation", () => {
describe("findFirstValidMappedValue", () => {
describe("value type mappings", () => {
it("matches exact value and returns mapping", () => {
const mappings = [{ type: "value", value: "critical", color: "#FF0000" }];
const result = findFirstValidMappedValue("critical", mappings, "color");
expect(result).toBeDefined();
expect(result?.color).toBe("#FF0000");
});
it("does not match different value", () => {
const mappings = [{ type: "value", value: "critical", color: "#FF0000" }];
const result = findFirstValidMappedValue("warning", mappings, "color");
expect(result).toBeUndefined();
});
it("returns undefined when fieldToCheck is missing from mapping", () => {
const mappings = [{ type: "value", value: "critical" }]; // no color
const result = findFirstValidMappedValue("critical", mappings, "color");
expect(result).toBeUndefined();
});
});
describe("range type mappings", () => {
it("matches value within range", () => {
const mappings = [{ type: "range", from: "0", to: "100", color: "#00FF00" }];
const result = findFirstValidMappedValue(50, mappings, "color");
expect(result).toBeDefined();
});
it("matches boundary value (from)", () => {
const mappings = [{ type: "range", from: "0", to: "100", color: "#00FF00" }];
const result = findFirstValidMappedValue(0, mappings, "color");
expect(result).toBeDefined();
});
it("matches boundary value (to)", () => {
const mappings = [{ type: "range", from: "0", to: "100", color: "#00FF00" }];
const result = findFirstValidMappedValue(100, mappings, "color");
expect(result).toBeDefined();
});
it("does not match value outside range", () => {
const mappings = [{ type: "range", from: "0", to: "100", color: "#00FF00" }];
const result = findFirstValidMappedValue(150, mappings, "color");
expect(result).toBeUndefined();
});
it("does not match when from/to are NaN", () => {
const mappings = [{ type: "range", from: "abc", to: "xyz", color: "#00FF00" }];
const result = findFirstValidMappedValue(50, mappings, "color");
expect(result).toBeUndefined();
});
});
describe("regex type mappings", () => {
it("matches value against regex pattern", () => {
const mappings = [{ type: "regex", pattern: "^error.*", text: "Error!" }];
const result = findFirstValidMappedValue("error_500", mappings, "text");
expect(result).toBeDefined();
});
it("does not match value that doesn't fit pattern", () => {
const mappings = [{ type: "regex", pattern: "^error.*", text: "Error!" }];
const result = findFirstValidMappedValue("warning_404", mappings, "text");
expect(result).toBeUndefined();
});
it("uses empty pattern when pattern is null (matches everything)", () => {
const mappings = [{ type: "regex", pattern: null, text: "Default" }];
const result = findFirstValidMappedValue("anything", mappings, "text");
expect(result).toBeDefined();
});
});
describe("first match wins", () => {
it("returns first matching mapping", () => {
const mappings = [
{ type: "value", value: "critical", color: "#FF0000" },
{ type: "value", value: "critical", color: "#00FF00" },
];
const result = findFirstValidMappedValue("critical", mappings, "color");
expect(result?.color).toBe("#FF0000");
});
});
describe("null/undefined input", () => {
it("returns undefined for null mappings", () => {
const result = findFirstValidMappedValue("critical", null as any, "color");
expect(result).toBeUndefined();
});
it("returns undefined for undefined mappings", () => {
const result = findFirstValidMappedValue("critical", undefined as any, "color");
expect(result).toBeUndefined();
});
it("returns undefined for empty mappings", () => {
const result = findFirstValidMappedValue("critical", [], "color");
expect(result).toBeUndefined();
});
});
});
describe("validateDashboardJson", () => {
const validDashboard = {
dashboardId: "dash-001",
title: "Test Dashboard",
version: "v3",
tabs: [
{
tabId: "tab-001",
name: "Tab 1",
panels: [
{
id: "panel-001",
type: "bar",
title: "Test Panel",
layout: { i: "panel-001", x: 0, y: 0, w: 12, h: 6 },
},
],
},
],
};
it("returns no errors for valid dashboard", () => {
const errors = validateDashboardJson(validDashboard);
expect(errors).toEqual([]);
});
it("returns error for null dashboard", () => {
const errors = validateDashboardJson(null);
expect(errors).toContain("Dashboard JSON is empty or invalid");
});
it("returns error for undefined dashboard", () => {
const errors = validateDashboardJson(undefined);
expect(errors).toContain("Dashboard JSON is empty or invalid");
});
it("returns error for missing dashboardId", () => {
const errors = validateDashboardJson({ ...validDashboard, dashboardId: undefined });
expect(errors).toContain("Dashboard ID is required");
});
it("returns error for missing title", () => {
const errors = validateDashboardJson({ ...validDashboard, title: undefined });
expect(errors).toContain("Dashboard title is required");
});
it("returns error for missing version", () => {
const errors = validateDashboardJson({ ...validDashboard, version: undefined });
expect(errors).toContain("Dashboard version is required");
});
it("returns error for wrong version", () => {
const errors = validateDashboardJson({ ...validDashboard, version: "v1" });
expect(errors.some(e => e.includes("v3"))).toBe(true);
});
it("returns error for missing tabs", () => {
const errors = validateDashboardJson({ ...validDashboard, tabs: undefined });
expect(errors).toContain("Dashboard must have at least one tab");
});
it("returns error for empty tabs array", () => {
const errors = validateDashboardJson({ ...validDashboard, tabs: [] });
expect(errors).toContain("Dashboard must have at least one tab");
});
it("returns error for tab missing tabId", () => {
const dashboard = {
...validDashboard,
tabs: [{ name: "Tab 1", panels: [] }],
};
const errors = validateDashboardJson(dashboard);
expect(errors).toContain("Each tab must have a tabId");
});
it("returns error for tab missing name", () => {
const dashboard = {
...validDashboard,
tabs: [{ tabId: "tab-001", panels: [] }],
};
const errors = validateDashboardJson(dashboard);
expect(errors.some(e => e.includes("must have a name"))).toBe(true);
});
it("returns error for duplicate tab IDs", () => {
const dashboard = {
...validDashboard,
tabs: [
{ tabId: "tab-001", name: "Tab 1", panels: [] },
{ tabId: "tab-001", name: "Tab 2", panels: [] },
],
};
const errors = validateDashboardJson(dashboard);
expect(errors.some(e => e.includes("Duplicate tab ID"))).toBe(true);
});
it("returns error for panel missing ID", () => {
const dashboard = {
...validDashboard,
tabs: [
{
tabId: "tab-001",
name: "Tab 1",
panels: [{ layout: { i: "panel-001" } }], // no id
},
],
};
const errors = validateDashboardJson(dashboard);
expect(errors.some(e => e.includes("missing an ID"))).toBe(true);
});
it("returns error for duplicate panel IDs", () => {
const dashboard = {
...validDashboard,
tabs: [
{
tabId: "tab-001",
name: "Tab 1",
panels: [
{ id: "panel-001", layout: { i: "panel-001" } },
{ id: "panel-001", layout: { i: "panel-001" } },
],
},
],
};
const errors = validateDashboardJson(dashboard);
expect(errors.some(e => e.includes("Duplicate panel ID"))).toBe(true);
});
it("can return multiple errors", () => {
const errors = validateDashboardJson({
version: "v3",
tabs: [{ tabId: "t1", name: "T", panels: [] }],
// no dashboardId, no title
});
expect(errors.length).toBeGreaterThan(1);
});
it("returns error when tab panels is not an array", () => {
const dashboard = {
...validDashboard,
tabs: [{ tabId: "tab-001", name: "Tab 1", panels: null }],
};
const errors = validateDashboardJson(dashboard);
expect(errors.some(e => e.includes("must have a panels array"))).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -53,12 +53,18 @@ vi.mock("../convertDataIntoUnitValue", () => ({
return { value: value.toFixed(decimals), unit: "" };
}),
formatUnitValue: vi.fn((obj) => `${obj.value}${obj.unit}`),
}));
vi.mock("../dateTimeUtils", () => ({
formatDate: vi.fn((date) => {
if (typeof date === "number") {
return new Date(date).toISOString();
}
return date.toISOString();
}),
}));
vi.mock("../panelValidation", () => ({
findFirstValidMappedValue: vi.fn((val, mappings, type) => {
if (!mappings || mappings.length === 0) return null;
@@ -178,7 +184,7 @@ describe("TableConverter", () => {
it("should apply value mappings in single mode", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({
text: "High",
});
@@ -590,7 +596,7 @@ describe("TableConverter", () => {
it("should apply value mappings to label columns in expanded_timeseries mode", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({
text: "Production",
});
@@ -747,7 +753,7 @@ describe("TableConverter", () => {
it("should apply value mappings in value column in expanded_timeseries mode", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({
text: "High",
});
@@ -1136,7 +1142,7 @@ describe("TableConverter", () => {
it("should apply value mappings to label columns in all mode", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({
text: "Production",
});
@@ -1711,7 +1717,7 @@ describe("TableConverter", () => {
describe("Value Mapping Edge Cases", () => {
it("should handle value mapping when mapped text returns null", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce(null);
const processedData: ProcessedPromQLData[] = [
@@ -1750,7 +1756,7 @@ describe("TableConverter", () => {
it("should handle value mapping without text property", async () => {
const { findFirstValidMappedValue } =
await import("../convertDataIntoUnitValue");
await import("../panelValidation");
vi.mocked(findFirstValidMappedValue).mockReturnValueOnce({});
const processedData: ProcessedPromQLData[] = [
@@ -18,9 +18,9 @@ import { applyAggregation } from "./shared/dataProcessor";
import {
getUnitValue,
formatUnitValue,
formatDate,
findFirstValidMappedValue,
} from "../convertDataIntoUnitValue";
import { formatDate } from "../dateTimeUtils";
import { findFirstValidMappedValue } from "../panelValidation";
import { toZonedTime } from "date-fns-tz";
/**
@@ -0,0 +1,184 @@
// 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 { describe, expect, it } from "vitest";
import { getPropsByChartTypeForSeries } from "@/utils/dashboard/promqlChartSeriesProps";
describe("promqlChartSeriesProps", () => {
describe("getPropsByChartTypeForSeries", () => {
it("returns bar series props for 'bar' type", () => {
const result = getPropsByChartTypeForSeries("bar");
expect(result).toEqual({
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns line series props for 'line' type", () => {
const result = getPropsByChartTypeForSeries("line");
expect(result).toEqual({
type: "line",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns scatter series props for 'scatter' type", () => {
const result = getPropsByChartTypeForSeries("scatter");
expect(result).toEqual({
type: "scatter",
emphasis: { focus: "series" },
symbolSize: 5,
});
});
it("returns pie series props for 'pie' type", () => {
const result = getPropsByChartTypeForSeries("pie");
expect(result).toEqual({
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns pie series props for 'donut' type (donut uses pie)", () => {
const result = getPropsByChartTypeForSeries("donut");
expect(result.type).toBe("pie");
expect(result).toEqual({
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns h-bar series props for 'h-bar' type", () => {
const result = getPropsByChartTypeForSeries("h-bar");
expect(result).toEqual({
type: "bar",
orientation: "h",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns area series props for 'area' type", () => {
const result = getPropsByChartTypeForSeries("area");
expect(result).toEqual({
type: "line",
emphasis: { focus: "series" },
areaStyle: {},
lineStyle: { width: 1.5 },
});
});
it("returns stacked bar series props for 'stacked' type", () => {
const result = getPropsByChartTypeForSeries("stacked");
expect(result).toEqual({
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns area-stacked series props for 'area-stacked' type", () => {
const result = getPropsByChartTypeForSeries("area-stacked");
expect(result).toEqual({
type: "line",
stack: "Total",
areaStyle: {},
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
});
});
it("returns gauge series props for 'gauge' type", () => {
const result = getPropsByChartTypeForSeries("gauge");
expect(result.type).toBe("gauge");
expect(result).toMatchObject({
type: "gauge",
startAngle: 205,
endAngle: -25,
pointer: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
});
});
it("returns metric series props for 'metric' type", () => {
const result = getPropsByChartTypeForSeries("metric");
expect(result).toEqual({
type: "custom",
coordinateSystem: "polar",
});
});
it("returns h-stacked series props for 'h-stacked' type", () => {
const result = getPropsByChartTypeForSeries("h-stacked");
expect(result).toEqual({
type: "bar",
emphasis: { focus: "series" },
orientation: "h",
lineStyle: { width: 1.5 },
});
});
it("returns default bar props for unknown type", () => {
const result = getPropsByChartTypeForSeries("unknown-type");
expect(result).toEqual({ type: "bar" });
});
it("returns default bar props for empty string", () => {
const result = getPropsByChartTypeForSeries("");
expect(result).toEqual({ type: "bar" });
});
it("area type includes areaStyle property", () => {
const result = getPropsByChartTypeForSeries("area");
expect(result).toHaveProperty("areaStyle");
});
it("area-stacked type has stack 'Total'", () => {
const result = getPropsByChartTypeForSeries("area-stacked");
expect((result as any).stack).toBe("Total");
});
it("scatter type has symbolSize", () => {
const result = getPropsByChartTypeForSeries("scatter");
expect((result as any).symbolSize).toBe(5);
});
it("h-bar and h-stacked have horizontal orientation", () => {
const hBar = getPropsByChartTypeForSeries("h-bar");
const hStacked = getPropsByChartTypeForSeries("h-stacked");
expect((hBar as any).orientation).toBe("h");
expect((hStacked as any).orientation).toBe("h");
});
it("all common chart types return valid objects", () => {
const types = [
"bar", "line", "scatter", "pie", "donut", "h-bar",
"area", "stacked", "area-stacked", "gauge", "metric", "h-stacked",
];
types.forEach((type) => {
const result = getPropsByChartTypeForSeries(type);
expect(result).toBeDefined();
expect(typeof result).toBe("object");
expect(result).toHaveProperty("type");
});
});
});
});
@@ -0,0 +1,119 @@
// 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/>.
/**
* Returns the ECharts series props object based on the given PromQL chart type.
*
* @param {string} type - The chart type (e.g. "bar", "line", "area", "gauge", etc.)
* @return {object} The series props object for the given chart type.
*/
export const getPropsByChartTypeForSeries = (type: string) => {
switch (type) {
case "bar":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "line":
return {
type: "line",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "scatter":
return {
type: "scatter",
emphasis: { focus: "series" },
symbolSize: 5,
};
case "pie":
return {
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "donut":
return {
type: "pie",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "h-bar":
return {
type: "bar",
orientation: "h",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "area":
return {
type: "line",
emphasis: { focus: "series" },
areaStyle: {},
lineStyle: { width: 1.5 },
};
case "stacked":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "area-stacked":
return {
type: "line",
stack: "Total",
areaStyle: {},
emphasis: {
focus: "series",
},
lineStyle: { width: 1.5 },
};
case "gauge":
return {
type: "gauge",
startAngle: 205,
endAngle: -25,
pointer: {
show: false,
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisLabel: {
show: false,
},
};
case "metric":
return {
type: "custom",
coordinateSystem: "polar",
};
case "h-stacked":
return {
type: "bar",
emphasis: { focus: "series" },
orientation: "h",
lineStyle: { width: 1.5 },
};
default:
return {
type: "bar",
};
}
};
@@ -0,0 +1,312 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyMetricChart } from "@/utils/dashboard/sql/charts/convertSQLMetricChart";
import { applyStackedChart } from "@/utils/dashboard/sql/charts/convertSQLStackedChart";
import { applyHStackedChart } from "@/utils/dashboard/sql/charts/convertSQLHStackedChart";
import { applyHBarChart } from "@/utils/dashboard/sql/charts/convertSQLHBarChart";
import { applyLineAreaScatterBarChart } from "@/utils/dashboard/sql/charts/convertSQLLineAreaChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
applyPieDonutChartAlignment: vi.fn(),
applyPieDonutCenterAdjustment: vi.fn(),
calculatePieChartContainer: vi.fn(() => ({ left: "15%", right: "15%", top: "15%", bottom: "15%" })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
/**
* Builds a minimal SQLContext mock that provides the fields required by chart
* converters without pulling in the full application bootstrap.
*/
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [
{ name: "series1", data: [1, 2, 3] },
]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120 }, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: {
axisPointer: {},
textStyle: {},
},
grid: {},
},
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
trellis: { layout: null },
},
queries: [
{
fields: {
y: [{ label: "Value" }],
breakdown: [],
},
customQuery: false,
},
],
},
store: {
state: {
theme: "light",
zoConfig: { timestamp_column: "_timestamp" },
},
},
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
chartPanelStyle: {},
hoveredSeriesState: { value: null },
annotations: [],
metadata: {},
resultMetaData: [],
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
zAxisKeys: [],
breakDownKeys: [],
missingValueData: [],
extras: {},
showGridlines: true,
hasTimestampField: false,
isHorizontalChart: false,
dynamicXAxisNameGap: 25,
convertedTimeStampToDataFormat: "",
defaultSeriesProps: { type: "bar", emphasis: { focus: "series" } },
chartMin: 0,
chartMax: 100,
defaultGrid: { containLabel: true },
getAxisDataFromKey,
getSeries,
getAnnotationMarkLine: vi.fn(() => null),
getSeriesMarkArea: vi.fn(() => null),
getPieChartRadius: vi.fn(() => "50%"),
updateTrellisConfig,
...overrides,
};
}
describe("SQL Chart Converters", () => {
describe("applyMetricChart", () => {
it("sets series array on options", () => {
const ctx = makeMockContext({
panelSchema: {
type: "metric",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }] }, customQuery: false }],
},
});
applyMetricChart(ctx);
expect(Array.isArray(ctx.options.series)).toBe(true);
expect(ctx.options.series.length).toBeGreaterThan(0);
});
it("sets xAxis to empty array", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.xAxis).toEqual([]);
});
it("sets yAxis to empty array", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.yAxis).toEqual([]);
});
it("disables tooltip", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.tooltip.show).toBe(false);
});
it("sets dataset", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.dataset).toBeDefined();
});
it("sets polar coordinate", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.polar).toBeDefined();
});
});
describe("applyStackedChart", () => {
it("sets series using getSeries", () => {
const ctx = makeMockContext({ panelSchema: { type: "stacked", config: { decimals: 2, trellis: { layout: null } }, queries: [{ fields: { y: [{ label: "Value" }] }, customQuery: false }] } });
applyStackedChart(ctx);
expect(ctx.getSeries).toHaveBeenCalled();
expect(Array.isArray(ctx.options.series)).toBe(true);
});
it("slices xAxis to one element", () => {
const ctx = makeMockContext();
// Give it two xAxis elements
ctx.options.xAxis = [
{ data: ["Jan", "Feb"], axisLabel: { rotate: 0, margin: 5 }, axisTick: {}, nameGap: 25 },
{ data: ["A", "B"], axisLabel: { rotate: 0, margin: 5 }, axisTick: {}, nameGap: 25 },
];
ctx.panelSchema = {
...ctx.panelSchema,
type: "stacked",
config: { ...ctx.panelSchema.config, axis_label_truncate_width: 120, decimals: 2 },
};
applyStackedChart(ctx);
expect(ctx.options.xAxis).toHaveLength(1);
});
it("sets tooltip axisPointer label", () => {
const ctx = makeMockContext();
ctx.panelSchema = { ...ctx.panelSchema, type: "stacked", config: { ...ctx.panelSchema.config, decimals: 2 } };
applyStackedChart(ctx);
expect(ctx.options.tooltip.axisPointer.label).toBeDefined();
});
});
describe("applyHStackedChart", () => {
it("swaps xAxis and yAxis", () => {
const ctx = makeMockContext();
ctx.options.xAxis = [{ data: ["A", "B"], axisLabel: { width: 80 }, name: "X" }];
ctx.options.yAxis = [{ data: ["1", "2"], axisLabel: { width: 60 }, name: "Y" }];
applyHStackedChart(ctx);
// The function overwrites xAxis[0].data with getAxisDataFromKey(xAxisKeys[0]) before swapping
// xAxisKeys[0] = "x1", getAxisDataFromKey("x1") = ["Jan", "Feb", "Mar"]
// After swap: xAxis = original yAxis, yAxis = sliced (updated) xAxis
expect(ctx.options.xAxis[0].data).toEqual(["1", "2"]);
expect(ctx.options.yAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("calls getSeries", () => {
const ctx = makeMockContext();
ctx.options.xAxis = [{ data: [], axisLabel: { width: 80 }, name: "" }];
ctx.options.yAxis = [{ data: [], axisLabel: { width: 60 }, name: "" }];
applyHStackedChart(ctx);
expect(ctx.getSeries).toHaveBeenCalled();
});
it("sets nameGap on yAxis elements", () => {
const ctx = makeMockContext();
ctx.options.xAxis = [{ data: ["A", "B"], axisLabel: { width: 100 }, name: "" }];
ctx.options.yAxis = [{ data: ["Jan", "Feb"], axisLabel: { width: 100 }, name: "" }];
applyHStackedChart(ctx);
// After swap, yAxis is original xAxis which should have nameGap set
ctx.options.yAxis.forEach((axis: any) => {
expect(axis.nameGap).toBeDefined();
});
});
});
describe("applyHBarChart", () => {
it("swaps xAxis and yAxis", () => {
const ctx = makeMockContext();
ctx.options.xAxis = [{ data: ["A", "B"], axisLabel: { width: 80 }, name: "" }];
ctx.options.yAxis = [{ data: ["1", "2"], axisLabel: { width: 60 }, name: "" }];
ctx.panelSchema = {
...ctx.panelSchema,
type: "h-bar",
config: { ...ctx.panelSchema.config, trellis: { layout: null } },
};
applyHBarChart(ctx);
// series should be set
expect(ctx.getSeries).toHaveBeenCalled();
});
it("sets xAxis name from query fields", () => {
const ctx = makeMockContext();
ctx.options.xAxis = [{ data: ["A"], axisLabel: { width: 80, overflow: "" }, nameGap: 0, name: "" }];
ctx.options.yAxis = [{ data: [], axisLabel: { width: 60 }, name: "" }];
ctx.panelSchema = {
...ctx.panelSchema,
type: "h-bar",
config: { ...ctx.panelSchema.config, trellis: { layout: null } },
queries: [{ fields: { y: [{ label: "Metrics" }] }, customQuery: false }],
};
applyHBarChart(ctx);
// After swap, original yAxis (now xAxis) should have name set
expect(ctx.options.xAxis.name).toBe("Metrics");
});
});
describe("applyLineAreaScatterBarChart", () => {
it("sets series when area-stacked type", () => {
const ctx = makeMockContext();
ctx.panelSchema = {
...ctx.panelSchema,
type: "area-stacked",
config: { ...ctx.panelSchema.config, decimals: 2 },
queries: [{ fields: { x: [], y: [{ label: "Value" }], breakdown: [{ alias: "cat" }] }, customQuery: false }],
};
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalled();
});
it("does not crash for line chart without breakdown", () => {
const ctx = makeMockContext();
ctx.panelSchema = {
...ctx.panelSchema,
type: "line",
queries: [{ fields: { x: [], y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
};
expect(() => applyLineAreaScatterBarChart(ctx)).not.toThrow();
});
});
});
@@ -0,0 +1,170 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyGaugeChart } from "@/utils/dashboard/sql/charts/convertSQLGaugeChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/calculateGridForSubPlot", () => ({
calculateGridPositions: vi.fn(() => ({
gridArray: [
{ left: "5%", top: "5%", width: "45%", height: "90%" },
{ left: "55%", top: "5%", width: "45%", height: "90%" },
],
gridWidth: 200,
gridHeight: 200,
})),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
return {
options: {
xAxis: [{ data: [] }],
yAxis: [{ data: [] }],
series: [],
tooltip: {},
grid: {},
},
panelSchema: {
type: "gauge",
config: { unit: "default", unit_custom: "", decimals: 2 },
queries: [{ config: { min: 0, max: 100 }, fields: { y: [{ label: "Value" }] } }],
},
store: {
state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } },
},
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
chartMin: 0,
chartMax: 100,
defaultSeriesProps: { type: "gauge" },
getAxisDataFromKey: vi.fn((key: string) => {
if (key === "y1") return [75, 90];
if (key === "x1") return ["Gauge1", "Gauge2"];
return [];
}),
...overrides,
};
}
describe("applyGaugeChart", () => {
it("sets series with one item per yAxisValue", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
// getAxisDataFromKey("y1") returns [75, 90] => 2 series items
expect(ctx.options.series.length).toBe(2);
});
it("clears xAxis to empty array", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.xAxis).toEqual([]);
});
it("clears yAxis to empty array", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.yAxis).toEqual([]);
});
it("sets dataset", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.dataset).toBeDefined();
expect(ctx.options.dataset).toEqual({ source: [[]] });
});
it("sets polar", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.polar).toBeDefined();
expect(ctx.options.polar).toEqual({});
});
it("sets angleAxis with show:false", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.angleAxis).toBeDefined();
expect(ctx.options.angleAxis.show).toBe(false);
});
it("sets radiusAxis with show:false", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.radiusAxis).toBeDefined();
expect(ctx.options.radiusAxis.show).toBe(false);
});
it("tooltip trigger is item", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.tooltip.trigger).toBe("item");
});
it("uses grid from calculateGridPositions", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
// calculateGridPositions mock returns gridArray with two elements (one per gauge)
expect(Array.isArray(ctx.options.grid)).toBe(true);
expect(ctx.options.grid[0]).toMatchObject({
left: "5%",
top: "5%",
width: "45%",
height: "90%",
});
expect(ctx.options.grid[1]).toMatchObject({
left: "55%",
top: "5%",
width: "45%",
height: "90%",
});
});
it("series data values come from yAxisValue", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
// First series item should have value 75 (first element of getAxisDataFromKey("y1"))
expect(ctx.options.series[0].data[0].value).toBe(75);
});
it("uses min/max from panelSchema config", () => {
const ctx = makeMockContext();
applyGaugeChart(ctx);
expect(ctx.options.series[0].min).toBe(0);
expect(ctx.options.series[0].max).toBe(100);
});
it("uses custom min/max when provided", () => {
const ctx = makeMockContext({
panelSchema: {
type: "gauge",
config: { unit: "default", unit_custom: "", decimals: 2 },
queries: [{ config: { min: 10, max: 500 }, fields: { y: [{ label: "Value" }] } }],
},
});
applyGaugeChart(ctx);
expect(ctx.options.series[0].min).toBe(10);
expect(ctx.options.series[0].max).toBe(500);
});
});
@@ -0,0 +1,186 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { calculateGridPositions } from "../../calculateGridForSubPlot";
import { getSeriesColor } from "../../colorPalette";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: gauge
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyGaugeChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
xAxisKeys,
yAxisKeys,
chartMin,
chartMax,
chartPanelRef,
defaultSeriesProps,
getAxisDataFromKey,
} = ctx;
const key1 = yAxisKeys[0];
const yAxisValue = getAxisDataFromKey(key1);
// used for gauge name
const xAxisValue = getAxisDataFromKey(xAxisKeys[0]);
// create grid array based on chart panel width, height and total no. of gauge
const gridDataForGauge = calculateGridPositions(
chartPanelRef.value.offsetWidth,
chartPanelRef.value.offsetHeight,
yAxisValue.length || 1,
);
options.dataset = { source: [[]] };
options.tooltip = {
show: true,
trigger: "item",
textStyle: {
color: store.state.theme === "dark" ? "#fff" : "#000",
fontSize: 12,
},
valueFormatter: (value: any) => {
try {
// unit conversion
return formatUnitValue(
getUnitValue(
value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
} catch (error) {
return value ?? "";
}
},
enterable: true,
backgroundColor:
store.state.theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)",
extraCssText:
"max-height: 200px; overflow: auto; max-width: 500px; user-select: text; scrollbar-width: thin; scrollbar-color: rgba(128,128,128,0.5) transparent;",
};
options.angleAxis = {
show: false,
};
options.radiusAxis = {
show: false,
};
options.polar = {};
options.xAxis = [];
options.yAxis = [];
// for each gauge we have separate grid
options.grid = gridDataForGauge.gridArray;
const gaugeData = yAxisValue.length > 0 ? yAxisValue : [0];
const gaugeNames = xAxisValue.length > 0 ? xAxisValue : [""];
options.series = gaugeData.map((it: any, index: any) => {
return {
...defaultSeriesProps,
min: panelSchema?.queries[0]?.config?.min || 0,
max: panelSchema?.queries[0]?.config?.max || 100,
//which grid will be used
gridIndex: index,
// radius, progress and axisline width will be calculated based on grid width and height
radius: `${
Math.min(gridDataForGauge.gridWidth, gridDataForGauge.gridHeight) / 2 -
5
}px`,
progress: {
show: true,
width: `${
Math.min(gridDataForGauge.gridWidth, gridDataForGauge.gridHeight) / 6
}`,
},
axisLine: {
lineStyle: {
width: `${
Math.min(gridDataForGauge.gridWidth, gridDataForGauge.gridHeight) /
6
}`,
},
},
title: {
fontSize: 10,
offsetCenter: [0, "70%"],
// width: upto chart width
width: `${gridDataForGauge.gridWidth}`,
overflow: "truncate",
},
// center of gauge
// x: left + width / 2,
// y: top + height / 2,
center: [
`${
parseFloat(options.grid[index].left) +
parseFloat(options.grid[index].width) / 2
}%`,
`${
parseFloat(options.grid[index].top) +
parseFloat(options.grid[index].height) / 2
}%`,
],
data: [
{
// gauge name may have or may not have
name: gaugeNames[index] ?? "",
value: it,
detail: {
formatter: function (value: any) {
try {
const unitValue = getUnitValue(
value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
);
return unitValue.value + unitValue.unit;
} catch (error) {
return value ?? "";
}
},
},
itemStyle: {
color:
getSeriesColor(
panelSchema?.config?.color,
gaugeNames[index] ?? "",
[it],
chartMin,
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null,
},
},
],
detail: {
valueAnimation: true,
offsetCenter: [0, 0],
fontSize: 12,
},
};
});
}
@@ -0,0 +1,200 @@
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { applyHBarChart } from "@/utils/dashboard/sql/charts/convertSQLHBarChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [{ name: "series1", data: [1, 2, 3] }]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120 }, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: { axisPointer: {}, textStyle: {} },
grid: {},
},
panelSchema: {
type: "h-bar",
config: { unit: "default", unit_custom: "", decimals: 2, axis_label_truncate_width: 120, trellis: { layout: null } },
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
chartPanelStyle: {},
hoveredSeriesState: { value: null },
annotations: [],
metadata: {},
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
breakDownKeys: [],
dynamicXAxisNameGap: 25,
hasTimestampField: false,
isHorizontalChart: true,
defaultSeriesProps: { type: "bar", emphasis: { focus: "series" } },
chartMin: 0,
chartMax: 100,
getAxisDataFromKey,
getSeries,
updateTrellisConfig,
...overrides,
};
}
describe("applyHBarChart", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls getSeries with barMinHeight: 1", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ barMinHeight: 1 });
});
it("sets series from getSeries result", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
expect(ctx.options.series).toEqual([{ name: "series1", data: [1, 2, 3] }]);
});
it("swaps xAxis and yAxis", () => {
const ctx = makeMockContext();
// Capture originals before calling
const originalXAxis = ctx.options.xAxis;
const originalYAxis = ctx.options.yAxis;
applyHBarChart(ctx);
// After swap: options.xAxis should be what was yAxis, options.yAxis should be what was xAxis
expect(ctx.options.xAxis).toBe(originalYAxis);
expect(ctx.options.yAxis).toBe(originalXAxis);
});
it("sets yAxis axisLabel.overflow to truncate", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
// After swap, yAxis is the original xAxis
ctx.options.yAxis.forEach((axis: any) => {
expect(axis.axisLabel.overflow).toBe("truncate");
});
});
it("sets nameGap on yAxis elements", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
// After swap, yAxis is original xAxis; nameGap = Math.min(Math.max(xMax, bMax), width) + 10
// calculateWidthText returns 60, so xAxisMaxLabel = 60 + 16 = 76, breakDownMaxLabel = 60 + 16 = 76
// Math.max(76, 76) = 76; Math.min(76, 120) = 76; 76 + 10 = 86
ctx.options.yAxis.forEach((axis: any) => {
expect(typeof axis.nameGap).toBe("number");
expect(axis.nameGap).toBe(86);
});
});
it("sets xAxis.name from first y field label", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
// After swap, options.xAxis is what was options.yAxis (an array).
// The source sets options.xAxis.name as a property on the array object.
expect((ctx.options.xAxis as any).name).toBe("Value");
});
it("sets xAxis.nameGap to dynamicXAxisNameGap", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
// options.xAxis.nameGap is set as a property on the array object after the swap
expect((ctx.options.xAxis as any).nameGap).toBe(25);
});
it("calls updateTrellisConfig when trellis layout is set and breakdown keys exist", () => {
const ctx = makeMockContext();
// Apply trellis layout and breakdown key overrides
ctx.breakDownKeys = ["breakdown"];
ctx.panelSchema = {
...ctx.panelSchema,
config: { ...ctx.panelSchema.config, trellis: { layout: "vertical" } },
};
applyHBarChart(ctx);
expect(ctx.updateTrellisConfig).toHaveBeenCalledTimes(1);
});
it("does NOT call updateTrellisConfig when no trellis layout", () => {
const ctx = makeMockContext();
// trellis.layout is null by default
applyHBarChart(ctx);
expect(ctx.updateTrellisConfig).not.toHaveBeenCalled();
});
it("does NOT call updateTrellisConfig when trellis layout set but no breakdown keys", () => {
const ctx = makeMockContext();
ctx.panelSchema = {
...ctx.panelSchema,
config: { ...ctx.panelSchema.config, trellis: { layout: "vertical" } },
};
// breakDownKeys is [] by default
applyHBarChart(ctx);
expect(ctx.updateTrellisConfig).not.toHaveBeenCalled();
});
it("sets xAxis.name to empty string when no y fields", () => {
const ctx = makeMockContext();
ctx.panelSchema = {
...ctx.panelSchema,
queries: [{ fields: { y: [], breakdown: [] }, customQuery: false }],
};
applyHBarChart(ctx);
expect((ctx.options.xAxis as any).name).toBe("");
});
it("getAxisDataFromKey is called for each xAxisKey when no trellis layout", () => {
const ctx = makeMockContext();
applyHBarChart(ctx);
// Should be called with xAxisKeys[0] = "x1" during xAxisMaxLabel calculation
expect(ctx.getAxisDataFromKey).toHaveBeenCalledWith("x1");
});
});
@@ -0,0 +1,85 @@
// 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 { calculateWidthText } from "../../chartDimensionUtils";
import { largestLabel } from "../shared/contextBuilder";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: h-bar
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyHBarChart(ctx: SQLContext): void {
const {
options,
panelSchema,
xAxisKeys,
breakDownKeys,
dynamicXAxisNameGap,
getSeries,
getAxisDataFromKey,
updateTrellisConfig,
} = ctx;
//generate trace based on the y axis keys
options.series = getSeries({ barMinHeight: 1 });
if (panelSchema.config.trellis?.layout && breakDownKeys.length) {
updateTrellisConfig();
}
//swap x and y axis
const temp = options.xAxis;
options.xAxis = options.yAxis;
options.yAxis = temp;
// xAxisKeys will be 1
if (!panelSchema.config.trellis?.layout) {
const xAxisMaxLabel =
calculateWidthText(
xAxisKeys.reduce(
(str: any, it: any) => largestLabel(getAxisDataFromKey(it)),
"",
),
) + 16;
// breakDownKeys will be 0 or 1
const breakDownMaxLabel =
calculateWidthText(
breakDownKeys.reduce(
(str: any, it: any) => largestLabel(getAxisDataFromKey(it)),
"",
),
) + 16;
options.yAxis.forEach((it: any) => {
it.axisLabel.overflow = "truncate";
it.nameGap =
Math.min(
Math.max(xAxisMaxLabel, breakDownMaxLabel),
it.axisLabel.width,
) + 10;
});
options.xAxis.name =
panelSchema.queries[0]?.fields?.y?.length >= 1
? panelSchema.queries[0]?.fields?.y[0]?.label
: "";
// For h-bar, xAxis is the bottom axis after swap
// Apply dynamic nameGap calculation if rotation is configured
options.xAxis.nameGap = dynamicXAxisNameGap;
}
}
@@ -0,0 +1,204 @@
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { applyHStackedChart } from "@/utils/dashboard/sql/charts/convertSQLHStackedChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [{ name: "series1", data: [1, 2, 3] }]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120 }, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: { axisPointer: {}, textStyle: {} },
grid: {},
},
panelSchema: {
type: "h-bar",
config: { unit: "default", unit_custom: "", decimals: 2, axis_label_truncate_width: 120, trellis: { layout: null } },
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
chartPanelStyle: {},
hoveredSeriesState: { value: null },
annotations: [],
metadata: {},
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
breakDownKeys: [],
dynamicXAxisNameGap: 25,
hasTimestampField: false,
isHorizontalChart: true,
defaultSeriesProps: { type: "bar", emphasis: { focus: "series" } },
chartMin: 0,
chartMax: 100,
getAxisDataFromKey,
getSeries,
updateTrellisConfig,
...overrides,
};
}
describe("applyHStackedChart", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls getSeries with barMinHeight: 1", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ barMinHeight: 1 });
});
it("sets series from getSeries result", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
expect(ctx.options.series).toEqual([{ name: "series1", data: [1, 2, 3] }]);
});
it("sets xAxis[0].data from getAxisDataFromKey before swap", () => {
// Before the swap, xAxis[0].data is set to the result of getAxisDataFromKey(xAxisKeys[0]).
// After the swap, this data ends up in yAxis[0].data.
const ctx = makeMockContext();
applyHStackedChart(ctx);
// After swap: yAxis is the original xAxis; yAxis[0].data should be ["Jan","Feb","Mar"]
expect(ctx.options.yAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("slices xAxis to one element before swap", () => {
// The function slices options.xAxis to length 1 before swapping.
// After swap, that sliced array becomes yAxis.
const ctx = makeMockContext();
// Start with two xAxis elements to verify slicing works
ctx.options.xAxis = [
{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120 }, name: "" },
{ data: ["X", "Y"], axisLabel: { rotate: 0, width: 80 }, name: "" },
];
applyHStackedChart(ctx);
// After swap, yAxis is what was the sliced xAxis (length 1)
expect(ctx.options.yAxis.length).toBe(1);
});
it("after swap, xAxis is what was yAxis", () => {
const ctx = makeMockContext();
// Capture original yAxis reference before calling
const originalYAxis = ctx.options.yAxis;
applyHStackedChart(ctx);
// After swap, options.xAxis should be the original yAxis object
expect(ctx.options.xAxis).toBe(originalYAxis);
});
it("after swap, xAxis[0].data is from original yAxis", () => {
const ctx = makeMockContext();
// Original yAxis[0].data is []
applyHStackedChart(ctx);
// After swap, options.xAxis is original yAxis array; xAxis[0].data should be []
expect(ctx.options.xAxis[0].data).toEqual([]);
});
it("after swap, yAxis[0].data is from getAxisDataFromKey", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
// After swap, options.yAxis is original xAxis (updated with getAxisDataFromKey result)
expect(ctx.options.yAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("sets nameGap on each yAxis element", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
// After swap, yAxis is original xAxis (with data ["Jan","Feb","Mar"]).
// maxYaxisWidth = max axisLabel.width over yAxis = 120
// largestLabel(["Jan","Feb","Mar"]) = "Jan", calculateWidthText("Jan") = 60
// nameGap = Math.min(60, 120) + 10 = 70
ctx.options.yAxis.forEach((axis: any) => {
expect(typeof axis.nameGap).toBe("number");
expect(axis.nameGap).toBe(70);
});
});
it("sets xAxis.nameGap to dynamicXAxisNameGap", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
// options.xAxis.nameGap is set as a property on the array object after the swap
expect((ctx.options.xAxis as any).nameGap).toBe(25);
});
it("calls getAxisDataFromKey with xAxisKeys[0]", () => {
const ctx = makeMockContext();
applyHStackedChart(ctx);
expect(ctx.getAxisDataFromKey).toHaveBeenCalledWith("x1");
});
it("getSeries called before swap occurs", () => {
// Verify getSeries is called (series is set on the pre-swap xAxis state).
// We verify getSeries is called exactly once with correct args.
const ctx = makeMockContext();
applyHStackedChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledTimes(1);
expect(ctx.getSeries).toHaveBeenCalledWith({ barMinHeight: 1 });
});
it("deduplicates xAxis data via Set", () => {
const ctx = makeMockContext();
// Return duplicate values from getAxisDataFromKey
ctx.getAxisDataFromKey = vi.fn(() => ["Jan", "Jan", "Feb", "Feb", "Mar"]);
applyHStackedChart(ctx);
// After swap, yAxis[0].data should have unique values
expect(ctx.options.yAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("handles empty xAxis data gracefully", () => {
const ctx = makeMockContext();
ctx.getAxisDataFromKey = vi.fn(() => []);
applyHStackedChart(ctx);
expect(ctx.options.yAxis[0].data).toEqual([]);
});
});
@@ -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 { calculateWidthText } from "../../chartDimensionUtils";
import { largestLabel } from "../shared/contextBuilder";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: h-stacked
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyHStackedChart(ctx: SQLContext): void {
const {
options,
xAxisKeys,
dynamicXAxisNameGap,
getSeries,
getAxisDataFromKey,
} = ctx;
options.xAxis[0].data = Array.from(new Set(getAxisDataFromKey(xAxisKeys[0])));
options.xAxis = options.xAxis.slice(0, 1);
// stacked with xAxis's second value
// allow 2 xAxis and 1 yAxis value for stack chart
// get second x axis key
options.series = getSeries({ barMinHeight: 1 });
const temp = options.xAxis;
options.xAxis = options.yAxis;
options.yAxis = temp;
const maxYaxisWidth = options.yAxis.reduce((acc: number, it: any) => {
return Math.max(acc, it.axisLabel.width || 0);
}, 0);
options.yAxis.map((it: any) => {
it.nameGap =
Math.min(calculateWidthText(largestLabel(it.data)), maxYaxisWidth) + 10;
});
// For h-stacked, xAxis is actually the original yAxis (bottom axis after swap)
// Apply dynamic nameGap calculation if rotation is configured
options.xAxis.nameGap = dynamicXAxisNameGap;
}
@@ -0,0 +1,147 @@
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { applyHeatmapChart } from "@/utils/dashboard/sql/charts/convertSQLHeatmapChart";
vi.mock("date-fns-tz", () => ({
toZonedTime: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
formatDate: vi.fn(() => "2024-01-15 10:00:00"),
isTimeSeries: vi.fn(() => false),
}));
vi.mock("@/utils/dashboard/aliasUtils", () => ({
getDataValue: vi.fn((obj: any, key: string) => obj?.[key]),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const rawData = [
{ x: "A", y: "cat1", z: 10 },
{ x: "B", y: "cat1", z: 20 },
{ x: "A", y: "cat2", z: 30 },
{ x: "B", y: "cat2", z: 40 },
];
return {
options: {
xAxis: [{ data: [] }],
yAxis: { data: [] },
series: [],
tooltip: {},
grid: {},
legend: { show: true },
},
panelSchema: {
type: "heatmap",
config: { unit: "default", unit_custom: "", decimals: 2 },
queries: [
{
customQuery: true,
fields: { x: [], y: [{ label: "Value" }] },
},
],
},
store: {
state: {
theme: "light",
zoConfig: { timestamp_column: "_timestamp" },
timezone: "UTC",
},
},
searchQueryData: [rawData],
xAxisKeys: ["x"],
yAxisKeys: ["y"],
zAxisKeys: ["z"],
hoveredSeriesState: { value: null },
defaultSeriesProps: { type: "heatmap" },
...overrides,
};
}
describe("applyHeatmapChart", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sets visualMap on options", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.visualMap).toBeDefined();
});
it("sets visualMap.min to 0", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.visualMap.min).toBe(0);
});
it("sets visualMap.max from data", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.visualMap.max).toBeGreaterThanOrEqual(40);
});
it("sets series array with one element", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.series.length).toBe(1);
});
it("series data is flattened matrix", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(Array.isArray(ctx.options.series[0].data)).toBe(true);
});
it("sets xAxis as category with unique x values", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.xAxis[0].type).toBe("category");
expect(ctx.options.xAxis[0].data).toContain("A");
expect(ctx.options.xAxis[0].data).toContain("B");
});
it("sets yAxis with unique y values", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.yAxis.data).toContain("cat1");
expect(ctx.options.yAxis.data).toContain("cat2");
});
it("hides legend", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.legend.show).toBe(false);
});
it("sets grid bottom to 60", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.grid.bottom).toBe(60);
});
it("tooltip position is top", () => {
const ctx = makeMockContext();
applyHeatmapChart(ctx);
expect(ctx.options.tooltip.position).toBe("top");
});
});
@@ -0,0 +1,228 @@
// 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 { toZonedTime } from "date-fns-tz";
import { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { formatDate, isTimeSeries } from "../../dateTimeUtils";
import { getDataValue } from "../../aliasUtils";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: heatmap
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyHeatmapChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
searchQueryData,
xAxisKeys,
yAxisKeys,
zAxisKeys,
hoveredSeriesState,
defaultSeriesProps,
} = ctx;
// get first x axis key
const key0 = xAxisKeys[0];
// get the unique value of the first xAxis's key
let xAxisZerothPositionUniqueValue = [
...new Set(searchQueryData[0].map((obj: any) => getDataValue(obj, key0))),
].filter((it) => it);
// get second x axis key
const key1 = yAxisKeys[0];
// get the unique value of the second xAxis's key
const xAxisFirstPositionUniqueValue = [
...new Set(searchQueryData[0].map((obj: any) => getDataValue(obj, key1))),
].filter((it) => it);
const yAxisKey0 = zAxisKeys[0];
// Build lookup map ONE TIME - O(n) instead of nested O(n┬▓)
const heatmapLookupMap = new Map<string, any>();
searchQueryData[0].forEach((row: any) => {
const yAxisValue = getDataValue(row, key1);
const xAxisValue = getDataValue(row, key0);
const compositeKey = `${yAxisValue}||${xAxisValue}`;
// Only set if NOT already present (keeps FIRST occurrence, matching original nested .find() behavior)
if (!heatmapLookupMap.has(compositeKey)) {
heatmapLookupMap.set(compositeKey, row);
}
});
// Use map for instant O(1) lookups
const zValues: any = xAxisFirstPositionUniqueValue.map((yAxisValue: any) => {
return xAxisZerothPositionUniqueValue.map((xAxisValue: any) => {
const compositeKey = `${yAxisValue}||${xAxisValue}`;
const dataRow = heatmapLookupMap.get(compositeKey);
return dataRow ? getDataValue(dataRow, yAxisKey0) || "-" : "-";
});
});
((options.visualMap = {
min: 0,
max: zValues.reduce(
(a: any, b: any) =>
Math.max(
a,
b.reduce((c: any, d: any) => Math.max(c, +d || 0), 0),
),
0,
),
calculable: true,
orient: "horizontal",
left: "center",
}),
(options.series = [
{
...defaultSeriesProps,
name: panelSchema?.queries[0]?.fields?.y[0].label,
data: zValues
.map((it: any, index: any) => {
return xAxisZerothPositionUniqueValue.map((i: any, j: number) => {
return [j, index, it[j]];
});
})
.flat(),
label: {
show: true,
fontSize: 12,
formatter: (params: any) => {
try {
return (
formatUnitValue(
getUnitValue(
params?.value?.[2],
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
) || params?.value?.[2]
);
} catch (error) {
return params?.value?.[2]?.toString() ?? "";
}
},
},
},
]));
// option.yAxis.data=xAxisFirstPositionUniqueValue;
((options.tooltip = {
position: "top",
textStyle: {
color: store.state.theme === "dark" ? "#fff" : "#000",
fontSize: 12,
},
backgroundColor:
store.state.theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)",
formatter: (params: any) => {
try {
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
// we have value[1] which return yaxis index
// it is used to get y axis data
return `${
options?.yAxis?.data[params?.value[1]] || params?.seriesName
} <br/> ${params?.marker} ${params?.name} : ${
formatUnitValue(
getUnitValue(
params?.value?.[2],
panelSchema?.config?.unit,
panelSchema?.config?.unit_custom,
panelSchema?.config?.decimals,
),
) || params?.value?.[2]
}`;
} catch (error) {
return "";
}
},
}),
(options.tooltip.axisPointer = {
type: "cross",
label: {
fontsize: 12,
precision: panelSchema.config?.decimals,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
},
}));
// if auto sql
if (panelSchema?.queries[0]?.customQuery == false) {
// check if x axis has histogram or not
// for heatmap we only have one field in x axis event we have used find fn
const field = panelSchema.queries[0].fields?.x.find(
(it: any) =>
it.functionName == "histogram" &&
it?.args?.[0]?.value?.field == store.state?.zoConfig?.timestamp_column,
);
// if histogram
if (field) {
// convert time string to selected timezone
xAxisZerothPositionUniqueValue = xAxisZerothPositionUniqueValue.map(
(it: any) => {
return formatDate(toZonedTime(it + "Z", store.state.timezone));
},
);
}
// else custom sql
} else {
// sampling data to know whether data is timeseries or not
const sample = xAxisZerothPositionUniqueValue.slice(
0,
Math.min(20, xAxisZerothPositionUniqueValue.length),
);
// if timeseries
if (isTimeSeries(sample)) {
// convert time string to selected timezone
xAxisZerothPositionUniqueValue = xAxisZerothPositionUniqueValue.map(
(it: any) => {
return formatDate(toZonedTime(it + "Z", store.state.timezone));
},
);
}
}
options.grid.bottom = 60;
((options.xAxis = [
{
type: "category",
data: xAxisZerothPositionUniqueValue,
splitArea: {
show: true,
},
},
]),
[
(options.yAxis = {
type: "category",
data: xAxisFirstPositionUniqueValue,
splitArea: {
show: true,
},
}),
]);
options.legend.show = false;
}
@@ -0,0 +1,446 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyLineAreaScatterBarChart } from "@/utils/dashboard/sql/charts/convertSQLLineAreaChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [{ name: "series1", data: [1, 2, 3] }]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120, margin: 5 }, axisTick: {}, nameGap: 25, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: { axisPointer: {}, textStyle: {} },
grid: {},
},
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
axis_label_rotate: 0,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
hoveredSeriesState: { value: null },
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
breakDownKeys: [],
dynamicXAxisNameGap: 25,
hasTimestampField: false,
defaultSeriesProps: { type: "bar" },
chartMin: 0,
chartMax: 100,
getAxisDataFromKey,
getSeries,
updateTrellisConfig,
...overrides,
};
}
describe("applyLineAreaScatterBarChart - area-stacked (Branch A)", () => {
it("area-stacked: slices xAxis to one element", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
ctx.options.xAxis.push({ data: ["extra"] });
applyLineAreaScatterBarChart(ctx);
expect(ctx.options.xAxis).toHaveLength(1);
});
it("area-stacked: sets tooltip.axisPointer.label", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.options.tooltip.axisPointer.label).toBeDefined();
expect(ctx.options.tooltip.axisPointer.label.show).toBe(true);
});
it("area-stacked: sets xAxis[0].data from getAxisDataFromKey", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getAxisDataFromKey).toHaveBeenCalledWith("x1");
expect(ctx.options.xAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("area-stacked: calls getSeries", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalled();
});
it("does not call updateTrellisConfig for area-stacked", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: "grid" },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [{ label: "Cat" }] }, customQuery: false }],
},
breakDownKeys: ["breakdown"],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.updateTrellisConfig).not.toHaveBeenCalled();
});
});
describe("applyLineAreaScatterBarChart - line with breakdown (Branch A)", () => {
it("line with breakdown: slices xAxis to one element", () => {
const ctx = makeMockContext({
panelSchema: {
type: "line",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [{ label: "Cat" }] }, customQuery: false }],
},
breakDownKeys: ["breakdown"],
});
ctx.options.xAxis.push({ data: ["extra"] });
applyLineAreaScatterBarChart(ctx);
expect(ctx.options.xAxis).toHaveLength(1);
});
it("line with breakdown: sets xAxis[0].nameGap to dynamicXAxisNameGap", () => {
const ctx = makeMockContext({
dynamicXAxisNameGap: 42,
panelSchema: {
type: "line",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [{ label: "Cat" }] }, customQuery: false }],
},
breakDownKeys: ["breakdown"],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.options.xAxis[0].nameGap).toBe(42);
});
});
describe("applyLineAreaScatterBarChart - bar without breakdown", () => {
it("bar: calls getSeries with barMinHeight: 1", () => {
const ctx = makeMockContext({
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ barMinHeight: 1 });
});
});
describe("applyLineAreaScatterBarChart - scatter without breakdown (Branch B)", () => {
it("scatter without breakdown: sets tooltip.formatter function", () => {
const ctx = makeMockContext({
panelSchema: {
type: "scatter",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
expect(typeof ctx.options.tooltip.formatter).toBe("function");
});
it("scatter: calls getSeries with empty options", () => {
const ctx = makeMockContext({
panelSchema: {
type: "scatter",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({});
});
});
describe("applyLineAreaScatterBarChart - line without breakdown", () => {
it("line without breakdown: does not throw", () => {
const ctx = makeMockContext({
panelSchema: {
type: "line",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
expect(() => applyLineAreaScatterBarChart(ctx)).not.toThrow();
});
it("line without breakdown: calls getSeries with opacity 0.8", () => {
const ctx = makeMockContext({
panelSchema: {
type: "line",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ opacity: 0.8 });
});
});
describe("applyLineAreaScatterBarChart - trellis behavior", () => {
it("calls updateTrellisConfig for line with trellis layout and breakdown", () => {
const ctx = makeMockContext({
panelSchema: {
type: "line",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: "grid" },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [{ label: "Cat" }] }, customQuery: false }],
},
breakDownKeys: ["breakdown"],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.updateTrellisConfig).toHaveBeenCalled();
});
});
describe("applyLineAreaScatterBarChart - additional coverage", () => {
it("area without breakdown: calls getSeries with opacity 0.8", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ opacity: 0.8 });
});
it("tooltip.axisPointer.label formatter returns formatted value for y dimension", () => {
const ctx = makeMockContext({
panelSchema: {
type: "area-stacked",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
applyLineAreaScatterBarChart(ctx);
const formatter = ctx.options.tooltip.axisPointer.label.formatter;
const result = formatter({ axisDimension: "y", value: 100 });
// formatUnitValue mock returns String(v ?? "")
expect(typeof result).toBe("string");
});
it("scatter tooltip formatter returns empty string for empty name array", () => {
const ctx = makeMockContext({
panelSchema: {
type: "scatter",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_rotate: 0,
axis_label_truncate_width: 120,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
breakDownKeys: [],
});
applyLineAreaScatterBarChart(ctx);
const formatter = ctx.options.tooltip.formatter;
const result = formatter([]);
expect(result).toBe("");
});
});
@@ -0,0 +1,183 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for:
* - line, area, area-stacked, scatter, bar
*
* Mutates `ctx.options` in place, exactly as the original switch cases did.
*/
export function applyLineAreaScatterBarChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
xAxisKeys,
breakDownKeys,
dynamicXAxisNameGap,
hasTimestampField,
getSeries,
getAxisDataFromKey,
updateTrellisConfig,
} = ctx;
//if area stacked then continue
//or if area or line or scatter, then check x axis length
if (
panelSchema.type == "area-stacked" ||
((panelSchema.type == "line" ||
panelSchema.type == "area" ||
panelSchema.type == "scatter" ||
panelSchema.type == "bar") &&
panelSchema.queries[0].fields.breakdown?.length)
) {
options.xAxis = options.xAxis.slice(0, 1);
options.tooltip.axisPointer.label = {
show: true,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
label: {
fontsize: 12,
precision: panelSchema.config?.decimals,
},
formatter: function (params: any) {
try {
if (params?.axisDimension == "y")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
return params?.value?.toString();
} catch (error) {
return params?.value?.toString() ?? "";
}
},
};
const xAxisLabelRotation = hasTimestampField
? 0
: panelSchema.config?.axis_label_rotate || 0;
options.xAxis[0].axisLabel = {
rotate: xAxisLabelRotation,
};
options.xAxis[0].axisTick = {};
options.xAxis[0].nameGap = dynamicXAxisNameGap;
// get the unique value of the first xAxis's key
options.xAxis[0].data = Array.from(
new Set(getAxisDataFromKey(xAxisKeys[0])),
);
} else if (
panelSchema.type !== "line" &&
panelSchema.type !== "area" &&
panelSchema.type !== "bar"
) {
options.tooltip.formatter = function (name: any) {
try {
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
ctx.hoveredSeriesState?.value &&
panelSchema.id &&
ctx.hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
if (name?.length == 0) return "";
// sort tooltip array based on value
name?.sort((a: any, b: any) => {
return (b.value ?? 0) - (a.value ?? 0);
});
// if hovered series name is not null then move it to first position
if (ctx.hoveredSeriesState?.value?.hoveredSeriesName) {
// get the current series index from name
const currentSeriesIndex = name?.findIndex(
(it: any) =>
it.seriesName == ctx.hoveredSeriesState?.value?.hoveredSeriesName,
);
// if hovered series index is not -1 then take it to very first position
if (currentSeriesIndex != -1) {
// shift all series to next position and place current series at first position
const temp = name?.[currentSeriesIndex];
for (let i = currentSeriesIndex; i > 0; i--) {
name[i] = name?.[i - 1];
}
name[0] = temp;
}
}
const hoverText: string[] = [];
name?.forEach((it: any) => {
if (it.data != null) {
// check if the series is the current series being hovered
// if have than bold it
if (
it?.seriesName == ctx.hoveredSeriesState?.value?.hoveredSeriesName
)
hoverText.push(
`<strong>${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.data,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)} </strong>`,
);
// else normal text
else
hoverText.push(
`${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.data,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}`,
);
}
});
return `${name?.[0]?.name} <br/> ${hoverText.join("<br/>")}`;
} catch (error) {
return "";
}
};
}
options.series = getSeries(
panelSchema.type == "line" || panelSchema.type == "area"
? { opacity: 0.8 }
: panelSchema.type == "bar"
? { barMinHeight: 1 }
: {},
);
if (
(panelSchema.type === "line" ||
panelSchema.type == "area" ||
panelSchema.type == "scatter" ||
panelSchema.type == "bar") &&
panelSchema.config.trellis?.layout &&
breakDownKeys.length
)
updateTrellisConfig();
}
@@ -0,0 +1,205 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyMetricChart } from "@/utils/dashboard/sql/charts/convertSQLMetricChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [{ name: "series1", data: [1, 2, 3] }]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120, margin: 5 }, axisTick: {}, nameGap: 25, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: { axisPointer: {}, textStyle: {} },
grid: {},
},
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
axis_label_rotate: 0,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
hoveredSeriesState: { value: null },
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
breakDownKeys: [],
dynamicXAxisNameGap: 25,
hasTimestampField: false,
defaultSeriesProps: { type: "bar" },
chartMin: 0,
chartMax: 100,
getAxisDataFromKey,
getSeries,
updateTrellisConfig,
...overrides,
};
}
describe("applyMetricChart", () => {
it("sets xAxis to empty array", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.xAxis).toEqual([]);
});
it("sets yAxis to empty array", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.yAxis).toEqual([]);
});
it("sets tooltip.show to false", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.tooltip.show).toBe(false);
});
it("sets dataset to { source: [[]] }", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.dataset).toEqual({ source: [[]] });
});
it("sets polar to {}", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.polar).toEqual({});
});
it("sets angleAxis.show to false", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.angleAxis).toBeDefined();
expect(ctx.options.angleAxis.show).toBe(false);
});
it("sets radiusAxis.show to false", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.radiusAxis).toBeDefined();
expect(ctx.options.radiusAxis.show).toBe(false);
});
it("sets series array with one element", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(Array.isArray(ctx.options.series)).toBe(true);
expect(ctx.options.series).toHaveLength(1);
});
it("series[0] has a renderItem function", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(typeof ctx.options.series[0].renderItem).toBe("function");
});
it("series[0] includes defaultSeriesProps", () => {
const ctx = makeMockContext({ defaultSeriesProps: { type: "custom", color: "red" } });
applyMetricChart(ctx);
expect(ctx.options.series[0].type).toBe("custom");
expect(ctx.options.series[0].color).toBe("red");
});
it("sets backgroundColor from panelSchema.config.background.value.color", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
expect(ctx.options.backgroundColor).toBe("#FFFFFF");
});
it("sets backgroundColor to empty string when background config missing", () => {
const ctx = makeMockContext({
panelSchema: {
type: "metric",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
axis_label_rotate: 0,
trellis: { layout: null },
// background intentionally omitted
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
applyMetricChart(ctx);
expect(ctx.options.backgroundColor).toBe("");
});
it("renderItem returns a text object with type 'text'", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
const renderItem = ctx.options.series[0].renderItem;
const params = { coordSys: { cx: 100, cy: 100 } };
const result = renderItem(params, null);
expect(result).toBeDefined();
expect(result.type).toBe("text");
});
it("renderItem result style has text property set to formatted unit value", () => {
const ctx = makeMockContext();
applyMetricChart(ctx);
const renderItem = ctx.options.series[0].renderItem;
const params = { coordSys: { cx: 200, cy: 150 } };
const result = renderItem(params, null);
// getUnitValue mock returns the value as-is; formatUnitValue mock converts to string
// yAxisValue[0] is 100 from mock
expect(result.style.text).toBe("100");
});
});
@@ -0,0 +1,88 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { calculateOptimalFontSize } from "../../chartDimensionUtils";
import { getContrastColor } from "../../chartColorUtils";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: metric
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyMetricChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
yAxisKeys,
defaultSeriesProps,
getAxisDataFromKey,
} = ctx;
const key1 = yAxisKeys[0];
const yAxisValue = getAxisDataFromKey(key1);
const unitValue = getUnitValue(
yAxisValue.length > 0 ? yAxisValue[0] : 0,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
);
options.backgroundColor = panelSchema.config?.background?.value?.color ?? "";
options.dataset = { source: [[]] };
options.tooltip = {
show: false,
};
options.angleAxis = {
show: false,
};
options.radiusAxis = {
show: false,
};
options.polar = {};
options.xAxis = [];
options.yAxis = [];
options.series = [
{
...defaultSeriesProps,
renderItem: function (params: any) {
try {
const backgroundColor = panelSchema.config?.background?.value?.color;
const isDarkTheme = store.state.theme === "dark";
return {
type: "text",
style: {
text: formatUnitValue(unitValue),
fontSize: calculateOptimalFontSize(
formatUnitValue(unitValue),
params?.coordSys?.cx * 2,
), //coordSys is relative. so that we can use it to calculate the dynamic size
fontWeight: 500,
align: "center",
verticalAlign: "middle",
x: params?.coordSys?.cx,
y: params?.coordSys?.cy,
fill: getContrastColor(backgroundColor, isDarkTheme),
},
};
} catch (error) {
return "";
}
},
},
];
}
@@ -0,0 +1,172 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyPieDonutChart } from "@/utils/dashboard/sql/charts/convertSQLPieDonutChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ chartWidth: 800, chartHeight: 400 })),
applyPieDonutChartAlignment: vi.fn(),
applyPieDonutCenterAdjustment: vi.fn(),
calculatePieChartContainer: vi.fn(() => ({ left: "15%", right: "15%", top: "15%", bottom: "15%" })),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"] }],
yAxis: [{ data: [] }],
series: [],
tooltip: {},
grid: {},
},
panelSchema: {
type: "pie",
config: { unit: "default", unit_custom: "", decimals: 2, color: { mode: "fixed" } },
queries: [{ fields: { y: [{ label: "Value" }] } }],
},
store: {
state: { theme: "light" },
},
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
hoveredSeriesState: { value: null },
yAxisKeys: ["y1"],
chartMin: 0,
chartMax: 100,
defaultSeriesProps: { type: "pie" },
getAxisDataFromKey: vi.fn((key: string) => {
if (key === "y1") return [100, 200, 300];
return [];
}),
getPieChartRadius: vi.fn(() => 50),
...overrides,
};
}
describe("applyPieDonutChart - pie type", () => {
it("pie: sets series array", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
expect(Array.isArray(ctx.options.series)).toBe(true);
expect(ctx.options.series.length).toBeGreaterThan(0);
});
it("pie: clears xAxis to empty array", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
expect(ctx.options.xAxis).toEqual([]);
});
it("pie: clears yAxis to empty array", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
expect(ctx.options.yAxis).toEqual([]);
});
it("pie: series data maps values with xAxis labels as names", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
// getAxisDataFromKey("y1") => [100, 200, 300]; xAxis[0].data => ["Jan","Feb","Mar"]
expect(ctx.options.series[0].data[0].value).toBe(100);
expect(ctx.options.series[0].data[0].name).toBe("Jan");
});
it("pie: sets grid with containLabel", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
expect(ctx.options.grid.containLabel).toBe(true);
});
it("pie: tooltip trigger is item", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
expect(ctx.options.tooltip.trigger).toBe("item");
});
it("pie: sets radius on first series", () => {
const ctx = makeMockContext();
applyPieDonutChart(ctx);
// getPieChartRadius returns 50, so radius should be "50%"
expect(ctx.options.series[0].radius).toBeDefined();
expect(ctx.options.series[0].radius).toBe("50%");
});
});
describe("applyPieDonutChart - donut type", () => {
it("donut: sets series array", () => {
const ctx = makeMockContext({
panelSchema: {
type: "donut",
config: { unit: "default", unit_custom: "", decimals: 2, color: { mode: "fixed" } },
queries: [{ fields: { y: [{ label: "Value" }] } }],
},
});
applyPieDonutChart(ctx);
expect(Array.isArray(ctx.options.series)).toBe(true);
expect(ctx.options.series.length).toBeGreaterThan(0);
});
it("donut: clears xAxis", () => {
const ctx = makeMockContext({
panelSchema: {
type: "donut",
config: { unit: "default", unit_custom: "", decimals: 2, color: { mode: "fixed" } },
queries: [{ fields: { y: [{ label: "Value" }] } }],
},
});
applyPieDonutChart(ctx);
expect(ctx.options.xAxis).toEqual([]);
});
it("donut: clears yAxis", () => {
const ctx = makeMockContext({
panelSchema: {
type: "donut",
config: { unit: "default", unit_custom: "", decimals: 2, color: { mode: "fixed" } },
queries: [{ fields: { y: [{ label: "Value" }] } }],
},
});
applyPieDonutChart(ctx);
expect(ctx.options.yAxis).toEqual([]);
});
it("donut: sets radius as array [inner, outer]", () => {
const ctx = makeMockContext({
panelSchema: {
type: "donut",
config: { unit: "default", unit_custom: "", decimals: 2, color: { mode: "fixed" } },
queries: [{ fields: { y: [{ label: "Value" }] } }],
},
});
applyPieDonutChart(ctx);
// getPieChartRadius returns 50 (outerRadius), so radius should be array ["20%", "50%"]
// innterRadius = Math.max(50 - 30, 20) = 20
const radius = ctx.options.series[0].radius;
expect(Array.isArray(radius)).toBe(true);
expect(radius).toHaveLength(2);
// Both entries should be strings ending in "%"
expect(typeof radius[0]).toBe("string");
expect(typeof radius[1]).toBe("string");
});
});
@@ -0,0 +1,295 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { getSeriesColor } from "../../colorPalette";
import {
getChartDimensions,
applyPieDonutChartAlignment,
applyPieDonutCenterAdjustment,
calculatePieChartContainer,
} from "../../legendConfiguration";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: pie AND donut
*
* Mutates `ctx.options` in place, exactly as the original switch cases did.
*/
export function applyPieDonutChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
yAxisKeys,
chartMin,
chartMax,
chartPanelRef,
hoveredSeriesState,
defaultSeriesProps,
getAxisDataFromKey,
getPieChartRadius,
} = ctx;
if (panelSchema.type === "pie") {
// Add more padding for pie chart
options.grid = {
containLabel: true,
left: "15%",
right: "15%",
top: "15%",
bottom: "15%",
};
options.tooltip = {
trigger: "item",
textStyle: {
color: store.state.theme === "dark" ? "#fff" : "#000",
fontSize: 12,
},
backgroundColor:
store.state.theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)",
formatter: function (name: any) {
try {
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
return `${name?.marker} ${name?.name} : <b>${formatUnitValue(
getUnitValue(
name?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}</b>`;
} catch (error) {
return "";
}
},
};
//generate trace based on the y axis keys
options.series = yAxisKeys?.map((key: any) => {
const seriesObj = {
...defaultSeriesProps,
data: getAxisDataFromKey(key).map((it: any, i: number) => {
return { value: it, name: options?.xAxis[0]?.data[i] };
}),
itemStyle: {
color: ["palette-classic"].includes(panelSchema.config?.color?.mode)
? null
: (params: any) => {
return (
getSeriesColor(
panelSchema?.config?.color,
params.name,
[params.value],
chartMin,
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null
);
},
},
label: {
show: true,
formatter: "{d}%", // {b} represents name, {c} represents value {d} represents percent
position: "inside", // You can adjust the position of the labels
fontSize: 10,
},
percentPrecision: panelSchema.config?.decimals ?? 2,
};
return seriesObj;
});
if (options.series.length > 0) {
// Get current chart dimensions
const { chartWidth, chartHeight } = getChartDimensions(chartPanelRef);
// Calculate responsive radius that accounts for dynamic resizing
const pieRadius = getPieChartRadius(options.series[0].data);
options.series[0].radius = `${pieRadius}%`;
// Apply chart alignment - only when legend position is right and chart_align is explicitly set
const shouldApplyAlignment =
panelSchema.config?.show_legends &&
panelSchema.config?.legends_position === "right" &&
(panelSchema.config?.legends_type === "plain" ||
panelSchema.config?.legends_type === "scroll" ||
panelSchema.config?.legends_type === null) &&
panelSchema.config?.chart_align; // Only apply when chart_align is explicitly set
if (shouldApplyAlignment) {
// Apply chart alignment based on container properties
const containerProps = calculatePieChartContainer(
panelSchema,
chartWidth,
chartHeight,
options.series[0].data || [],
);
// Apply center positioning based on alignment requirements
if (containerProps.shouldUseGridAlignment) {
// Calculate center position for alignment within ONLY the chart area (excluding legend)
const chartAlign = panelSchema.config?.chart_align;
const chartAreaWidth = containerProps.availableWidth;
let centerX = 50; // Default center
let centerY = 50; // Default center
// For right legends, adjust horizontal positioning
const chartAreaRadius = Math.min(chartAreaWidth, chartHeight) * 0.4; // 40% of smaller dimension
const radiusAsPercentOfTotal = (chartAreaRadius / chartWidth) * 100;
const minSafeXInChartArea = radiusAsPercentOfTotal + 2; // 2% padding
switch (chartAlign) {
case "left": {
// Position chart to the left within ONLY the chart area
const leftPositionInChartArea = chartAreaWidth * 0.25; // 25% into chart area
centerX = Math.max(
minSafeXInChartArea,
(leftPositionInChartArea / chartWidth) * 100,
);
break;
}
case "center":
default: {
// Center within ONLY the chart area
const chartAreaCenter = chartAreaWidth / 2;
centerX = (chartAreaCenter / chartWidth) * 100;
break;
}
}
const finalCenter = [`${centerX}%`, `${centerY}%`];
options.series[0].center = finalCenter;
} else {
// Fallback to default center when grid alignment is not used
options.series[0].center = ["50%", "50%"];
}
} else {
applyPieDonutCenterAdjustment(
panelSchema,
options,
chartWidth,
chartHeight,
);
}
}
options.xAxis = [];
options.yAxis = [];
} else {
// donut
// Add more padding for donut chart
options.grid = {
containLabel: true,
left: "15%",
right: "15%",
top: "15%",
bottom: "15%",
};
options.tooltip = {
trigger: "item",
textStyle: {
color: store.state.theme === "dark" ? "#fff" : "#000",
fontSize: 12,
},
backgroundColor:
store.state.theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)",
formatter: function (name: any) {
try {
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
return `${name?.marker} ${name?.name} : <b>${formatUnitValue(
getUnitValue(
name?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}<b/>`;
} catch (error) {
return "";
}
},
};
//generate trace based on the y axis keys
options.series = yAxisKeys?.map((key: any) => {
const seriesObj = {
...defaultSeriesProps,
data: getAxisDataFromKey(key).map((it: any, i: number) => {
return { value: it, name: options?.xAxis[0]?.data[i] };
}),
itemStyle: {
color: ["palette-classic"].includes(panelSchema.config?.color?.mode)
? null
: (params: any) => {
return (
getSeriesColor(
panelSchema?.config?.color,
params.name,
[params.value],
chartMin,
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null
);
},
},
label: {
show: true,
formatter: "{d}%", // {b} represents name, {c} represents value {d} represents percent
position: "inside", // You can adjust the position of the labels
fontSize: 10,
},
percentPrecision: panelSchema.config?.decimals ?? 2,
};
return seriesObj;
});
if (options.series.length > 0) {
// Get current chart dimensions
const { chartWidth, chartHeight } = getChartDimensions(chartPanelRef);
const outerRadius: number = getPieChartRadius(options.series[0].data);
// Adjust inner radius based on outer radius size for better proportions
const thickness = outerRadius > 70 ? 35 : 30;
const innterRadius = Math.max(outerRadius - thickness, 20); // Ensure minimum inner radius
options.series[0].radius = [`${innterRadius}%`, `${outerRadius}%`];
// Apply chart alignment and center positioning using centralized function
applyPieDonutChartAlignment(
panelSchema,
options,
chartWidth,
chartHeight,
);
}
options.xAxis = [];
options.yAxis = [];
}
}
@@ -0,0 +1,213 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { applyStackedChart } from "@/utils/dashboard/sql/charts/convertSQLStackedChart";
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
calculateOptimalFontSize: vi.fn(() => 24),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
getContrastColor: vi.fn(() => "#FFFFFF"),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
}));
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
largestLabel: vi.fn((data) => data?.[0] ?? ""),
}));
function makeMockContext(overrides: Partial<any> = {}): any {
const getAxisDataFromKey = vi.fn((key: string) => {
const map: any = {
y1: [100, 200, 300],
x1: ["Jan", "Feb", "Mar"],
breakdown: ["A", "B"],
};
return map[key] ?? [];
});
const getSeries = vi.fn(() => [{ name: "series1", data: [1, 2, 3] }]);
const updateTrellisConfig = vi.fn();
return {
options: {
xAxis: [{ data: ["Jan", "Feb", "Mar"], axisLabel: { rotate: 0, width: 120, margin: 5 }, axisTick: {}, nameGap: 25, name: "" }],
yAxis: [{ data: [], axisLabel: { width: 80 }, name: "" }],
series: [],
tooltip: { axisPointer: {}, textStyle: {} },
grid: {},
},
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
axis_label_rotate: 0,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
hoveredSeriesState: { value: null },
xAxisKeys: ["x1"],
yAxisKeys: ["y1"],
breakDownKeys: [],
dynamicXAxisNameGap: 25,
hasTimestampField: false,
defaultSeriesProps: { type: "bar" },
chartMin: 0,
chartMax: 100,
getAxisDataFromKey,
getSeries,
updateTrellisConfig,
...overrides,
};
}
describe("applyStackedChart", () => {
it("sets xAxis[0].data from getAxisDataFromKey", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(ctx.getAxisDataFromKey).toHaveBeenCalledWith("x1");
expect(ctx.options.xAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("deduplicates xAxis data using Set", () => {
const getAxisDataFromKey = vi.fn((key: string) => {
if (key === "x1") return ["Jan", "Feb", "Jan", "Mar", "Feb"];
return [];
});
const ctx = makeMockContext({ getAxisDataFromKey });
applyStackedChart(ctx);
expect(ctx.options.xAxis[0].data).toEqual(["Jan", "Feb", "Mar"]);
});
it("slices xAxis to one element", () => {
const ctx = makeMockContext();
// Add extra axes to verify slicing
ctx.options.xAxis.push({ data: ["extra"] });
applyStackedChart(ctx);
expect(ctx.options.xAxis).toHaveLength(1);
});
it("calls getSeries with barMinHeight: 1", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(ctx.getSeries).toHaveBeenCalledWith({ barMinHeight: 1 });
});
it("sets series from getSeries result", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(ctx.options.series).toEqual([{ name: "series1", data: [1, 2, 3] }]);
});
it("sets tooltip.axisPointer.label with show: true", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(ctx.options.tooltip.axisPointer.label).toBeDefined();
expect(ctx.options.tooltip.axisPointer.label.show).toBe(true);
});
it("sets tooltip axisPointer label backgroundColor for dark theme", () => {
const ctx = makeMockContext({
store: { state: { theme: "dark", zoConfig: { timestamp_column: "_timestamp" } } },
});
applyStackedChart(ctx);
expect(ctx.options.tooltip.axisPointer.label.backgroundColor).toBe("#333");
});
it("sets tooltip axisPointer label backgroundColor to empty string for light theme", () => {
const ctx = makeMockContext({
store: { state: { theme: "light", zoConfig: { timestamp_column: "_timestamp" } } },
});
applyStackedChart(ctx);
expect(ctx.options.tooltip.axisPointer.label.backgroundColor).toBe("");
});
it("sets xAxis[0].axisTick to empty object", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(ctx.options.xAxis[0].axisTick).toEqual({});
});
it("calls calculateDynamicNameGap to set xAxis[0].nameGap", async () => {
const { calculateDynamicNameGap } = vi.mocked(
await import("@/utils/dashboard/chartDimensionUtils"),
);
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(calculateDynamicNameGap).toHaveBeenCalled();
});
it("sets xAxis[0].nameGap from calculateDynamicNameGap result", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
// calculateDynamicNameGap mock returns 25
expect(ctx.options.xAxis[0].nameGap).toBe(25);
});
it("uses 0 rotation when hasTimestampField is true", () => {
const ctx = makeMockContext({
hasTimestampField: true,
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
axis_label_truncate_width: 120,
axis_label_rotate: 45,
trellis: { layout: null },
background: { value: { color: "#FFFFFF" } },
},
queries: [{ fields: { y: [{ label: "Value" }], breakdown: [] }, customQuery: false }],
},
});
// Override xAxis axisLabel rotate to 45 to verify it gets overridden to 0
ctx.options.xAxis[0].axisLabel.rotate = 45;
applyStackedChart(ctx);
expect(ctx.options.xAxis[0].axisLabel.rotate).toBe(0);
});
it("tooltip axisPointer label formatter is a function", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
expect(typeof ctx.options.tooltip.axisPointer.label.formatter).toBe("function");
});
it("tooltip formatter returns value string for x dimension", () => {
const ctx = makeMockContext();
applyStackedChart(ctx);
const formatter = ctx.options.tooltip.axisPointer.label.formatter;
const result = formatter({ axisDimension: "x", value: "TestLabel" });
expect(result).toBe("TestLabel");
});
});
@@ -0,0 +1,85 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { calculateDynamicNameGap } from "../../chartDimensionUtils";
import { type SQLContext } from "../shared/types";
/**
* Applies chart-specific options for: stacked
*
* Mutates `ctx.options` in place, exactly as the original switch case did.
*/
export function applyStackedChart(ctx: SQLContext): void {
const {
options,
panelSchema,
store,
xAxisKeys,
hasTimestampField,
getSeries,
getAxisDataFromKey,
} = ctx;
options.xAxis[0].data = Array.from(new Set(getAxisDataFromKey(xAxisKeys[0])));
options.xAxis = options.xAxis.slice(0, 1);
options.tooltip.axisPointer.label = {
show: true,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
label: {
fontsize: 12,
precision: panelSchema.config?.decimals,
},
formatter: function (params: any) {
try {
if (params?.axisDimension == "y")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
return params?.value?.toString();
} catch (error) {
return params?.value?.toString() ?? "";
}
},
};
const stackedXAxisRotation = hasTimestampField
? 0
: options.xAxis[0].axisLabel?.rotate || 0;
const stackedXAxisWidth = hasTimestampField
? 120
: panelSchema.config?.axis_label_truncate_width || 120;
options.xAxis[0].axisLabel.margin = 5;
options.xAxis[0].axisLabel = {
rotate: stackedXAxisRotation,
};
options.xAxis[0].axisTick = {};
options.xAxis[0].nameGap = calculateDynamicNameGap(
stackedXAxisRotation,
stackedXAxisWidth,
12,
25,
5,
);
// stacked with xAxis's second value
// allow 2 xAxis and 1 yAxis value for stack chart
// get second x axis key
options.series = getSeries({ barMinHeight: 1 });
}
@@ -0,0 +1,256 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import { convertSQLChartData } from "@/utils/dashboard/sql/convertSQLChartData";
import { buildSQLContext } from "@/utils/dashboard/sql/shared/contextBuilder";
import {
applyAutoSQLTimeSeries,
applyCustomSQLTimeSeries,
} from "@/utils/dashboard/sqlTimeSeriesConverter";
import { applyLineAreaScatterBarChart } from "@/utils/dashboard/sql/charts/convertSQLLineAreaChart";
import { applyHBarChart } from "@/utils/dashboard/sql/charts/convertSQLHBarChart";
import { applyStackedChart } from "@/utils/dashboard/sql/charts/convertSQLStackedChart";
import { applyHStackedChart } from "@/utils/dashboard/sql/charts/convertSQLHStackedChart";
import { applyPieDonutChart } from "@/utils/dashboard/sql/charts/convertSQLPieDonutChart";
import { applyHeatmapChart } from "@/utils/dashboard/sql/charts/convertSQLHeatmapChart";
import { applyMetricChart } from "@/utils/dashboard/sql/charts/convertSQLMetricChart";
import { applyGaugeChart } from "@/utils/dashboard/sql/charts/convertSQLGaugeChart";
vi.mock("@/utils/dashboard/sql/shared/contextBuilder", () => ({
buildSQLContext: vi.fn(),
}));
vi.mock("@/utils/dashboard/sqlTimeSeriesConverter", () => ({
applyAutoSQLTimeSeries: vi.fn(() => false),
applyCustomSQLTimeSeries: vi.fn(() => false),
}));
vi.mock("@/utils/dashboard/query/sqlUtils", () => ({
isGivenFieldInOrderBy: vi.fn(async () => null),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
applyLegendConfiguration: vi.fn(),
}));
vi.mock("@/utils/dashboard/chartColorUtils", () => ({
applySeriesColorMappings: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLLineAreaChart", () => ({
applyLineAreaScatterBarChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLHBarChart", () => ({
applyHBarChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLStackedChart", () => ({
applyStackedChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLHStackedChart", () => ({
applyHStackedChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLPieDonutChart", () => ({
applyPieDonutChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLHeatmapChart", () => ({
applyHeatmapChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLMetricChart", () => ({
applyMetricChart: vi.fn(),
}));
vi.mock("@/utils/dashboard/sql/charts/convertSQLGaugeChart", () => ({
applyGaugeChart: vi.fn(),
}));
// ─── Helpers ────────────────────────────────────────────────────────────────
const makeMinimalCtx = () => ({
options: {
xAxis: [{ data: [] }],
yAxis: [{ data: [] }],
series: [{ data: [1, 2, 3] }],
tooltip: {},
grid: {},
toolbox: { show: true },
legend: {},
},
extras: {},
yAxisKeys: ["y1"],
convertedTimeStampToDataFormat: "",
getAnnotationMarkLine: vi.fn(() => null),
getSeriesMarkArea: vi.fn(() => null),
panelSchema: {
type: "bar",
config: { trellis: { layout: null } },
},
});
const store = {
state: {
theme: "light",
zoConfig: { timestamp_column: "_timestamp" },
},
};
const chartPanelRef = { value: { offsetWidth: 800, offsetHeight: 400 } };
// metadata with queries array to avoid optional-chaining TypeError in
// the stacked/h-stacked sort-by-y-axis post-processing path
const metadata = { queries: [{ query: "" }] };
/**
* Helper that calls convertSQLChartData with a given panelSchema type,
* after setting up buildSQLContext to return a ctx with that type.
*/
async function callWithType(type: string) {
const ctx = makeMinimalCtx();
ctx.panelSchema = { ...ctx.panelSchema, type };
vi.mocked(buildSQLContext).mockReturnValue(ctx as any);
const panelSchema = { type, config: { trellis: { layout: null } } };
return convertSQLChartData(
panelSchema,
[],
store,
chartPanelRef,
null,
[],
metadata,
{},
[],
);
}
// ─── Tests ──────────────────────────────────────────────────────────────────
describe("convertSQLChartData", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset time series mocks to return false by default
vi.mocked(applyAutoSQLTimeSeries).mockReturnValue(false);
vi.mocked(applyCustomSQLTimeSeries).mockReturnValue(false);
});
it("returns { options: null } when buildSQLContext returns null", async () => {
vi.mocked(buildSQLContext).mockReturnValue(null as any);
const panelSchema = { type: "bar", config: { trellis: { layout: null } } };
const result = await convertSQLChartData(
panelSchema,
[],
store,
chartPanelRef,
null,
[],
metadata,
{},
[],
);
expect(result).toEqual({ options: null });
});
it("returns options object when buildSQLContext returns context", async () => {
const result = await callWithType("bar");
expect(result).toHaveProperty("options");
expect(result.options).not.toBeNull();
});
it("calls applyLineAreaScatterBarChart for bar type", async () => {
await callWithType("bar");
expect(applyLineAreaScatterBarChart).toHaveBeenCalledTimes(1);
});
it("calls applyLineAreaScatterBarChart for line type", async () => {
await callWithType("line");
expect(applyLineAreaScatterBarChart).toHaveBeenCalledTimes(1);
});
it("calls applyHBarChart for h-bar type", async () => {
await callWithType("h-bar");
expect(applyHBarChart).toHaveBeenCalledTimes(1);
});
it("calls applyPieDonutChart for pie type", async () => {
await callWithType("pie");
expect(applyPieDonutChart).toHaveBeenCalledTimes(1);
});
it("calls applyPieDonutChart for donut type", async () => {
await callWithType("donut");
expect(applyPieDonutChart).toHaveBeenCalledTimes(1);
});
it("calls applyStackedChart for stacked type", async () => {
await callWithType("stacked");
expect(applyStackedChart).toHaveBeenCalledTimes(1);
});
it("calls applyHeatmapChart for heatmap type", async () => {
await callWithType("heatmap");
expect(applyHeatmapChart).toHaveBeenCalledTimes(1);
});
it("calls applyHStackedChart for h-stacked type", async () => {
await callWithType("h-stacked");
expect(applyHStackedChart).toHaveBeenCalledTimes(1);
});
it("calls applyMetricChart for metric type", async () => {
const ctx = makeMinimalCtx();
ctx.panelSchema = { ...ctx.panelSchema, type: "metric" };
// metric type is excluded from series filter in post-processing
ctx.options.series = [];
vi.mocked(buildSQLContext).mockReturnValue(ctx as any);
const panelSchema = { type: "metric", config: { trellis: { layout: null } } };
await convertSQLChartData(panelSchema, [], store, chartPanelRef, null, [], metadata, {}, []);
expect(applyMetricChart).toHaveBeenCalledTimes(1);
});
it("calls applyGaugeChart for gauge type", async () => {
const ctx = makeMinimalCtx();
ctx.panelSchema = { ...ctx.panelSchema, type: "gauge" };
ctx.options.series = [];
vi.mocked(buildSQLContext).mockReturnValue(ctx as any);
const panelSchema = { type: "gauge", config: { trellis: { layout: null } } };
await convertSQLChartData(panelSchema, [], store, chartPanelRef, null, [], metadata, {}, []);
expect(applyGaugeChart).toHaveBeenCalledTimes(1);
});
it("calls applyAutoSQLTimeSeries and applyCustomSQLTimeSeries", async () => {
await callWithType("bar");
expect(applyAutoSQLTimeSeries).toHaveBeenCalledTimes(1);
expect(applyCustomSQLTimeSeries).toHaveBeenCalledTimes(1);
});
it("extras.isTimeSeries is true when applyAutoSQLTimeSeries returns true", async () => {
vi.mocked(applyAutoSQLTimeSeries).mockReturnValue(true);
const result = await callWithType("bar");
expect((result as any).extras?.isTimeSeries).toBe(true);
});
it("extras.isTimeSeries is false when both time series functions return false", async () => {
vi.mocked(applyAutoSQLTimeSeries).mockReturnValue(false);
vi.mocked(applyCustomSQLTimeSeries).mockReturnValue(false);
const result = await callWithType("bar");
expect((result as any).extras?.isTimeSeries).toBe(false);
});
});
@@ -0,0 +1,303 @@
// 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 {
applyAutoSQLTimeSeries,
applyCustomSQLTimeSeries,
} from "../sqlTimeSeriesConverter";
import { isGivenFieldInOrderBy } from "../../query/sqlUtils";
import { applyLegendConfiguration } from "../legendConfiguration";
import { applySeriesColorMappings } from "../chartColorUtils";
import { buildSQLContext } from "./shared/contextBuilder";
import { applyLineAreaScatterBarChart } from "./charts/convertSQLLineAreaChart";
import { applyHBarChart } from "./charts/convertSQLHBarChart";
import { applyStackedChart } from "./charts/convertSQLStackedChart";
import { applyHStackedChart } from "./charts/convertSQLHStackedChart";
import { applyPieDonutChart } from "./charts/convertSQLPieDonutChart";
import { applyHeatmapChart } from "./charts/convertSQLHeatmapChart";
import { applyMetricChart } from "./charts/convertSQLMetricChart";
import { applyGaugeChart } from "./charts/convertSQLGaugeChart";
/**
* Converts SQL data into a format suitable for rendering a chart.
* This is the main orchestrator: builds shared context, dispatches to a
* chart-type handler, then applies common post-processing.
*/
export const convertSQLChartData = async (
panelSchema: any,
searchQueryData: any,
store: any,
chartPanelRef: any,
hoveredSeriesState: any,
resultMetaData: any,
metadata: any,
chartPanelStyle: any,
annotations: any,
) => {
// Build the full shared context (all pre-switch logic lives here)
const ctx = buildSQLContext(
panelSchema,
searchQueryData,
store,
chartPanelRef,
hoveredSeriesState,
resultMetaData,
metadata,
chartPanelStyle,
annotations,
);
// Guard returned from contextBuilder (invalid/empty input)
if (!ctx) {
return { options: null };
}
const {
options,
extras,
yAxisKeys,
convertedTimeStampToDataFormat,
getAnnotationMarkLine,
getSeriesMarkArea,
} = ctx;
// Now set the series values as per the chart data
// Override any configs if required as per the chart type
switch (panelSchema.type) {
case "area-stacked":
case "line":
case "area":
case "scatter":
case "bar": {
applyLineAreaScatterBarChart(ctx);
break;
}
case "h-bar": {
applyHBarChart(ctx);
break;
}
case "pie": {
applyPieDonutChart(ctx);
break;
}
case "donut": {
applyPieDonutChart(ctx);
break;
}
case "stacked": {
applyStackedChart(ctx);
break;
}
case "heatmap": {
applyHeatmapChart(ctx);
break;
}
case "h-stacked": {
applyHStackedChart(ctx);
break;
}
case "metric": {
applyMetricChart(ctx);
break;
}
case "gauge": {
applyGaugeChart(ctx);
break;
}
default: {
break;
}
}
// POST-PROCESSING (verbatim from original convertSQLData)
let isTimeSeriesFlag = false;
// auto SQL: if x axis has time series
if (
applyAutoSQLTimeSeries(
options,
panelSchema,
store,
metadata,
hoveredSeriesState,
)
) {
// set timeseries flag as a true
isTimeSeriesFlag = true;
}
//custom SQL: check if it is timeseries or not
if (
applyCustomSQLTimeSeries(
options,
panelSchema,
store,
metadata,
hoveredSeriesState,
)
) {
// set timeseries flag as a true
isTimeSeriesFlag = true;
}
// stacked chart sort by y axis
// ignore if time series chart
if (
["stacked", "h-stacked", "area-stacked"].includes(panelSchema?.type) &&
isTimeSeriesFlag == false
) {
// get x axis object
// for h-stacked, categorical axis is y axis
// for stacked and area-stacked, categorical axis is x axis
const xAxisObj =
panelSchema.type == "h-stacked" ? options.yAxis : options.xAxis;
// check if order by uses y axis field
// will return null if not exist
// will return ASC or DESC if exist
const isYAxisExistInOrderBy = await isGivenFieldInOrderBy(
metadata?.queries[0]?.query ?? "",
yAxisKeys[0],
);
if (isYAxisExistInOrderBy) {
// Calculate the total for each series and combine it with the corresponding x-axis label
const totals = new Map();
for (let i = 0; i < xAxisObj[0]?.data?.length; i++) {
const total = options?.series?.reduce(
(sum: number, currentSeries: any) =>
sum + (currentSeries?.data[i] ?? 0),
0,
);
totals.set(i, { label: xAxisObj[0]?.data[i], total });
}
// Sort the indices by total in the specified order
// ASC for ascending, DESC for descending
const sortedIndices = Array.from(totals.keys()).sort((a, b) => {
const diff = totals.get(a).total - totals.get(b).total;
return isYAxisExistInOrderBy == "ASC" ? diff : -diff;
});
// Create new sorted arrays for the x-axis labels and series
xAxisObj[0].data = sortedIndices.map((i) => totals.get(i).label);
options.series = options?.series?.map((currentSeries: any) => {
currentSeries.data = sortedIndices?.map((i) => currentSeries?.data[i]);
return currentSeries;
});
}
}
// Apply all legend configurations using the new centralized function
applyLegendConfiguration(
panelSchema,
chartPanelRef,
hoveredSeriesState,
options,
);
//check if is there any data else filter out axis or series data
// for metric, gauge we does not have data field
if (!["metric", "gauge"].includes(panelSchema.type)) {
options.series = options.series.filter((it: any) => it.data?.length);
if (panelSchema.type == "h-bar" || panelSchema.type == "h-stacked") {
options.xAxis = options.xAxis;
} else if (!["pie", "donut"].includes(panelSchema.type)) {
options.yAxis = options.yAxis;
}
}
// allowed to zoom, only if timeseries
options.toolbox.show = options.toolbox.show && isTimeSeriesFlag;
if (
[
"area",
"area-stacked",
"bar",
"h-bar",
"line",
"scatter",
"stacked",
"h-stacked",
].includes(panelSchema.type) &&
isTimeSeriesFlag &&
!panelSchema.config.trellis?.layout
) {
options.series.push({
type: "line",
data: [[convertedTimeStampToDataFormat, null]],
markLine: getAnnotationMarkLine(),
markArea: getSeriesMarkArea(),
zlevel: 1,
});
}
// Apply series color mappings via reusable helper
applySeriesColorMappings(
options.series,
panelSchema?.config?.color?.colorBySeries,
store.state.theme,
);
// Apply label truncation to x-axis only
if (panelSchema.config?.axis_label_truncate_width) {
if (Array.isArray(options.xAxis)) {
options.xAxis.forEach((axis: any) => {
if (!axis.axisLabel) axis.axisLabel = {};
axis.axisLabel.width = panelSchema.config.axis_label_truncate_width;
axis.axisLabel.overflow = "truncate";
});
} else if (options.xAxis) {
if (!options.xAxis.axisLabel) options.xAxis.axisLabel = {};
options.xAxis.axisLabel.width =
panelSchema.config.axis_label_truncate_width;
options.xAxis.axisLabel.overflow = "truncate";
}
}
// Merge custom chart options if provided (e.g., for alert preview tooltip customization)
if (panelSchema.config?.custom_chart_options) {
const customOptions = panelSchema.config.custom_chart_options;
// Deep merge custom tooltip options if provided
if (customOptions.tooltip && options.tooltip) {
options.tooltip = {
...options.tooltip,
...customOptions.tooltip,
};
}
// Merge any other custom options
Object.keys(customOptions).forEach((key) => {
if (key !== "tooltip" && customOptions[key]) {
options[key] = {
...(options[key] || {}),
...customOptions[key],
};
}
});
}
return {
options,
extras: {
...extras,
panelId: panelSchema?.id,
isTimeSeries: isTimeSeriesFlag,
},
};
};
+16
View File
@@ -0,0 +1,16 @@
// 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/>.
export { convertSQLChartData } from "./convertSQLChartData";
@@ -0,0 +1,134 @@
// 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 { describe, expect, it, vi } from "vitest";
import { largestLabel } from "@/utils/dashboard/sql/shared/contextBuilder";
// Mock all heavy dependencies of contextBuilder
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v)),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 50),
calculateDynamicNameGap: vi.fn(() => 25),
calculateRotatedLabelBottomSpace: vi.fn(() => 0),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
ColorModeWithoutMinMax: [],
getSQLMinMaxValue: vi.fn(() => ({ min: 0, max: 100 })),
getColorPalette: vi.fn(() => ["#FF5733"]),
}));
vi.mock("@/utils/dashboard/aliasUtils", () => ({
getDataValue: vi.fn((obj, key) => obj?.[key]),
}));
vi.mock("@/utils/dashboard/legendConfiguration", () => ({
createBaseLegendConfig: vi.fn(() => ({})),
getChartDimensions: vi.fn(() => ({ width: 800, height: 400 })),
calculateChartDimensions: vi.fn(() => ({})),
calculatePieChartRadius: vi.fn(() => "50%"),
}));
vi.mock("@/utils/dashboard/sqlChartSeriesProps", () => ({
getPropsByChartTypeForSeries: vi.fn(() => ({ type: "bar" })),
}));
vi.mock("@/utils/dashboard/sqlProcessData", () => ({
processData: vi.fn((data) => data[0] || []),
}));
vi.mock("@/utils/dashboard/sqlMissingValueFiller", () => ({
fillMissingValues: vi.fn((data) => data),
}));
vi.mock("@/utils/dashboard/sql/shared/seriesBuilder", () => ({
buildDataLookupMap: vi.fn(() => new Map()),
createSeriesBuilders: vi.fn(() => ({
getAxisDataFromKey: vi.fn(() => []),
getSeries: vi.fn(() => []),
getAnnotationMarkLine: vi.fn(() => null),
getSeriesMarkArea: vi.fn(() => null),
getPieChartRadius: vi.fn(() => "50%"),
})),
}));
vi.mock("@/utils/dashboard/sql/shared/trellisConfig", () => ({
createTrellisHelpers: vi.fn(() => ({
updateTrellisConfig: vi.fn(),
})),
}));
describe("contextBuilder", () => {
describe("largestLabel", () => {
it("returns the label with the longest string representation", () => {
const data = ["short", "medium text", "a"];
expect(largestLabel(data)).toBe("medium text");
});
it("returns empty string for empty array", () => {
expect(largestLabel([])).toBe("");
});
it("handles single element array", () => {
expect(largestLabel(["hello"])).toBe("hello");
});
it("handles numbers by converting to string for length comparison", () => {
const data = [1, 100, 10];
// 100 has 3 chars, others have 1 and 2
expect(largestLabel(data)).toBe(100);
});
it("handles mix of strings and numbers", () => {
const data = ["abc", 12345, "de"];
// 12345 as string is 5 chars, "abc" is 3, "de" is 2
expect(largestLabel(data)).toBe(12345);
});
it("returns last of equal-length items (uses > not >=)", () => {
// both have length 3
const data = ["abc", "xyz"];
// "abc" is first, "xyz" is NOT greater (lengths are equal), so "abc" stays
expect(largestLabel(data)).toBe("abc");
});
it("handles null/undefined values gracefully (toString may throw)", () => {
// If items can be converted to string, it should work
const data = ["", "hello", "hi"];
expect(largestLabel(data)).toBe("hello");
});
it("handles all same length items", () => {
const data = ["aaa", "bbb", "ccc"];
// All length 3, first one wins (> not >=)
expect(largestLabel(data)).toBe("aaa");
});
it("handles large array", () => {
const data = Array.from({ length: 100 }, (_, i) => "a".repeat(i + 1));
expect(largestLabel(data)).toBe("a".repeat(100));
});
it("handles timestamp-like strings", () => {
const data = ["2024-01-01T00:00:00", "2024-01-15", "hi"];
// "2024-01-01T00:00:00" is 19 chars
expect(largestLabel(data)).toBe("2024-01-01T00:00:00");
});
});
});
@@ -0,0 +1,828 @@
// 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 { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import {
calculateWidthText,
calculateDynamicNameGap,
calculateRotatedLabelBottomSpace,
} from "../../chartDimensionUtils";
import { ColorModeWithoutMinMax, getSQLMinMaxValue } from "../../colorPalette";
import { getDataValue } from "../../aliasUtils";
import {
createBaseLegendConfig,
getChartDimensions,
calculateChartDimensions,
calculatePieChartRadius,
} from "../../legendConfiguration";
import { getPropsByChartTypeForSeries } from "../../sqlChartSeriesProps";
import { processData } from "../../sqlProcessData";
import { fillMissingValues } from "../../sqlMissingValueFiller";
import { buildDataLookupMap, createSeriesBuilders } from "./seriesBuilder";
import { createTrellisHelpers } from "./trellisConfig";
import { type SQLContext } from "./types";
/**
* Finds the largest label in the given data array.
*/
export const largestLabel = (data: any) => {
const largestlabel = data.reduce((largest: any, label: any) => {
return label?.toString().length > largest?.toString().length
? label
: largest;
}, "");
return largestlabel;
};
/**
* Builds the complete SQLContext for a given set of raw inputs.
*
* This function replicates all the work that `convertSQLData` used to do
* *before* the chart-type switch statement ΓÇö axis-key extraction, data
* processing, base-options construction, and helper-function creation.
*
* @returns The fully populated SQLContext, or `null` if the input is invalid.
*/
export function buildSQLContext(
panelSchema: any,
searchQueryData: any,
store: any,
chartPanelRef: any,
hoveredSeriesState: any,
resultMetaData: any,
metadata: any,
chartPanelStyle: any,
annotations: any,
): SQLContext | null {
// Set gridlines visibility based on config.show_gridlines (default: true)
const showGridlines =
panelSchema?.config?.show_gridlines !== undefined
? panelSchema.config.show_gridlines
: true;
const extras: any = {};
// if no data than return it
if (
!Array.isArray(searchQueryData) ||
searchQueryData.length === 0 ||
!searchQueryData[0] ||
!panelSchema.queries[0].fields.x ||
!panelSchema.queries[0].fields.y
) {
return null;
}
// get the x axis key
const getXAxisKeys = () => {
return panelSchema?.queries[0]?.fields?.x?.length
? panelSchema?.queries[0]?.fields?.x.map((it: any) => it.alias)
: [];
};
// get the y axis key
const getYAxisKeys = () => {
return panelSchema?.queries[0]?.fields?.y?.length
? panelSchema?.queries[0]?.fields?.y.map((it: any) => it.alias)
: [];
};
// get the z axis key
const getZAxisKeys = () => {
return panelSchema?.queries[0]?.fields?.z?.length
? panelSchema?.queries[0]?.fields?.z.map((it: any) => it.alias)
: [];
};
// get the breakdown key
const getBreakDownKeys = () => {
return panelSchema?.queries[0]?.fields?.breakdown?.length
? panelSchema?.queries[0]?.fields?.breakdown.map((it: any) => it.alias)
: [];
};
// Step 1: Get the X-Axis key
const xAxisKeys = getXAxisKeys();
// Step 2: Get the Y-Axis key
const yAxisKeys = getYAxisKeys();
const zAxisKeys = getZAxisKeys();
const breakDownKeys = getBreakDownKeys();
const getMarkLineData = (panelSchema: any) => {
return (
panelSchema?.config?.mark_line?.map((markLine: any) => {
return {
name: markLine.name,
type: markLine.type,
xAxis: markLine.type == "xAxis" ? markLine.value : null,
yAxis: markLine.type == "yAxis" ? markLine.value : null,
label: {
formatter: markLine.name ? "{b}:{c}" : "{c}",
position: "insideEndTop",
},
};
}) ?? []
);
};
const noValueConfigOption = panelSchema?.config?.no_value_replacement ?? "";
const processedData = processData(
searchQueryData,
panelSchema,
store,
yAxisKeys,
breakDownKeys,
xAxisKeys,
extras,
);
const missingValueData = fillMissingValues(
processedData,
panelSchema,
resultMetaData,
metadata,
xAxisKeys,
yAxisKeys,
zAxisKeys,
breakDownKeys,
noValueConfigOption,
);
// get the axis data using key
const getAxisDataFromKey = (key: string) => {
const data =
missingValueData?.filter((item: any) => {
return (
xAxisKeys.every((key: any) => getDataValue(item, key) != null) &&
yAxisKeys.every((key: any) => getDataValue(item, key) != null) &&
breakDownKeys.every((key: any) => getDataValue(item, key) != null)
);
}) || [];
// if data is not there use {} as a default value
const keys = Object.keys((data.length && data[0]) || {}); // Assuming there's at least one object
const keyArrays: any = {};
for (const key of keys) {
keyArrays[key] = data.map((obj: any) => getDataValue(obj, key));
}
const result = getDataValue(keyArrays, key) || [];
return result;
};
/**
* Returns the largest label from the stacked chart data.
* Calculates the largest value for each unique breakdown and sums those values.
* @param axisKey - key of the yaxis
* @param breakDownkey - key of the breakdown
* @returns {number} - the largest value
*/
const largestStackLabel = (axisKey: string, breakDownkey: string) => {
const data =
missingValueData?.filter((item: any) => {
return (
xAxisKeys.every((key: any) => getDataValue(item, key) != null) &&
yAxisKeys.every((key: any) => getDataValue(item, key) != null) &&
breakDownKeys.every((key: any) => getDataValue(item, key) != null)
);
}) || [];
const maxValues: any = {};
data.forEach((obj: any) => {
const breakDownValue = getDataValue(obj, breakDownkey);
const axisValue = getDataValue(obj, axisKey);
if (maxValues[breakDownValue]) {
if (axisValue > maxValues[breakDownValue]) {
maxValues[breakDownValue] = axisValue;
}
} else {
maxValues[breakDownValue] =
typeof axisValue === "number" ? axisValue : 0;
}
});
return Object.values(maxValues).reduce((a: any, b: any) => a + b, 0);
};
function getLargestLabel() {
if (
(panelSchema.type === "stacked" || panelSchema.type === "area-stacked") &&
breakDownKeys.length > 0
) {
return largestStackLabel(yAxisKeys[0], breakDownKeys[0]);
} else {
return largestLabel(getAxisDataFromKey(yAxisKeys[0]));
}
}
const convertedTimeStampToDataFormat = new Date(
annotations?.value?.[0]?.start_time / 1000,
).toString();
/**
* Returns the pie chart radius that accounts for legend space
* @param {any[]} seriesData - The series data for legend calculation
* @returns {number} - the radius percentage
*/
const getPieChartRadius = (seriesData: any[] = []) => {
// Get chart dimensions from chartPanelRef
const dimensions = getChartDimensions(chartPanelRef);
// Calculate available dimensions using our centralized helper function
const chartDimensions = calculateChartDimensions(
panelSchema,
dimensions.chartWidth,
dimensions.chartHeight,
seriesData,
);
// Use the optimized pie chart radius calculation
const radius = calculatePieChartRadius(
panelSchema,
chartDimensions.availableWidth,
chartDimensions.availableHeight,
dimensions.chartWidth,
dimensions.chartHeight,
);
return radius;
};
const legendConfig = createBaseLegendConfig(panelSchema, hoveredSeriesState);
const isHorizontalChart =
panelSchema.type === "h-bar" || panelSchema.type === "h-stacked";
const defaultGrid = {
containLabel: true,
left: "10%",
right: "10%",
top: "15%",
bottom: "15%",
};
const [min, max] = getSQLMinMaxValue(yAxisKeys, missingValueData);
const getFinalAxisValue = (
configValue: number | null | undefined,
dataValue: number,
isMin: boolean,
) => {
if (configValue === null || configValue === undefined) {
return undefined;
}
return isMin
? Math.min(configValue, dataValue)
: Math.max(configValue, dataValue);
};
const hasXAxisName = panelSchema.queries[0]?.fields?.x[0]?.label;
const hasYAxisName =
panelSchema.queries[0]?.fields?.y?.length == 1 &&
panelSchema.queries[0]?.fields?.y[0]?.label;
// Check if x-axis will be time-based by looking for timestamp fields
const hasTimestampField = panelSchema.queries[0].fields?.x?.some(
(it: any) =>
it?.args?.[0]?.value?.field == store.state?.zoConfig?.timestamp_column ||
["histogram", "date_bin"].includes(it.aggregationFunction),
);
// Calculate additional spacing needed for rotated labels
// Skip rotation for time-based x-axis and horizontal chart types (h-bar, h-stacked)
// For horizontal charts, labels should always be at 0 degrees (not rotated)
let labelRotation =
hasTimestampField || isHorizontalChart
? 0
: panelSchema.config?.axis_label_rotate || 0;
let labelWidth =
hasTimestampField || isHorizontalChart
? 0
: panelSchema.config?.axis_label_truncate_width || 0;
// If truncate width is not set and not time-based/horizontal, calculate the actual max width from data
if (
!hasTimestampField &&
!isHorizontalChart &&
labelWidth === 0 &&
xAxisKeys.length > 0
) {
const longestLabelStr = largestLabel(getAxisDataFromKey(xAxisKeys[0]));
labelWidth = calculateWidthText(longestLabelStr, "12px");
} else if (!hasTimestampField && !isHorizontalChart && labelWidth === 0) {
labelWidth = 120; // Fallback
}
const labelFontSize = 12;
const labelMargin = 10;
// Calculate the section height (nameGap) upfront so we can use it for bottom spacing
// Skip rotation-based calculations for horizontal charts and time-based axes
const dynamicXAxisNameGap =
hasTimestampField || isHorizontalChart
? 25
: calculateDynamicNameGap(
labelRotation,
labelWidth,
labelFontSize,
25,
labelMargin,
);
// Additional bottom space is only needed for non-horizontal, non-time-based charts
const additionalBottomSpace =
hasTimestampField || isHorizontalChart
? 0
: calculateRotatedLabelBottomSpace(
labelRotation,
labelWidth,
labelFontSize,
!!hasXAxisName,
dynamicXAxisNameGap,
);
const options: any = {
backgroundColor: "transparent",
legend: legendConfig,
grid: {
containLabel: panelSchema.config?.axis_width == null ? true : false,
left: hasYAxisName ? (panelSchema.config?.axis_width ?? 30) : 5,
right: 20,
top: "15",
bottom: hasXAxisName
? (() => {
const baseBottom =
legendConfig.orient === "horizontal" &&
panelSchema.config?.show_legends
? panelSchema.config?.axis_width == null
? 50
: 60
: panelSchema.config?.axis_width == null
? 35
: 40;
// When an x-axis name is present, `nameGap` already reserves space
// between rotated labels and the axis name. Adding `additionalBottomSpace`
// here causes double-counting and extra blank space beneath the axis
// name. Only return the base bottom in this case.
return baseBottom;
})()
: (() => {
const baseBottom =
legendConfig.orient === "vertical" &&
panelSchema.config?.show_legends
? 0
: breakDownKeys.length > 0
? 25
: 0;
return baseBottom + additionalBottomSpace;
})(),
},
tooltip: {
trigger: "axis",
textStyle: {
color: store.state.theme === "dark" ? "#fff" : "#000",
fontSize: 12,
},
enterable: true,
backgroundColor:
store.state.theme === "dark" ? "rgba(0,0,0,1)" : "rgba(255,255,255,1)",
extraCssText:
"max-height: 200px; overflow: auto; max-width: 400px; user-select: text; scrollbar-width: thin; scrollbar-color: rgba(128,128,128,0.5) transparent;",
axisPointer: {
type: "cross",
label: {
show: true,
fontsize: 12,
precision: panelSchema.config?.decimals,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
formatter: function (params: any) {
try {
let lineBreaks = "";
if (
panelSchema.type === "h-bar" ||
panelSchema.type === "h-stacked"
) {
if (params?.axisDimension == "x")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
//we does not required any linebreaks for h-stacked because we only use one x axis
if (panelSchema.type === "h-stacked")
return params?.value?.toString();
for (
let i = 0;
i <
xAxisKeys.length +
breakDownKeys.length -
params?.axisIndex -
1;
i++
) {
lineBreaks += " \n \n";
}
params.value = params?.value?.toString();
return `${lineBreaks} ${params?.value}`;
}
if (params?.axisDimension == "y")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
for (
let i = 0;
i <
xAxisKeys.length + breakDownKeys.length - params?.axisIndex - 1;
i++
) {
lineBreaks += " \n \n";
}
params.value = params?.value?.toString();
return `${lineBreaks} ${params?.value}`;
} catch (error) {
return params?.value?.toString() ?? "";
}
},
},
},
formatter: function (name: any) {
try {
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
if (name?.length == 0) return "";
// sort tooltip array based on value
name?.sort((a: any, b: any) => {
return (b.value ?? 0) - (a.value ?? 0);
});
// if hovered series name is not null then move it to first position
if (hoveredSeriesState?.value?.hoveredSeriesName) {
// get the current series index from name
const currentSeriesIndex = name?.findIndex(
(it: any) =>
it.seriesName == hoveredSeriesState?.value?.hoveredSeriesName,
);
// if hovered series index is not -1 then take it to very first position
if (currentSeriesIndex != -1) {
// shift all series to next position and place current series at first position
const temp = name?.[currentSeriesIndex];
for (let i = currentSeriesIndex; i > 0; i--) {
name[i] = name?.[i - 1];
}
name[0] = temp;
}
}
const hoverText: string[] = [];
name?.forEach((it: any) => {
// if value is not null, show in tooltip
if (it.value != null) {
// check if the series is the current series being hovered
// if have than bold it
if (
it?.seriesName == hoveredSeriesState?.value?.hoveredSeriesName
)
hoverText.push(
`<strong>${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)} </strong>`,
);
// else normal text
else
hoverText.push(
`${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}`,
);
}
});
return `${name?.[0]?.name} <br/> ${hoverText.join("<br/>")}`;
} catch (error) {
return "";
}
},
},
xAxis: [...xAxisKeys, ...breakDownKeys]?.map((key: any, index: number) => {
const data = getAxisDataFromKey(key);
//unique value index array
const arr: any = [];
for (let i = 0; i < data.length; i++) {
if (i == 0 || data[i] != data[i - 1]) arr.push(i);
}
// Use 0 for rotation and width if time-based field or horizontal chart
const labelRotation =
hasTimestampField || isHorizontalChart
? 0
: panelSchema.config?.axis_label_rotate || 0;
const labelWidth =
hasTimestampField || isHorizontalChart
? 120
: panelSchema.config?.axis_label_truncate_width || 120;
const labelFontSize = 12;
const labelMargin = 10;
return {
type: "category",
position: panelSchema.type == "h-bar" ? "left" : "bottom",
// inverse data for h-stacked and h-bar
inverse: ["h-stacked", "h-bar"].includes(panelSchema.type),
name: index == 0 ? panelSchema.queries[0]?.fields?.x[index]?.label : "",
label: {
show: panelSchema.config?.label_option?.position != null,
position: panelSchema.config?.label_option?.position || "None",
rotate: panelSchema.config?.label_option?.rotate || 0,
},
nameLocation: "middle",
nameGap: dynamicXAxisNameGap,
nameTextStyle: {
fontWeight: "bold",
fontSize: 14,
},
axisLabel: {
interval:
panelSchema.type == "h-stacked"
? "auto"
: index == xAxisKeys.length + breakDownKeys.length - 1
? "auto"
: function (i: any) {
return arr.includes(i);
},
overflow:
index == xAxisKeys.length + breakDownKeys.length - 1
? "none"
: "truncate",
// hide axis label if overlaps
hideOverlap: true,
width: labelWidth,
margin: labelMargin,
rotate: labelRotation,
},
splitLine: {
show: showGridlines,
},
axisLine: {
show: searchQueryData?.every((it: any) => it.length == 0)
? true
: (panelSchema.config?.axis_border_show ?? false),
},
axisTick: {
show: xAxisKeys.length + breakDownKeys.length == 1 ? false : true,
align: "left",
alignWithLabel: false,
length: 5,
interval:
panelSchema.type == "h-stacked"
? "auto"
: function (i: any) {
return arr.includes(i);
},
},
data: data,
};
}),
yAxis: {
type: "value",
name:
panelSchema.queries[0]?.fields?.y?.length == 1
? panelSchema.queries[0]?.fields?.y[0]?.label
: "",
nameLocation: "middle",
min: getFinalAxisValue(panelSchema.config.y_axis_min, min, true),
max: getFinalAxisValue(panelSchema.config.y_axis_max, max, false),
nameGap:
calculateWidthText(
panelSchema.type == "h-bar" || panelSchema.type == "h-stacked"
? largestLabel(getAxisDataFromKey(yAxisKeys[0]))
: formatUnitValue(
getUnitValue(
getLargestLabel(),
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
),
) + 8,
nameTextStyle: {
fontWeight: "bold",
fontSize: 14,
},
axisLabel: {
formatter: function (value: any) {
try {
return formatUnitValue(
getUnitValue(
value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
} catch (error) {
return "";
}
},
},
splitLine: {
show: showGridlines,
},
axisLine: {
show: searchQueryData?.every((it: any) => it.length == 0)
? true
: (panelSchema.config?.axis_border_show ?? false),
},
},
toolbox: {
orient: "vertical",
show: !["pie", "donut", "metric", "gauge"].includes(panelSchema.type),
showTitle: false,
tooltip: {
show: false,
},
itemSize: 0,
itemGap: 0,
// it is used to hide toolbox buttons
bottom: "100%",
feature: {
dataZoom: {
yAxisIndex: panelSchema.config?.dataZoom?.hasOwnProperty("yAxisIndex")
? panelSchema.config?.dataZoom.yAxisIndex
: "none",
},
},
},
series: [],
};
// Ensure gridlines visibility is set for all xAxis and yAxis (handles both array and object cases)
if (options.xAxis) {
(Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]).forEach(
(axis) => {
axis.splitLine.show = showGridlines;
},
);
}
if (options.yAxis) {
(Array.isArray(options.yAxis) ? options.yAxis : [options.yAxis]).forEach(
(axis) => {
// if (!axis.splitLine) axis.splitLine = {};
axis.splitLine.show = showGridlines;
},
);
}
const defaultSeriesProps = getPropsByChartTypeForSeries(panelSchema);
// if color type is shades, continuous then required to calculate min and max for chart.
let chartMin: any = Infinity;
let chartMax: any = -Infinity;
if (
!Object.values(ColorModeWithoutMinMax).includes(
panelSchema.config?.color?.mode,
)
) {
// if heatmap then get min and max from z axis sql data
if (panelSchema.type == "heatmap") {
// NOTE: Currently we do not support color options for heatmap
// [chartMin, chartMax] = getSQLMinMaxValue(zAxisKeys, searchQueryData);
chartMin = null;
chartMax = null;
} else {
[chartMin, chartMax] = getSQLMinMaxValue(yAxisKeys, missingValueData);
}
}
// Build the lookup map once for O(1) series data access
const dataLookupMap = buildDataLookupMap(
missingValueData,
breakDownKeys,
xAxisKeys,
);
// Build series helpers (they close over options, missingValueData, etc.)
const seriesHelpers = createSeriesBuilders({
options,
panelSchema,
store,
missingValueData,
xAxisKeys,
yAxisKeys,
breakDownKeys,
defaultSeriesProps,
chartMin,
chartMax,
dataLookupMap,
annotations,
getMarkLineData,
getAxisDataFromKey,
});
// Build trellis helpers (they close over options, chartPanelRef, etc.)
const trellisHelpers = createTrellisHelpers({
options,
panelSchema,
chartPanelRef,
chartPanelStyle,
yAxisKeys,
xAxisKeys,
isHorizontalChart,
convertedTimeStampToDataFormat,
defaultGrid,
getAxisDataFromKey,
getAnnotationMarkLine: seriesHelpers.getAnnotationMarkLine,
getSeriesMarkArea: seriesHelpers.getSeriesMarkArea,
largestLabel,
});
const ctx: SQLContext = {
// Raw inputs
panelSchema,
searchQueryData,
store,
chartPanelRef,
hoveredSeriesState,
chartPanelStyle,
annotations,
metadata,
resultMetaData,
// Axis keys
xAxisKeys,
yAxisKeys,
zAxisKeys,
breakDownKeys,
// Processed data
missingValueData,
extras,
// Derived scalars
showGridlines,
hasTimestampField,
isHorizontalChart,
dynamicXAxisNameGap,
convertedTimeStampToDataFormat,
defaultSeriesProps,
chartMin,
chartMax,
defaultGrid,
// Mutable ECharts options
options,
// Closure-based helpers
getAxisDataFromKey,
getSeries: seriesHelpers.getSeries,
getAnnotationMarkLine: seriesHelpers.getAnnotationMarkLine,
getSeriesMarkArea: seriesHelpers.getSeriesMarkArea,
getPieChartRadius,
updateTrellisConfig: trellisHelpers.updateTrellisConfig,
};
return ctx;
}
@@ -0,0 +1,114 @@
// 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 { describe, expect, it, vi } from "vitest";
import { buildDataLookupMap } from "@/utils/dashboard/sql/shared/seriesBuilder";
// Mock heavy dependencies
vi.mock("@/utils/dashboard/aliasUtils", () => ({
getDataValue: vi.fn((obj: any, key: any) => obj?.[key]),
}));
vi.mock("@/utils/dashboard/colorPalette", () => ({
getSeriesColor: vi.fn(() => "#FF5733"),
}));
vi.mock("@/utils/dashboard/getAnnotationsData", () => ({
getAnnotationsData: vi.fn(() => []),
}));
vi.mock("@/ts/interfaces/dashboard", () => ({}));
describe("seriesBuilder", () => {
describe("buildDataLookupMap", () => {
it("returns empty map when no breakdown keys", () => {
const data = [{ category: "A", time: "2024-01", value: 10 }];
const result = buildDataLookupMap(data, [], ["time"]);
expect(result.size).toBe(0);
});
it("returns empty map when no x-axis keys", () => {
const data = [{ category: "A", time: "2024-01", value: 10 }];
const result = buildDataLookupMap(data, ["category"], []);
expect(result.size).toBe(0);
});
it("builds map with breakdown||xAxis key format", () => {
const data = [{ category: "A", time: "2024-01", value: 10 }];
const result = buildDataLookupMap(data, ["category"], ["time"]);
expect(result.has("A||2024-01")).toBe(true);
});
it("maps to correct data row", () => {
const row = { category: "A", time: "2024-01", value: 10 };
const result = buildDataLookupMap([row], ["category"], ["time"]);
expect(result.get("A||2024-01")).toBe(row);
});
it("handles multiple rows", () => {
const data = [
{ category: "A", time: "2024-01", value: 10 },
{ category: "B", time: "2024-01", value: 20 },
{ category: "A", time: "2024-02", value: 30 },
];
const result = buildDataLookupMap(data, ["category"], ["time"]);
expect(result.size).toBe(3);
expect(result.has("A||2024-01")).toBe(true);
expect(result.has("B||2024-01")).toBe(true);
expect(result.has("A||2024-02")).toBe(true);
});
it("keeps FIRST occurrence when duplicate keys exist", () => {
const first = { category: "A", time: "2024-01", value: 10 };
const second = { category: "A", time: "2024-01", value: 99 };
const result = buildDataLookupMap([first, second], ["category"], ["time"]);
expect(result.get("A||2024-01")).toBe(first);
expect(result.get("A||2024-01")).not.toBe(second);
});
it("handles empty data array", () => {
const result = buildDataLookupMap([], ["category"], ["time"]);
expect(result.size).toBe(0);
});
it("handles undefined breakdown values", () => {
const data = [{ time: "2024-01", value: 10 }]; // no category key
const result = buildDataLookupMap(data, ["category"], ["time"]);
expect(result.has("undefined||2024-01")).toBe(true);
});
it("handles null breakdown values", () => {
const data = [{ category: null, time: "2024-01", value: 10 }];
const result = buildDataLookupMap(data, ["category"], ["time"]);
expect(result.has("null||2024-01")).toBe(true);
});
it("uses only first breakdown key", () => {
const data = [{ cat: "A", sub: "X", time: "2024-01", value: 10 }];
const result = buildDataLookupMap(data, ["cat", "sub"], ["time"]);
// Only first breakdown key "cat" is used
expect(result.has("A||2024-01")).toBe(true);
expect(result.has("X||2024-01")).toBe(false);
});
it("uses only first x-axis key", () => {
const data = [{ category: "A", time: "2024-01", month: "Jan", value: 10 }];
const result = buildDataLookupMap(data, ["category"], ["time", "month"]);
// Only first x-axis key "time" is used
expect(result.has("A||2024-01")).toBe(true);
expect(result.has("A||Jan")).toBe(false);
});
});
});
@@ -0,0 +1,297 @@
// 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 { getDataValue } from "../../aliasUtils";
import { getSeriesColor } from "../../colorPalette";
import { getAnnotationsData } from "@/utils/dashboard/getAnnotationsData";
import { type SeriesObject } from "@/ts/interfaces/dashboard";
export interface SeriesDeps {
options: any;
panelSchema: any;
store: any;
missingValueData: any[];
xAxisKeys: string[];
yAxisKeys: string[];
breakDownKeys: string[];
defaultSeriesProps: any;
chartMin: any;
chartMax: any;
dataLookupMap: Map<string, any>;
annotations: any;
getMarkLineData: (arg0: any) => any[];
getAxisDataFromKey: (arg0: string) => any[];
}
/**
* Builds a lookup map ONCE for O(1) access during series generation.
* Key: "breakdownValue||xAxisValue", Value: data row.
* This prevents nested O(n┬▓) loops in getSeriesData.
*/
export function buildDataLookupMap(
missingValueData: any[],
breakDownKeys: string[],
xAxisKeys: string[],
): Map<string, any> {
const dataMap = new Map<string, any>();
if (!breakDownKeys.length || !xAxisKeys.length) {
return dataMap;
}
const breakdownKey = breakDownKeys[0];
const xAxisKey = xAxisKeys[0];
for (const item of missingValueData) {
const breakdownValue = getDataValue(item, breakdownKey);
const xValue = getDataValue(item, xAxisKey);
const key = `${breakdownValue}||${xValue}`;
// Only set if NOT already present (keeps FIRST occurrence, matching original .find() behavior)
if (!dataMap.has(key)) {
dataMap.set(key, item);
}
}
return dataMap;
}
/**
* Creates all series-building helper functions.
* All functions close over `deps` so they behave identically to the original
* closures defined inside `convertSQLData`.
*/
export function createSeriesBuilders(deps: SeriesDeps) {
const {
options,
panelSchema,
store,
missingValueData,
yAxisKeys,
breakDownKeys,
defaultSeriesProps,
chartMin,
chartMax,
dataLookupMap,
annotations,
getMarkLineData,
getAxisDataFromKey,
} = deps;
const { markLines, markAreas } = getAnnotationsData(
annotations,
store.state.timezone,
);
const getSeriesLabel = () => {
return {
show: panelSchema.config?.label_option?.position != null,
position: panelSchema.config?.label_option?.position || "None",
rotate: panelSchema.config?.label_option?.rotate || 0,
};
};
const getSeriesMarkLine = () => {
return {
silent: true,
animation: false,
data: getMarkLineData(panelSchema),
};
};
const getAnnotationMarkLine = () => {
return {
itemStyle: {
color: "rgba(108, 122, 125, 1)",
},
silent: false,
animation: false,
data: markLines,
};
};
const getSeriesMarkArea = () => {
return {
itemStyle: {
color: "rgba(0, 191, 255, 0.15)",
},
data: markAreas,
};
};
/**
* Retrieves unique values for the second x-axis key in a stacked chart.
* Assumes the first value in breakDownKeys corresponds to the second x-axis key.
*/
function getUniqueStackedXAxisValues(breakDownKey: string): any[] {
// Check if there's a second x-axis key in breakDownKeys
if (!breakDownKey) return [];
// Extract unique values for the second x-axis key
// NOTE: while filter, we can't compare type as well because set will have string values
const uniqueValues = [
...Array.from(
new Set(
missingValueData.map((obj: any) => getDataValue(obj, breakDownKey)),
),
),
].filter((value: any) => value != null || value != undefined);
return uniqueValues;
}
const getSeriesData = (
breakdownKey: string,
yAxisKey: string,
xAxisKey: string,
) => {
if (!(breakdownKey !== null && yAxisKey !== null && xAxisKey !== null))
return [];
// Use the pre-built lookup map for O(1) access instead of O(n) filter + find
// xAxisKey parameter is the breakdown value passed from getSeries()
const seriesData = options.xAxis[0].data.map((xValue: any) => {
const lookupKey = `${xAxisKey}||${xValue}`;
const dataRow = dataLookupMap.get(lookupKey);
return dataRow ? (getDataValue(dataRow, yAxisKey) ?? null) : null;
});
return seriesData;
};
const getSeriesObj = (
yAxisName: string,
seriesData: Array<number> = [],
seriesConfig: Record<string, any>,
seriesName: string,
): SeriesObject => {
return {
//only append if yaxiskeys length is more than 1
name: yAxisName?.toString(),
...defaultSeriesProps,
label: getSeriesLabel(),
originalSeriesName: seriesName,
// markLine if exist
markLine: getSeriesMarkLine(),
// markArea: getSeriesMarkArea(),
// config to connect null values
connectNulls: panelSchema.config?.connect_nulls ?? false,
large: true,
color:
getSeriesColor(
panelSchema.config.color,
yAxisName,
seriesData,
chartMin,
chartMax,
store.state.theme,
panelSchema?.config?.color?.colorBySeries,
) ?? null,
data: seriesData,
...seriesConfig,
zlevel: 2,
};
};
const getYAxisLabel = (yAxisKey: string, xAXisKey: string = "") => {
const label = panelSchema?.queries[0]?.fields?.y.find(
(it: any) => it.alias == yAxisKey,
)?.label;
if (
panelSchema.type == "area-stacked" ||
((panelSchema.type == "line" ||
panelSchema.type == "area" ||
panelSchema.type == "scatter" ||
panelSchema.type == "bar" ||
panelSchema.type == "h-bar" ||
panelSchema.type == "stacked" ||
panelSchema.type == "h-stacked") &&
panelSchema.queries[0].fields.breakdown?.length)
) {
return yAxisKeys.length === 1
? xAXisKey !== ""
? xAXisKey
: label
: `${xAXisKey} (${label})`;
}
return label;
};
const getSeries = (seriesConfig: Record<string, any> = {}) => {
let stackedXAxisUniqueValue: any[] = [];
let breakdownKey = "";
// Area-stacked, stacked, h-stacked and trellis needs breakdown field
if (breakDownKeys?.length) {
breakdownKey = breakDownKeys[0];
// get the unique value of the second xAxis's key
stackedXAxisUniqueValue = getUniqueStackedXAxisValues(breakdownKey);
}
return (
yAxisKeys
.map((yAxis: any, index: number) => {
let yAxisName = getYAxisLabel(yAxis);
if (breakDownKeys.length) {
return stackedXAxisUniqueValue?.map((key: any) => {
// queryData who has the xaxis[1] key as well from xAxisUniqueValue.
yAxisName = getYAxisLabel(yAxis, key);
const seriesData = getSeriesData(breakdownKey, yAxis, key);
// Add stack property for stacked charts
const updatedSeriesConfig = {
...seriesConfig,
// Only add stack property for stacked or h-stacked chart types
...(["stacked", "h-stacked"].includes(panelSchema.type) && {
stack: `stack-${index}`,
}),
yAxisGroup: index,
};
// Can create different method to get series
return getSeriesObj(
yAxisName,
seriesData,
updatedSeriesConfig,
key,
);
});
} else {
const seriesData = getAxisDataFromKey(yAxis);
const updatedSeriesConfig = {
...seriesConfig,
yAxisGroup: index,
};
return getSeriesObj(yAxisName, seriesData, updatedSeriesConfig, "");
}
})
.flat() || []
);
};
return {
getSeries,
getSeriesData,
getSeriesObj,
getYAxisLabel,
getSeriesLabel,
getSeriesMarkLine,
getAnnotationMarkLine,
getSeriesMarkArea,
getUniqueStackedXAxisValues,
};
}
@@ -0,0 +1,252 @@
// 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 { describe, expect, it, vi, beforeEach } from "vitest";
import {
createTrellisHelpers,
} from "@/utils/dashboard/sql/shared/trellisConfig";
import { getTrellisGrid } from "@/utils/dashboard/calculateGridForSubPlot";
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateWidthText: vi.fn(() => 60),
calculateDynamicNameGap: vi.fn(() => 25),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => ({ value: String(v ?? ""), unit: "" })),
}));
vi.mock("@/utils/dashboard/calculateGridForSubPlot", () => ({
getTrellisGrid: vi.fn(() => ({
gridArray: [{ left: "5%", top: "5%", width: "90%", height: "40%" }],
gridWidth: 800,
gridHeight: 200,
gridNoOfCol: 1,
panelHeight: 400,
})),
}));
vi.mock("@/utils/zincutils", () => ({
deepCopy: vi.fn((v) => JSON.parse(JSON.stringify(v))),
}));
function makeDeps(overrides: Partial<any> = {}): any {
return {
options: {
xAxis: [
{
data: ["Jan", "Feb"],
axisLabel: { rotate: 0, width: 120 },
axisTick: { length: 5 },
nameGap: 25,
name: "",
nameTextStyle: { fontSize: 12 },
},
],
// yAxis is a single object (not array) before trellis is applied;
// updateTrellisConfig wraps it: options.yAxis = [options.yAxis]
yAxis: {
data: [],
axisLabel: { width: 80 },
nameGap: 25,
name: "",
nameTextStyle: { fontSize: 12 },
},
series: [{ name: "s1", data: [1, 2] }],
grid: {},
legend: { show: true },
},
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
trellis: { layout: null },
},
},
chartPanelRef: { value: { offsetWidth: 800, offsetHeight: 400 } },
chartPanelStyle: {},
yAxisKeys: ["y1"],
xAxisKeys: ["x1"],
isHorizontalChart: false,
convertedTimeStampToDataFormat: "",
defaultGrid: { containLabel: true },
getAxisDataFromKey: vi.fn((key: string) => {
if (key === "y1") return [10, 20, 30];
return ["Jan", "Feb"];
}),
getAnnotationMarkLine: vi.fn(() => null),
getSeriesMarkArea: vi.fn(() => null),
largestLabel: vi.fn((data: any) => data?.[0] ?? ""),
...overrides,
};
}
describe("createTrellisHelpers", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset getTrellisGrid to default mock
vi.mocked(getTrellisGrid).mockReturnValue({
gridArray: [{ left: "5%", top: "5%", width: "90%", height: "40%" }],
gridWidth: 800,
gridHeight: 200,
gridNoOfCol: 1,
panelHeight: 400,
} as any);
});
it("createTrellisHelpers returns updateTrellisConfig function", () => {
const deps = makeDeps();
const helpers = createTrellisHelpers(deps);
expect(typeof helpers.updateTrellisConfig).toBe("function");
});
it("updateTrellisConfig does nothing when series array is empty", () => {
const deps = makeDeps({
options: {
xAxis: [
{
data: ["Jan", "Feb"],
axisLabel: { rotate: 0, width: 120 },
axisTick: { length: 5 },
nameGap: 25,
name: "",
nameTextStyle: { fontSize: 12 },
},
],
yAxis: {
data: [],
axisLabel: { width: 80 },
nameGap: 25,
name: "",
nameTextStyle: { fontSize: 12 },
},
series: [],
grid: {},
legend: { show: true },
},
});
const { updateTrellisConfig } = createTrellisHelpers(deps);
// Should return early without modifying grid when series is empty
const originalGrid = deps.options.grid;
updateTrellisConfig();
expect(deps.options.grid).toBe(originalGrid);
expect(getTrellisGrid).not.toHaveBeenCalled();
});
it("updateTrellisConfig does not throw when series is populated", () => {
const deps = makeDeps();
const { updateTrellisConfig } = createTrellisHelpers(deps);
expect(() => updateTrellisConfig()).not.toThrow();
});
it("updateTrellisConfig sets options.grid to gridArray from getTrellisGrid", () => {
const expectedGridArray = [
{ left: "5%", top: "5%", width: "90%", height: "40%" },
];
vi.mocked(getTrellisGrid).mockReturnValue({
gridArray: expectedGridArray,
gridWidth: 800,
gridHeight: 200,
gridNoOfCol: 1,
panelHeight: 400,
} as any);
const deps = makeDeps();
const { updateTrellisConfig } = createTrellisHelpers(deps);
updateTrellisConfig();
expect(deps.options.grid).toBe(expectedGridArray);
});
it("updateTrellisConfig calls getTrellisGrid with panel dimensions", () => {
const deps = makeDeps();
const { updateTrellisConfig } = createTrellisHelpers(deps);
updateTrellisConfig();
expect(getTrellisGrid).toHaveBeenCalledWith(
800, // offsetWidth
400, // offsetHeight
expect.any(Number), // series count
expect.any(Number), // yAxisNameGap
expect.any(Number), // customCols
undefined, // axis_width
);
});
it("updateTrellisConfig uses vertical layout (1 column) when trellis.layout is vertical", () => {
const deps = makeDeps({
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
trellis: { layout: "vertical" },
},
},
});
const { updateTrellisConfig } = createTrellisHelpers(deps);
updateTrellisConfig();
// customCols should be 1 for vertical layout
expect(getTrellisGrid).toHaveBeenCalledWith(
800,
400,
expect.any(Number),
expect.any(Number),
1, // customCols = 1 for vertical
undefined,
);
});
it("updateTrellisConfig uses custom columns when layout is custom and num_of_columns is set", () => {
const deps = makeDeps({
panelSchema: {
type: "bar",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
trellis: { layout: "custom", num_of_columns: 3 },
},
},
});
const { updateTrellisConfig } = createTrellisHelpers(deps);
updateTrellisConfig();
expect(getTrellisGrid).toHaveBeenCalledWith(
800,
400,
expect.any(Number),
expect.any(Number),
3, // customCols = 3
undefined,
);
});
it("updateTrellisConfig throws when chartPanelRef.value is null", () => {
const deps = makeDeps({
chartPanelRef: { value: null },
});
const { updateTrellisConfig } = createTrellisHelpers(deps);
// The function catches internally and falls back - chartPanelRef.value being null
// triggers a throw inside the try block, which is caught internally.
// The catch block tries to access chartPanelRef.value.offsetHeight which will throw again.
// So this should not throw to the caller if caught, but fallback grid is set.
// From reading the source: catch block does: chartPanelStyle.height = chartPanelRef.value.offsetHeight + "px"
// which will throw again when chartPanelRef.value is null.
// That secondary throw is NOT caught, so it propagates to the caller.
expect(() => updateTrellisConfig()).toThrow();
});
});
@@ -0,0 +1,365 @@
// 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 {
calculateWidthText,
calculateDynamicNameGap,
} from "../../chartDimensionUtils";
import { formatUnitValue, getUnitValue } from "../../convertDataIntoUnitValue";
import { getTrellisGrid } from "../../calculateGridForSubPlot";
import { deepCopy } from "@/utils/zincutils";
export interface TrellisDeps {
options: any;
panelSchema: any;
chartPanelRef: any;
chartPanelStyle: any;
yAxisKeys: string[];
xAxisKeys: string[];
isHorizontalChart: boolean;
convertedTimeStampToDataFormat: string;
defaultGrid: any;
getAxisDataFromKey: (key: string) => any[];
getAnnotationMarkLine: () => any;
getSeriesMarkArea: () => any;
largestLabel: (data: any) => any;
}
/**
* Creates the four trellis-layout helper functions.
* All functions close over `deps` so they behave identically to the original
* closures defined inside `convertSQLData`.
*/
export function createTrellisHelpers(deps: TrellisDeps) {
const {
options,
panelSchema,
chartPanelRef,
chartPanelStyle,
yAxisKeys,
xAxisKeys,
isHorizontalChart,
convertedTimeStampToDataFormat,
defaultGrid,
getAxisDataFromKey,
getAnnotationMarkLine,
getSeriesMarkArea,
largestLabel,
} = deps;
const getYAxisNameGap = () => {
const yAxisLabel = isHorizontalChart ? xAxisKeys[0] : yAxisKeys[0];
return (
calculateWidthText(
formatUnitValue(
getUnitValue(
largestLabel(getAxisDataFromKey(yAxisLabel)),
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
),
) + 8
);
};
/**
* This method is used to configure trellis layout for the chart, it will update the yAxis label properties
* @param yAxisNameGap
* @param gridData
*/
const updateYAxisOption = (
yAxisNameGap: number,
gridData: null | any = null,
) => {
const maxYValue = getUnitValue(
Math.max(
...yAxisKeys
.map((key: any) => getAxisDataFromKey(key))
.flat()
.filter((value: any) => typeof value === "number"),
),
"null", // We don't need to add unit, as we are only calculating the max value. Unit will format the value
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
);
// Some units, currencies are formatted with , separator, we need to remove it and convert it to valid number
maxYValue.value = maxYValue.value.replace(",", "");
const [num, decimals] = maxYValue.value.split(".");
// The purpose of addMaxValue is to ensure that the maxYValue.value is in a valid numeric format before updating the yAxis properties. By doing this check, the code ensures that only valid numeric values are used to set the max property of the yAxis, preventing potential errors or invalid configurations.
// Summary
// addMaxValue is a boolean that verifies if maxYValue.value is a valid numeric string.
// It ensures that the max property of the yAxis is only updated with valid numeric values.
const addMaxValue =
maxYValue.value ===
parseInt(num) + (decimals === undefined ? "" : `.${decimals}`);
options.yAxis.forEach((it: any, index: number) => {
if (addMaxValue) {
const rounedMaxValue = Math.ceil(parseFloat(maxYValue.value));
it.max = rounedMaxValue + rounedMaxValue * 0.1; // Add 10% to the max value, to ensure that the max value is not at the top of the chart
}
it.axisLabel = {
formatter: function (value: any) {
return formatUnitValue(
getUnitValue(
value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
},
};
let showAxisLabel = true;
if (isHorizontalChart) {
showAxisLabel = options.yAxis.length - gridData.gridNoOfCol <= index;
} else {
showAxisLabel = index % gridData.gridNoOfCol === 0;
}
// Here we are setting the axis label properties, if showAxisLabel is false then we are hiding the axis label
it.nameGap = showAxisLabel ? (isHorizontalChart ? 25 : yAxisNameGap) : 0;
it.axisLabel.margin = showAxisLabel ? 5 : 0;
it.axisLabel.fontSize = showAxisLabel ? 12 : 10;
it.nameTextStyle.fontSize = 12;
it.axisLabel.show = showAxisLabel;
if (!showAxisLabel) it.name = "";
});
};
/**
* This method is used to configure trellis layout for the chart, it will update the xAxis label properties
* @param yAxisNameGap
* @param gridData
*/
const updateXAxisOption = (
yAxisNameGap: number,
gridData: null | any = null,
) => {
// Update xAxis label properties for each chart xAxis based on the grid position
options.xAxis.forEach((it: any, index: number) => {
let showAxisLabel = false;
if (isHorizontalChart) {
showAxisLabel = index % gridData.gridNoOfCol === 0;
} else {
showAxisLabel = options.xAxis.length - gridData.gridNoOfCol <= index;
}
// Calculate dynamic nameGap for non-horizontal charts when label rotation is applied
const axisLabelRotation = it.axisLabel?.rotate || 0;
const axisLabelWidth = it.axisLabel?.width || 120;
const axisLabelFontSize = showAxisLabel ? 12 : 10;
const axisLabelMargin = showAxisLabel ? 8 : 0;
const dynamicNameGap = isHorizontalChart
? yAxisNameGap
: calculateDynamicNameGap(
axisLabelRotation,
axisLabelWidth,
axisLabelFontSize,
25,
axisLabelMargin,
);
it.axisTick.length = showAxisLabel ? 5 : 0;
it.nameGap = showAxisLabel ? dynamicNameGap : 0;
it.axisLabel.margin = axisLabelMargin;
it.axisLabel.fontSize = axisLabelFontSize;
it.nameTextStyle.fontSize = 12;
it.axisLabel.show = showAxisLabel;
if (!showAxisLabel) it.name = "";
});
};
const updateTrellisConfig = () => {
try {
if (!chartPanelRef?.value) {
throw new Error("Chart panel reference is not available");
}
// Validate panel dimensions
if (
chartPanelRef.value.offsetWidth <= 0 ||
chartPanelRef.value.offsetHeight <= 0
) {
throw new Error("Invalid panel dimensions");
}
if (!options.series?.length) {
return;
}
const yAxisNameGap = getYAxisNameGap();
// If the trellis layout is custom, we need to calculate the number of columns in panel
let customCols = -1;
if (
panelSchema.config.trellis?.layout === "custom" &&
panelSchema.config.trellis?.num_of_columns
) {
if (panelSchema.config.trellis.num_of_columns <= 0) {
throw new Error("Number of columns must be positive");
}
customCols = panelSchema.config.trellis?.num_of_columns;
}
if (panelSchema.config.trellis?.layout === "vertical") {
customCols = 1;
}
// When group_by_y_axis is enabled, create separate charts for each breakdown category,
// with each chart containing multiple series (one per y-axis metric)
const group_by_y_axis = panelSchema.config.trellis?.group_by_y_axis;
// Calculate grid layout for trellis charts
const gridData = getTrellisGrid(
chartPanelRef.value.offsetWidth,
chartPanelRef.value.offsetHeight,
group_by_y_axis
? options.series.length / yAxisKeys.length
: options.series.length,
yAxisNameGap,
customCols,
panelSchema.config?.axis_width,
);
options.grid = gridData.gridArray;
chartPanelStyle.height = gridData.panelHeight + "px";
// Update axes configuration for trellis layout
options.xAxis = options.xAxis.slice(0, 1);
options.yAxis = [options.yAxis];
options.title = [];
// Store original series
const originalSeries = [...options.series];
options.series = [];
let seriesUniqueIndex = 0;
// Configure each series with its corresponding grid index
originalSeries.forEach((series: any, index: number) => {
let gridIndex = index;
let existingSeriesIndex = -1;
if (group_by_y_axis) {
// Find if there's already a series with the same originalSeriesName
existingSeriesIndex = options.series.findIndex(
(existingSeries: any) =>
existingSeries.originalSeriesName === series.originalSeriesName,
);
// Use existing gridIndex if found, otherwise use current index
gridIndex =
existingSeriesIndex !== -1
? options.series[existingSeriesIndex].gridIndex
: seriesUniqueIndex++;
}
// Add the original series
const updatedSeries = {
...series,
xAxisIndex: gridIndex,
yAxisIndex: gridIndex,
gridIndex: gridIndex,
zlevel: 2,
};
options.series.push(updatedSeries);
if (existingSeriesIndex === -1) {
options.series.push({
type: "line",
xAxisIndex: gridIndex,
yAxisIndex: gridIndex,
gridIndex: gridIndex,
data: [[convertedTimeStampToDataFormat, null]],
markArea: getSeriesMarkArea(),
markLine: getAnnotationMarkLine(),
zlevel: 1,
});
}
if (gridIndex > 0 && existingSeriesIndex === -1) {
options.xAxis.push({
...deepCopy(options.xAxis[0]),
gridIndex: gridIndex,
});
options.yAxis.push({
...deepCopy(options.yAxis[0]),
gridIndex: gridIndex,
});
}
// Add title for each chart
if (existingSeriesIndex === -1) {
options.title.push({
text: group_by_y_axis
? series.originalSeriesName || series.name
: series.name,
textStyle: {
fontSize: 12,
width:
parseInt(gridData.gridArray[gridIndex].width) *
(chartPanelRef.value.offsetWidth / 100) -
8,
overflow: "truncate",
ellipsis: "...",
},
top:
parseFloat(gridData.gridArray[gridIndex].top) -
(20 / (gridData.panelHeight as number)) * 100 +
"%",
left: gridData.gridArray[gridIndex].left,
});
}
});
updateYAxisOption(yAxisNameGap, gridData);
updateXAxisOption(yAxisNameGap, gridData);
options.legend.show = false;
} catch (err: any) {
console.error("Trellis configuration failed:", {
error: err?.message,
code: err?.code,
details: err?.details,
}); // Fallback to default single grid configuration
options.grid = defaultGrid;
options.xAxis = options.xAxis.slice(0, 1);
options.yAxis = [options.yAxis];
// Set default chartPanelStyle height
chartPanelStyle.height = chartPanelRef.value.offsetHeight + "px";
}
};
return {
updateTrellisConfig,
getYAxisNameGap,
updateYAxisOption,
updateXAxisOption,
};
}
@@ -0,0 +1,72 @@
// 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/>.
/**
* SQLContext
*
* Carries all shared, computed state that is built once (before the chart-type
* switch) and consumed by every chart-specific handler. Chart handlers receive
* this object, mutate `ctx.options` as needed, and return.
*/
export interface SQLContext {
// Raw inputs
panelSchema: any;
searchQueryData: any;
store: any;
chartPanelRef: any;
hoveredSeriesState: any;
chartPanelStyle: any;
annotations: any;
metadata: any;
resultMetaData: any;
// Computed axis keys
xAxisKeys: string[];
yAxisKeys: string[];
zAxisKeys: string[];
breakDownKeys: string[];
// Processed data
missingValueData: any[];
extras: any;
// Derived scalars
showGridlines: boolean;
hasTimestampField: boolean;
isHorizontalChart: boolean;
dynamicXAxisNameGap: number;
convertedTimeStampToDataFormat: string;
defaultSeriesProps: any;
chartMin: any;
chartMax: any;
defaultGrid: any;
// Mutable ECharts options object (mutated by chart handlers)
options: any;
// Closure-based helpers
/** Returns axis data array for the given field alias. */
getAxisDataFromKey: (key: string) => any[];
/** Builds series array for the current chart type. */
getSeries: (seriesConfig?: Record<string, any>) => any[];
/** Returns annotation mark-line config. */
getAnnotationMarkLine: () => any;
/** Returns annotation mark-area config. */
getSeriesMarkArea: () => any;
/** Calculates pie/donut chart radius accounting for legend space. */
getPieChartRadius: (seriesData?: any[]) => number;
/** Applies trellis grid layout to options (only for trellis chart types). */
updateTrellisConfig: () => void;
}
@@ -0,0 +1,271 @@
// 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 { describe, expect, it } from "vitest";
import { getPropsByChartTypeForSeries } from "@/utils/dashboard/sqlChartSeriesProps";
const makeSchema = (type: string, config: any = {}) => ({ type, config });
describe("sqlChartSeriesProps - getPropsByChartTypeForSeries", () => {
describe("bar chart", () => {
it("returns bar type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("bar")).type).toBe("bar");
});
it("includes emphasis focus series", () => {
const result = getPropsByChartTypeForSeries(makeSchema("bar")) as any;
expect(result.emphasis.focus).toBe("series");
});
});
describe("line chart", () => {
it("returns line type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("line")).type).toBe("line");
});
it("uses smooth interpolation by default (null)", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: null })) as any;
expect(result.smooth).toBe(true);
});
it("uses smooth interpolation when config is 'smooth'", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "smooth" })) as any;
expect(result.smooth).toBe(true);
});
it("does not use smooth for step-start", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "step-start" })) as any;
expect(result.smooth).toBe(false);
});
it("returns step for step-start", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "step-start" })) as any;
expect(result.step).toBe("start");
});
it("returns step for step-end", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "step-end" })) as any;
expect(result.step).toBe("end");
});
it("returns step for step-middle", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "step-middle" })) as any;
expect(result.step).toBe("middle");
});
it("returns false for step when not step interpolation", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_interpolation: "smooth" })) as any;
expect(result.step).toBe(false);
});
it("uses show_symbol from config", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { show_symbol: true })) as any;
expect(result.showSymbol).toBe(true);
});
it("defaults showSymbol to false", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line")) as any;
expect(result.showSymbol).toBe(false);
});
it("uses line_thickness from config", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line", { line_thickness: 3 })) as any;
expect(result.lineStyle.width).toBe(3);
});
it("defaults line thickness to 1.5", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line")) as any;
expect(result.lineStyle.width).toBe(1.5);
});
it("areaStyle is null for line (not area)", () => {
const result = getPropsByChartTypeForSeries(makeSchema("line")) as any;
expect(result.areaStyle).toBeNull();
});
});
describe("scatter chart", () => {
it("returns scatter type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("scatter")).type).toBe("scatter");
});
it("has symbolSize 5", () => {
const result = getPropsByChartTypeForSeries(makeSchema("scatter")) as any;
expect(result.symbolSize).toBe(5);
});
});
describe("pie chart", () => {
it("returns pie type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("pie")).type).toBe("pie");
});
it("has avoidLabelOverlap false", () => {
const result = getPropsByChartTypeForSeries(makeSchema("pie")) as any;
expect(result.avoidLabelOverlap).toBe(false);
});
it("has radius 50%", () => {
const result = getPropsByChartTypeForSeries(makeSchema("pie")) as any;
expect(result.radius).toBe("50%");
});
});
describe("donut chart", () => {
it("returns pie type (donut uses pie)", () => {
expect(getPropsByChartTypeForSeries(makeSchema("donut")).type).toBe("pie");
});
it("has two-element radius array", () => {
const result = getPropsByChartTypeForSeries(makeSchema("donut")) as any;
expect(Array.isArray(result.radius)).toBe(true);
expect(result.radius).toHaveLength(2);
});
it("has avoidLabelOverlap false", () => {
const result = getPropsByChartTypeForSeries(makeSchema("donut")) as any;
expect(result.avoidLabelOverlap).toBe(false);
});
});
describe("h-bar chart", () => {
it("returns bar type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("h-bar")).type).toBe("bar");
});
});
describe("area chart", () => {
it("returns line type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("area")).type).toBe("line");
});
it("has areaStyle object", () => {
const result = getPropsByChartTypeForSeries(makeSchema("area")) as any;
expect(result.areaStyle).toBeDefined();
expect(result.areaStyle).not.toBeNull();
});
it("uses smooth interpolation by default", () => {
const result = getPropsByChartTypeForSeries(makeSchema("area")) as any;
expect(result.smooth).toBe(true);
});
it("handles step interpolation", () => {
const result = getPropsByChartTypeForSeries(makeSchema("area", { line_interpolation: "step-start" })) as any;
expect(result.step).toBe("start");
});
});
describe("stacked chart", () => {
it("returns bar type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("stacked")).type).toBe("bar");
});
});
describe("heatmap chart", () => {
it("returns heatmap type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("heatmap")).type).toBe("heatmap");
});
it("shows label", () => {
const result = getPropsByChartTypeForSeries(makeSchema("heatmap")) as any;
expect(result.label.show).toBe(true);
});
});
describe("area-stacked chart", () => {
it("returns line type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("area-stacked")).type).toBe("line");
});
it("has stack Total", () => {
const result = getPropsByChartTypeForSeries(makeSchema("area-stacked")) as any;
expect(result.stack).toBe("Total");
});
it("has areaStyle", () => {
const result = getPropsByChartTypeForSeries(makeSchema("area-stacked")) as any;
expect(result.areaStyle).toBeDefined();
});
});
describe("metric chart", () => {
it("returns custom type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("metric")).type).toBe("custom");
});
it("has polar coordinateSystem", () => {
const result = getPropsByChartTypeForSeries(makeSchema("metric")) as any;
expect(result.coordinateSystem).toBe("polar");
});
});
describe("h-stacked chart", () => {
it("returns bar type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("h-stacked")).type).toBe("bar");
});
});
describe("gauge chart", () => {
it("returns gauge type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("gauge")).type).toBe("gauge");
});
it("has startAngle 205", () => {
const result = getPropsByChartTypeForSeries(makeSchema("gauge")) as any;
expect(result.startAngle).toBe(205);
});
it("has endAngle -25", () => {
const result = getPropsByChartTypeForSeries(makeSchema("gauge")) as any;
expect(result.endAngle).toBe(-25);
});
it("shows progress", () => {
const result = getPropsByChartTypeForSeries(makeSchema("gauge")) as any;
expect(result.progress.show).toBe(true);
});
it("hides pointer", () => {
const result = getPropsByChartTypeForSeries(makeSchema("gauge")) as any;
expect(result.pointer.show).toBe(false);
});
});
describe("default / unknown type", () => {
it("returns bar for unknown type", () => {
expect(getPropsByChartTypeForSeries(makeSchema("unknown")).type).toBe("bar");
});
it("returns bar for empty string", () => {
expect(getPropsByChartTypeForSeries(makeSchema("")).type).toBe("bar");
});
});
describe("all chart types return valid objects", () => {
const types = [
"bar", "line", "scatter", "pie", "donut", "h-bar",
"area", "stacked", "heatmap", "area-stacked", "metric", "h-stacked", "gauge",
];
types.forEach((type) => {
it(`returns object with type for '${type}'`, () => {
const result = getPropsByChartTypeForSeries(makeSchema(type));
expect(result).toBeDefined();
expect(result).toHaveProperty("type");
});
});
});
});
@@ -0,0 +1,202 @@
// 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/>.
/**
* Retrieves the ECharts series properties for a given SQL chart type.
*
* @param {any} panelSchema - The panel schema object containing chart type and config.
* @return {Object} - The series properties for the given chart type.
*/
export const getPropsByChartTypeForSeries = (panelSchema: any) => {
switch (panelSchema.type) {
case "bar":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "line":
return {
type: "line",
emphasis: { focus: "series" },
smooth:
panelSchema.config?.line_interpolation === "smooth" ||
panelSchema.config?.line_interpolation == null,
step: ["step-start", "step-end", "step-middle"].includes(
panelSchema.config?.line_interpolation,
)
? // TODO: replace this with type integrations
panelSchema.config.line_interpolation.replace("step-", "")
: false,
showSymbol: panelSchema.config?.show_symbol ?? false,
areaStyle: null,
lineStyle: { width: panelSchema.config?.line_thickness ?? 1.5 },
};
case "scatter":
return {
type: "scatter",
emphasis: { focus: "series" },
symbolSize: 5,
};
case "pie":
return {
type: "pie",
avoidLabelOverlap: false,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
label: {
show: true,
},
},
label: {
show: true,
},
radius: "50%",
lineStyle: { width: 1.5 },
};
case "donut":
return {
type: "pie",
radius: ["25%", "50%"],
avoidLabelOverlap: false,
label: {
show: true,
// position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 12,
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
lineStyle: { width: 1.5 },
};
case "h-bar":
return {
type: "bar",
emphasis: { focus: "series" },
lineStyle: { width: 1.5 },
};
case "area":
return {
type: "line",
smooth:
panelSchema.config?.line_interpolation === "smooth" ||
panelSchema.config?.line_interpolation == null,
step: ["step-start", "step-end", "step-middle"].includes(
panelSchema.config?.line_interpolation,
)
? panelSchema.config.line_interpolation.replace("step-", "")
: false,
emphasis: { focus: "series" },
areaStyle: {},
showSymbol: panelSchema.config?.show_symbol ?? false,
lineStyle: { width: panelSchema.config?.line_thickness ?? 1.5 },
};
case "stacked":
return {
type: "bar",
emphasis: {
focus: "series",
},
lineStyle: { width: 1.5 },
};
case "heatmap":
return {
type: "heatmap",
label: {
show: true,
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, 0.5)",
},
},
lineStyle: { width: 1.5 },
};
case "area-stacked":
return {
type: "line",
smooth:
panelSchema.config?.line_interpolation === "smooth" ||
panelSchema.config?.line_interpolation == null,
step: ["step-start", "step-end", "step-middle"].includes(
panelSchema.config?.line_interpolation,
)
? panelSchema.config.line_interpolation.replace("step-", "")
: false,
stack: "Total",
areaStyle: {},
emphasis: {
focus: "series",
},
showSymbol: panelSchema.config?.show_symbol ?? false,
lineStyle: { width: panelSchema.config?.line_thickness ?? 1.5 },
};
case "metric":
return {
type: "custom",
coordinateSystem: "polar",
};
case "h-stacked":
return {
type: "bar",
emphasis: {
focus: "series",
},
lineStyle: { width: 1.5 },
};
case "gauge":
return {
type: "gauge",
startAngle: 205,
endAngle: -25,
progress: {
show: true,
width: 10,
},
pointer: {
show: false,
},
axisLine: {
lineStyle: {
width: 10,
},
},
axisTick: {
show: false,
},
splitLine: {
show: false,
},
axisLabel: {
show: false,
},
};
default:
return {
type: "bar",
};
}
};
@@ -0,0 +1,308 @@
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
import { fillMissingValues } from "./sqlMissingValueFiller";
// Mock date-fns-tz: toZonedTime passes through the date unchanged
vi.mock("date-fns-tz", () => ({
toZonedTime: vi.fn((date: Date) => date),
}));
// Mock date-fns: format always returns a constant formatted time
vi.mock("date-fns", () => ({
format: vi.fn(() => "2024-01-15T10:00:00"),
}));
// Mock datetimeStartPoint: dateBin returns startTime (the second arg)
vi.mock("@/utils/dashboard/datetimeStartPoint", () => ({
dateBin: vi.fn((_interval: any, startTime: Date) => startTime),
}));
// Mock dateTimeUtils: isTimeSeries returns true when the value is an ISO string
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
isTimeSeries: vi.fn((arr: any[]) =>
typeof arr[0] === "string" && /\d{4}-\d{2}-\d{2}T/.test(arr[0]),
),
}));
// Mock aliasUtils: getDataValue just does a plain property lookup
vi.mock("@/utils/dashboard/aliasUtils", () => ({
getDataValue: vi.fn((obj: any, key: any) => obj?.[key]),
}));
// TS = 1705323600000000 microseconds → ms = 1705323600000
// startTime = endTime = new Date(1705323600000)
// dateBin mock returns startTime, so currentTime === endTime → loop runs exactly once
// interval = 60 seconds, intervalMillis = 60000 ms → after first iteration currentTime > endTime
const TS_MICROS = 1705323600000000;
const TS_MS = 1705323600000; // TS_MICROS / 1000
const makeMetadata = (overrides?: any) => ({
queries: [
{
startTime: TS_MICROS,
endTime: TS_MICROS,
...overrides,
},
],
});
const makeResultMetaData = (interval: number | undefined) =>
interval !== undefined ? [{ histogram_interval: interval }] : [{}];
const makePanelSchema = (type: string) => ({ type });
describe("fillMissingValues", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// -------------------------------------------------------------------------
// Early-exit / deep-copy tests
// -------------------------------------------------------------------------
it("returns a deep copy when resultMetaData has no histogram_interval", () => {
const processedData = [{ ts: "2024-01-15T10:00:00", value: 42 }];
const result = fillMissingValues(
processedData,
makePanelSchema("line"),
makeResultMetaData(undefined),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
// Ensure it's a deep copy, not the same reference
expect(result).not.toBe(processedData);
expect(result[0]).not.toBe(processedData[0]);
});
it("returns a deep copy when metadata has no queries", () => {
const processedData = [{ ts: "2024-01-15T10:00:00", value: 10 }];
const result = fillMissingValues(
processedData,
makePanelSchema("line"),
makeResultMetaData(60),
{ queries: undefined },
["ts"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
expect(result).not.toBe(processedData);
});
it("returns a deep copy for unsupported chart type: gauge", () => {
const processedData = [{ ts: "2024-01-15T10:00:00", value: 5 }];
const result = fillMissingValues(
processedData,
makePanelSchema("gauge"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
expect(result).not.toBe(processedData);
});
it("returns a deep copy for unsupported chart type: metric", () => {
const processedData = [{ ts: "2024-01-15T10:00:00", value: 99 }];
const result = fillMissingValues(
processedData,
makePanelSchema("metric"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
expect(result).not.toBe(processedData);
});
it("returns a deep copy for unsupported chart type: pie", () => {
const processedData = [{ ts: "2024-01-15T10:00:00", value: 7 }];
const result = fillMissingValues(
processedData,
makePanelSchema("pie"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
expect(result).not.toBe(processedData);
});
it("returns a deep copy for supported type (line) when no time-based key found", () => {
// All values are non-ISO strings so isTimeSeries returns false for each key
const processedData = [{ category: "foo", value: 3 }];
const result = fillMissingValues(
processedData,
makePanelSchema("line"),
makeResultMetaData(60),
makeMetadata(),
["category"],
["value"],
[],
[],
null,
);
expect(result).toEqual(processedData);
expect(result).not.toBe(processedData);
});
// -------------------------------------------------------------------------
// Gap-filling tests (no breakdown keys)
// -------------------------------------------------------------------------
it("fills data for simple time-series when data exists at the current time slot", () => {
// format mock always returns "2024-01-15T10:00:00"
// processedData has ts = "2024-01-15T10:00:00" → key matches → data pushed as-is
const processedData = [{ ts: "2024-01-15T10:00:00", value: 100 }];
const result = fillMissingValues(
processedData,
makePanelSchema("line"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ ts: "2024-01-15T10:00:00", value: 100 });
});
it("inserts null/noValue entry for a missing time slot (no breakdown)", () => {
// processedData has ts = "2024-01-15T09:00:00" (different from "2024-01-15T10:00:00")
// The map key for that entry is "2024-01-15T09:00:00" but format mock returns
// "2024-01-15T10:00:00" → no match → null entry created
const processedData = [{ ts: "2024-01-15T09:00:00", value: 50 }];
const result = fillMissingValues(
processedData,
makePanelSchema("line"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
null,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ ts: "2024-01-15T10:00:00", value: null });
});
it("uses the noValueConfigOption value when inserting null entries (no breakdown)", () => {
const processedData = [{ ts: "2024-01-15T09:00:00", value: 50 }];
const result = fillMissingValues(
processedData,
makePanelSchema("area"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
[],
0,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ ts: "2024-01-15T10:00:00", value: 0 });
});
// -------------------------------------------------------------------------
// Gap-filling tests (with breakdown keys)
// -------------------------------------------------------------------------
it("fills data with breakdown keys when existing data is found", () => {
// xAxisKeys = ["ts"], breakDownKeys = ["service"]
// uniqueKey = "service" (first xAxisKeysWithoutTimeStamp is undefined, so falls to breakdown)
// map key = "2024-01-15T10:00:00-frontend" → currentData found → pushed directly
const processedData = [
{ ts: "2024-01-15T10:00:00", service: "frontend", value: 200 },
];
const result = fillMissingValues(
processedData,
makePanelSchema("stacked"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
["service"],
null,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
ts: "2024-01-15T10:00:00",
service: "frontend",
value: 200,
});
});
it("inserts null entry with breakdown when data is missing for the time slot", () => {
// processedData has ts "2024-01-15T09:00:00" → map key "2024-01-15T09:00:00-backend"
// format mock returns "2024-01-15T10:00:00" → lookup key "2024-01-15T10:00:00-backend" → not found
// → null entry inserted with noValueConfigOption for non-time, non-uniqueKey fields
const processedData = [
{ ts: "2024-01-15T09:00:00", service: "backend", value: 75 },
];
const result = fillMissingValues(
processedData,
makePanelSchema("bar"),
makeResultMetaData(60),
makeMetadata(),
["ts"],
["value"],
[],
["service"],
null,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
ts: "2024-01-15T10:00:00",
service: "backend",
value: null,
});
});
});
@@ -0,0 +1,174 @@
// 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 { toZonedTime } from "date-fns-tz";
import { dateBin } from "@/utils/dashboard/datetimeStartPoint";
import { format } from "date-fns";
import { isTimeSeries } from "./dateTimeUtils";
import { getDataValue } from "./aliasUtils";
/**
* 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,
): 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);
// Convert interval to milliseconds
const intervalMillis = interval * 1000;
// 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));
}
// Extract and process metaDataEndTime
const metaDataEndTime = metadata?.queries[0]?.endTime?.toString() ?? 0;
const endTime = new Date(parseInt(metaDataEndTime) / 1000);
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 filledData: any = [];
let currentTime = binnedDate;
// Create a map of existing data
const searchDataMap = new Map();
processedData?.forEach((d: any) => {
const key =
xAxisKeysWithoutTimeStamp.length > 0 ||
breakdownAxisKeysWithoutTimeStamp.length > 0
? `${getDataValue(d, timeKey)}-${getDataValue(d, uniqueKey)}`
: `${getDataValue(d, timeKey)}`;
searchDataMap.set(key, d);
});
while (currentTime <= endTime) {
const currentFormattedTime = format(
toZonedTime(currentTime, "UTC"),
"yyyy-MM-dd'T'HH:mm:ss",
);
if (
xAxisKeysWithoutTimeStamp.length === 0 &&
breakdownAxisKeysWithoutTimeStamp.length === 0
) {
const key = `${currentFormattedTime}`;
const currentData = searchDataMap.get(key);
const nullEntry: any = {
[timeKey]: currentFormattedTime,
...currentData,
};
if (!currentData) {
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);
}
return filledData;
};
@@ -0,0 +1,291 @@
// 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 { describe, expect, it } from "vitest";
import { processData } from "@/utils/dashboard/sqlProcessData";
const makeStore = (maxDashboardSeries = 100) => ({
state: {
zoConfig: {
max_dashboard_series: maxDashboardSeries,
},
},
});
const makeSchema = (topResults: number | null = null, topResultsOthers = false) => ({
config: {
top_results: topResults,
top_results_others: topResultsOthers,
},
});
describe("sqlProcessData - processData", () => {
describe("empty/invalid input", () => {
it("returns empty array for empty data", () => {
const result = processData([], makeSchema(), makeStore(), [], [], [], {});
expect(result).toEqual([]);
});
it("returns empty array when data[0] is not an array", () => {
const result = processData([{}], makeSchema(), makeStore(), [], [], [], {});
expect(result).toEqual([]);
});
it("returns empty array for null/undefined inner array", () => {
const result = processData([null as any], makeSchema(), makeStore(), [], [], [], {});
expect(result).toEqual([]);
});
});
describe("no breakdown keys", () => {
it("returns inner array as-is when no breakdown keys", () => {
const innerData = [
{ x: "a", y: 10 },
{ x: "b", y: 20 },
];
const result = processData([innerData], makeSchema(), makeStore(), ["y"], [], ["x"], {});
expect(result).toEqual(innerData);
});
it("returns inner array regardless of top_results when no breakdown", () => {
const innerData = [{ x: "a", y: 10 }];
const result = processData([innerData], makeSchema(5), makeStore(), ["y"], [], ["x"], {});
expect(result).toEqual(innerData);
});
});
describe("with breakdown keys", () => {
const innerData = [
{ category: "A", time: "2024-01", value: 100 },
{ category: "A", time: "2024-02", value: 200 },
{ category: "B", time: "2024-01", value: 50 },
{ category: "B", time: "2024-02", value: 150 },
{ category: "C", time: "2024-01", value: 10 },
];
it("returns all items when no top_results limit", () => {
const result = processData(
[innerData],
makeSchema(null, false),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
// All items should be in result since no top_results constraint (limited by max_dashboard_series=100)
expect(result.length).toBe(5);
});
it("filters to top N breakdown values based on sum", () => {
// A has total 300, B has total 200, C has total 10
// top_results=2 -> only A and B
const result = processData(
[innerData],
makeSchema(2, false),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
const categories = new Set(result.map((r: any) => r.category));
expect(categories.has("A")).toBe(true);
expect(categories.has("B")).toBe(true);
expect(categories.has("C")).toBe(false);
});
it("includes others when top_results_others is true", () => {
const result = processData(
[innerData],
makeSchema(2, true),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
const categories = result.map((r: any) => r.category);
expect(categories).toContain("others");
});
it("aggregates others by x-axis key", () => {
const result = processData(
[innerData],
makeSchema(2, true),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
const othersEntry = result.find((r: any) => r.category === "others" && r.time === "2024-01");
expect(othersEntry).toBeDefined();
expect(othersEntry?.value).toBe(10); // C's value at 2024-01
});
it("handles null breakdown values by treating them as empty string", () => {
const dataWithNull = [
{ category: null, time: "2024-01", value: 50 },
{ category: "A", time: "2024-01", value: 100 },
];
const result = processData(
[dataWithNull],
makeSchema(null, false),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
expect(result.length).toBe(2);
});
it("handles undefined breakdown values by treating them as empty string", () => {
const dataWithUndefined = [
{ time: "2024-01", value: 50 }, // no category key
{ category: "A", time: "2024-01", value: 100 },
];
const result = processData(
[dataWithUndefined],
makeSchema(null, false),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
expect(result.length).toBe(2);
});
it("respects max_dashboard_series as upper bound", () => {
const bigData = Array.from({ length: 20 }, (_, i) => ({
category: `cat${i}`,
time: "2024-01",
value: 100 - i,
}));
const result = processData(
[bigData],
makeSchema(null, false),
makeStore(5), // max 5 series
["value"],
["category"],
["time"],
{},
);
const categories = new Set(result.map((r: any) => r.category));
expect(categories.size).toBeLessThanOrEqual(5);
});
it("divides limit by number of y-axes when multiple y-axes and breakdown", () => {
const bigData = Array.from({ length: 20 }, (_, i) => ({
category: `cat${i}`,
time: "2024-01",
value1: 100 - i,
value2: i,
}));
const result = processData(
[bigData],
makeSchema(null, false),
makeStore(10), // 10/2 = 5 per axis
["value1", "value2"], // 2 y-axes
["category"],
["time"],
{},
);
const categories = new Set(result.map((r: any) => r.category));
expect(categories.size).toBeLessThanOrEqual(5);
});
it("sets warning message when series count exceeds limit", () => {
const bigData = Array.from({ length: 10 }, (_, i) => ({
category: `cat${i}`,
time: "2024-01",
value: 100 - i,
}));
const extras: any = {};
processData(
[bigData],
makeSchema(null, false),
makeStore(5), // max 5, but 10 available
["value"],
["category"],
["time"],
extras,
);
expect(extras.limitNumberOfSeriesWarningMessage).toBeDefined();
});
it("does not set warning when within limits", () => {
const smallData = [
{ category: "A", time: "2024-01", value: 10 },
{ category: "B", time: "2024-01", value: 20 },
];
const extras: any = {};
processData(
[smallData],
makeSchema(null, false),
makeStore(100),
["value"],
["category"],
["time"],
extras,
);
expect(extras.limitNumberOfSeriesWarningMessage).toBeUndefined();
});
it("uses min of top_results and max_dashboard_series", () => {
const bigData = Array.from({ length: 20 }, (_, i) => ({
category: `cat${i}`,
time: "2024-01",
value: 100 - i,
}));
const result = processData(
[bigData],
makeSchema(3, false), // top_results=3, but max_dashboard_series=10
makeStore(10),
["value"],
["category"],
["time"],
{},
);
const categories = new Set(result.map((r: any) => r.category));
expect(categories.size).toBeLessThanOrEqual(3);
});
});
describe("sorting", () => {
it("selects top series by highest total y-axis value", () => {
const innerData = [
{ category: "Low", time: "2024-01", value: 5 },
{ category: "High", time: "2024-01", value: 1000 },
{ category: "Medium", time: "2024-01", value: 50 },
];
const result = processData(
[innerData],
makeSchema(1, false),
makeStore(100),
["value"],
["category"],
["time"],
{},
);
const categories = result.map((r: any) => r.category);
expect(categories).toContain("High");
expect(categories).not.toContain("Low");
expect(categories).not.toContain("Medium");
});
});
});
+186
View File
@@ -0,0 +1,186 @@
// 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 { getDataValue } from "./aliasUtils";
/**
* Process the SQL data and convert it into a format suitable for the echarts
* library.
*
* This function takes in the raw data returned from the SQL query and the
* panel schema, and returns a processed data array that can be fed into the
* echarts library.
*
* Here's a step-by-step breakdown of what this function does:
*
* 1. It checks if the data is empty or if the first item in the array is not
* an object. If either condition is true, it returns an empty array.
*
* 2. It extracts the top_results and top_results_others values from the
* panel schema config.
*
* 3. If top_results is not enabled, it simply returns the inner data array
* without any modifications.
*
* 4. It extracts the breakdown key, y-axis key, and x-axis key from the
* panel schema.
*
* 5. It aggregates the y-axis values by breakdown, ignoring items without a
* breakdown key. This is done using the reduce() method.
*
* 6. It sorts the breakdown object by value in descending order and extracts
* the top keys based on the configured number of top results. This is done
* using the Object.entries() and sort() methods.
*
* 7. It initializes an empty result array and an empty others object.
*
* 8. It loops through the inner data array and checks if the breakdown value
* is in the top keys. If it is, it adds the item to the result array. If it's
* not, and top_results_others is enabled, it adds the item to the others
* object.
*
* 9. If top_results_others is enabled, it loops through the others object and
* adds the aggregated values to the result array.
*
* 10. Finally, it returns the result array.
*
* @param {any[]} data - The data returned from the SQL query.
* @param {any} panelSchema - The schema of the panel.
* @param {any} store - The Vuex store object.
* @param {string[]} yAxisKeys - Array of y-axis key aliases.
* @param {string[]} breakDownKeys - Array of breakdown key aliases.
* @param {string[]} xAxisKeys - Array of x-axis key aliases.
* @param {any} extras - Mutable extras object for warning messages.
* @returns {any[]} - The processed data array.
*/
export const processData = (
data: any[],
panelSchema: any,
store: any,
yAxisKeys: string[],
breakDownKeys: string[],
xAxisKeys: string[],
extras: any,
): any[] => {
if (!data.length || !Array.isArray(data[0])) {
return [];
}
const { top_results, top_results_others } = panelSchema.config;
// get the limit series from the config
// if top_results is enabled then use the top_results value
// otherwise use the max_dashboard_series value
let limitSeries = top_results
? (Math.min(
top_results,
store.state?.zoConfig?.max_dashboard_series ?? 100,
) ?? 100)
: (store.state?.zoConfig?.max_dashboard_series ?? 100);
// For multi y-axis charts, divide the limit by number of y-axes
// to keep total series count at or below max_dashboard_series
// This applies when there are multiple y-axes AND breakdown fields
if (yAxisKeys.length > 1 && breakDownKeys.length > 0) {
limitSeries = Math.floor(limitSeries / yAxisKeys.length);
}
const innerDataArray = data[0];
if (!breakDownKeys.length) {
return innerDataArray;
}
const breakdownKey = breakDownKeys[0];
const yAxisKey = yAxisKeys[0];
const xAxisKey = xAxisKeys[0];
// Step 1: Aggregate y_axis values by breakdown, ensuring missing values are set to empty string
const breakdown = innerDataArray.reduce((acc: any, item: any) => {
let breakdownValue = getDataValue(item, breakdownKey);
// Convert null, undefined, and empty string to a default empty string
if (
breakdownValue == null ||
breakdownValue === "" ||
breakdownValue === undefined
) {
breakdownValue = "";
}
const yAxisValue = getDataValue(item, yAxisKey);
acc[breakdownValue] = (acc[breakdownValue] || 0) + (+yAxisValue || 0);
return acc;
}, {});
// Step 2: Sort and extract the top keys based on the configured number of top results
const allKeys = Object.entries(breakdown).sort(
([, a]: any, [, b]: any) => b - a,
);
// if top_results is enabled and the number of unique breakdown values is greater than the limit, add a warning message
// if top_results is not enabled and the number of unique breakdown values is greater than the max_dashboard_series, add a warning message
if (
(top_results &&
top_results > (store.state?.zoConfig?.max_dashboard_series ?? 100) &&
allKeys.length > top_results) ||
(!top_results &&
allKeys.length > (store.state?.zoConfig?.max_dashboard_series ?? 100))
) {
extras.limitNumberOfSeriesWarningMessage =
"Limiting the displayed series to ensure optimal performance";
}
const topKeys = allKeys.slice(0, limitSeries).map(([key]) => key);
// Step 3: Initialize result array and others object for aggregation
const resultArray: any[] = [];
const othersObj: any = {};
innerDataArray.forEach((item: any) => {
let breakdownValue = getDataValue(item, breakdownKey);
// Ensure missing breakdown values are treated as empty strings
if (
breakdownValue == null ||
breakdownValue === "" ||
breakdownValue === undefined
) {
breakdownValue = "";
}
if (topKeys.includes(breakdownValue.toString())) {
resultArray.push({ ...item, [breakdownKey]: breakdownValue });
} else if (top_results_others) {
const xAxisValue = String(getDataValue(item, xAxisKey));
othersObj[xAxisValue] =
(othersObj[xAxisValue] || 0) + (+getDataValue(item, yAxisKey) || 0);
}
});
// Step 4: Add 'others' aggregation to the result array if enabled
if (top_results_others) {
Object.entries(othersObj).forEach(([xAxisValue, yAxisValue]) => {
resultArray.push({
[breakdownKey]: "others",
[xAxisKey]: xAxisValue,
[yAxisKey]: yAxisValue,
});
});
}
return resultArray;
};
@@ -0,0 +1,314 @@
// 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 { describe, it, expect, vi, beforeEach } from "vitest";
import {
applyAutoSQLTimeSeries,
applyCustomSQLTimeSeries,
} from "@/utils/dashboard/sqlTimeSeriesConverter";
vi.mock("date-fns-tz", () => ({
toZonedTime: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
formatUnitValue: vi.fn((v) => String(v ?? "")),
getUnitValue: vi.fn((v) => v),
}));
vi.mock("@/utils/dashboard/chartDimensionUtils", () => ({
calculateDynamicNameGap: vi.fn(() => 25),
}));
vi.mock("@/utils/dashboard/dateTimeUtils", () => ({
formatDate: vi.fn(() => "2024-01-15 10:00:00"),
isTimeSeries: vi.fn(() => false),
isTimeStamp: vi.fn(() => false),
}));
// Import the mocked module so we can control return values per test
import * as dateTimeUtils from "@/utils/dashboard/dateTimeUtils";
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
function makeOptions(xAxisData: any[] = ["2024-01-15 10:00:00", "2024-01-15 11:00:00"]): any {
return {
xAxis: [
{
data: xAxisData,
type: "category",
axisLabel: { rotate: 30, overflow: "truncate", width: 80 },
name: "",
},
],
series: [{ name: "s1", data: [10, 20] }],
tooltip: {},
};
}
function makeStore(): any {
return {
state: {
theme: "light",
zoConfig: { timestamp_column: "_timestamp" },
timezone: "UTC",
},
};
}
function makeMetadata(): any {
return {
queries: [{ timeRangeGap: { seconds: 0 } }],
};
}
function makePanelSchema(
type = "line",
customQuery = false,
xFields: any[] = [],
): any {
return {
type,
id: "panel1",
config: {
unit: "default",
unit_custom: "",
decimals: 2,
trellis: { layout: null },
},
queries: [{ customQuery, fields: { x: xFields, y: [{ label: "Value" }] } }],
};
}
function makeHistogramField(): any {
return {
functionName: "histogram",
args: [{ value: { field: "_timestamp" } }],
};
}
function makeTimestampField(): any {
return {
functionName: null,
args: [{ value: { field: "_timestamp" } }],
};
}
// ────────────────────────────────────────────────────────────
// applyAutoSQLTimeSeries
// ────────────────────────────────────────────────────────────
describe("applyAutoSQLTimeSeries", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns false for h-bar type", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("h-bar", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false for h-stacked type", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("h-stacked", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false for pie type", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("pie", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when customQuery is true", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("line", true, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when xAxis is empty array", () => {
const options = makeOptions();
options.xAxis = [];
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when xAxis[0].data is empty", () => {
const options = makeOptions([]);
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when no histogram or timestamp field", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("line", false, [{ functionName: "count", args: [] }]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns true and sets xAxis.type to time when histogram field present", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(true);
expect(options.xAxis[0].type).toBe("time");
});
it("converts series data to [x, y] tuples when histogram field present", () => {
const options = makeOptions(["2024-01-15 10:00:00", "2024-01-15 11:00:00"]);
options.series = [{ name: "s1", data: [10, 20] }];
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(Array.isArray(options.series[0].data[0])).toBe(true);
expect(options.series[0].data[0]).toHaveLength(2);
expect(options.series[0].data[1]).toHaveLength(2);
});
it("clears xAxis[0].data after conversion", () => {
const options = makeOptions(["2024-01-15 10:00:00"]);
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(options.xAxis[0].data).toEqual([]);
});
it("sets tooltip.formatter function when histogram field present", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(typeof options.tooltip.formatter).toBe("function");
});
it("sets tooltip.axisPointer when histogram field present", () => {
const options = makeOptions();
const panelSchema = makePanelSchema("line", false, [makeHistogramField()]);
applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(options.tooltip.axisPointer).toBeDefined();
expect(options.tooltip.axisPointer.type).toBe("cross");
});
it("returns true when timestampField present (no functionName)", () => {
const options = makeOptions(["1705320000000", "1705323600000"]);
const panelSchema = makePanelSchema("line", false, [makeTimestampField()]);
const result = applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(true);
});
it("converts series data to [x, y] tuples when timestamp field present", () => {
const options = makeOptions(["1705320000000", "1705323600000"]);
options.series = [{ name: "s1", data: [5, 15] }];
const panelSchema = makePanelSchema("bar", false, [makeTimestampField()]);
applyAutoSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(Array.isArray(options.series[0].data[0])).toBe(true);
expect(options.series[0].data[0]).toHaveLength(2);
});
});
// ────────────────────────────────────────────────────────────
// applyCustomSQLTimeSeries
// ────────────────────────────────────────────────────────────
describe("applyCustomSQLTimeSeries", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: not time series, not timestamp
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(false as any);
vi.mocked(dateTimeUtils.isTimeStamp).mockReturnValue(false as any);
});
it("returns false for heatmap type", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions();
const panelSchema = makePanelSchema("heatmap", true);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when customQuery is false", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions();
const panelSchema = makePanelSchema("line", false);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns false when xAxis data is not time series and not timestamp", () => {
const options = makeOptions(["A", "B", "C"]);
const panelSchema = makePanelSchema("line", true);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
it("returns true and sets xAxis.type to time when data is time series", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions(["2024-01-15 10:00:00", "2024-01-15 11:00:00"]);
const panelSchema = makePanelSchema("line", true);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(true);
expect(options.xAxis[0].type).toBe("time");
});
it("converts series data to [x, y] tuples when isTimeSeries is true", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions(["2024-01-15 10:00:00", "2024-01-15 11:00:00"]);
options.series = [{ name: "custom", data: [100, 200] }];
const panelSchema = makePanelSchema("line", true);
applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(Array.isArray(options.series[0].data[0])).toBe(true);
expect(options.series[0].data[0]).toHaveLength(2);
});
it("returns true and sets xAxis.type to time when data is timestamp", () => {
vi.mocked(dateTimeUtils.isTimeStamp).mockReturnValue(true as any);
const options = makeOptions(["1705320000000", "1705323600000"]);
const panelSchema = makePanelSchema("bar", true);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(true);
expect(options.xAxis[0].type).toBe("time");
});
it("clears xAxis[0].data after conversion", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions(["2024-01-15 10:00:00"]);
const panelSchema = makePanelSchema("line", true);
applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(options.xAxis[0].data).toEqual([]);
});
it("sets tooltip.formatter when time series detected", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions();
const panelSchema = makePanelSchema("line", true);
applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(typeof options.tooltip.formatter).toBe("function");
});
it("returns false when xAxis is empty array", () => {
vi.mocked(dateTimeUtils.isTimeSeries).mockReturnValue(true as any);
const options = makeOptions();
options.xAxis = [];
const panelSchema = makePanelSchema("line", true);
const result = applyCustomSQLTimeSeries(options, panelSchema, makeStore(), makeMetadata(), { value: null });
expect(result).toBe(false);
});
});
@@ -0,0 +1,498 @@
// 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 { toZonedTime } from "date-fns-tz";
import { formatUnitValue, getUnitValue } from "./convertDataIntoUnitValue";
import { calculateDynamicNameGap } from "./chartDimensionUtils";
import { formatDate, isTimeSeries, isTimeStamp } from "./dateTimeUtils";
/**
* Handles auto SQL time series conversion.
* Detects histogram / timestamp x-axis fields and converts series data and
* chart options (xAxis, tooltip, axisPointer) to time-series format.
*
* @param options - The mutable ECharts options object.
* @param panelSchema - The panel schema object.
* @param store - The Vuex store object.
* @param metadata - Query metadata (startTime, endTime, queries, timeRangeGap …).
* @param hoveredSeriesState - Ref tracking the currently-hovered series.
* @returns {boolean} - true if time-series mode was activated, false otherwise.
*/
export const applyAutoSQLTimeSeries = (
options: any,
panelSchema: any,
store: any,
metadata: any,
hoveredSeriesState: any,
): boolean => {
// auto SQL: if x axis has time series
if (
panelSchema.type != "h-bar" &&
panelSchema.type != "h-stacked" &&
panelSchema.type != "heatmap" &&
panelSchema.type != "metric" &&
panelSchema.type != "pie" &&
panelSchema.type != "donut" &&
panelSchema?.queries[0]?.customQuery == false &&
Array.isArray(options.xAxis) &&
options.xAxis.length > 0 &&
options.xAxis[0].data.length > 0
) {
// auto SQL: if x axis has time series(aggregation function is histogram)
const field = panelSchema.queries[0].fields?.x.find(
(it: any) =>
it.functionName == "histogram" &&
it?.args?.[0]?.value?.field == store.state?.zoConfig?.timestamp_column,
);
const timestampField = panelSchema.queries[0].fields?.x.find(
(it: any) =>
!it.functionName &&
it?.args?.[0]?.value?.field == store.state?.zoConfig?.timestamp_column,
);
//if x axis has time series
if (field || timestampField) {
// if timezone is UTC then simply return x axis value which will be in UTC (note that need to remove Z from timezone string)
// else check if xaxis value is integer(ie time will be in milliseconds)
// if yes then return to convert into other timezone
// if no then create new datetime object and get in milliseconds using getTime method
const timeStringCache: any = {};
options?.series?.forEach((seriesObj: any) => {
// if value field is not present in the data than use null
if (field) {
seriesObj.data = seriesObj?.data?.map((it: any, index: any) => {
const xKey = options.xAxis[0].data[index] + "Z";
let x;
if (timeStringCache[xKey]) {
x = timeStringCache[xKey];
} else {
// need to consider time range gap
x = toZonedTime(
new Date(options.xAxis[0].data[index] + "Z").getTime() +
(metadata?.queries[0]?.timeRangeGap.seconds ?? 0),
store.state.timezone,
);
timeStringCache[xKey] = x;
}
return [x, it ?? null];
});
} else if (timestampField) {
seriesObj.data = seriesObj?.data?.map((it: any, index: any) => {
const xKey = options.xAxis[0].data[index].toString();
let x;
if (timeStringCache[xKey]) {
x = timeStringCache[xKey];
} else {
// need to consider time range gap
x = toZonedTime(
(new Date(options.xAxis[0].data[index]).getTime() +
(metadata?.queries[0]?.timeRangeGap.seconds ?? 0)) /
1000,
store.state.timezone,
);
timeStringCache[xKey] = x;
}
return [x, it ?? null];
});
}
});
// Trellis has multiple x axis
if (panelSchema.config.trellis?.layout) {
options.xAxis.forEach((axis: any) => {
axis.type = "time";
// For time-based x-axis, reset axis label rotation and truncation
if (axis.axisLabel) {
axis.axisLabel.rotate = 0;
axis.axisLabel.overflow = "none";
axis.axisLabel.width = undefined;
}
// Recalculate nameGap with 0 rotation for time-based axis
if (axis.name) {
axis.nameGap = calculateDynamicNameGap(0, 120, 12, 25, 10);
}
});
} else {
options.xAxis[0].type = "time";
// For time-based x-axis, reset axis label rotation and truncation
if (options.xAxis[0].axisLabel) {
options.xAxis[0].axisLabel.rotate = 0;
options.xAxis[0].axisLabel.overflow = "none";
options.xAxis[0].axisLabel.width = undefined;
}
// Recalculate nameGap with 0 rotation for time-based axis
if (options.xAxis[0].name) {
options.xAxis[0].nameGap = calculateDynamicNameGap(
0,
120,
12,
25,
10,
);
}
}
options.xAxis[0].data = [];
options.tooltip.formatter = function (name: any) {
// show tooltip for hovered panel only for other we only need axis so just return empty string
// if (
// showTrellisConfig(panelSchema.type) &&
// panelSchema.config.trellis?.layout &&
// breakDownKeys.length
// )
// name = [name[0]];
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
if (name.length == 0) return "";
const date = new Date(name[0].data[0]);
// sort tooltip array based on value
name.sort((a: any, b: any) => {
return (b.value[1] || 0) - (a.value[1] || 0);
});
// if hovered series name is not null then move it to first position
if (hoveredSeriesState?.value?.hoveredSeriesName) {
// get the current series index from name
const currentSeriesIndex = name.findIndex(
(it: any) =>
it.seriesName == hoveredSeriesState?.value?.hoveredSeriesName,
);
// if hovered series index is not -1 then take it to very first position
if (currentSeriesIndex != -1) {
// shift all series to next position and place current series at first position
const temp = name[currentSeriesIndex];
for (let i = currentSeriesIndex; i > 0; i--) {
name[i] = name[i - 1];
}
name[0] = temp;
}
}
const hoverText: string[] = [];
name.forEach((it: any) => {
if (it.data[1] != null) {
// check if the series is the current series being hovered
// if have than bold it
if (it?.seriesName == hoveredSeriesState?.value?.hoveredSeriesName)
hoverText.push(
`<strong>${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.data[1],
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)} </strong>`,
);
// else normal text
else
hoverText.push(
`${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it.data[1],
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}`,
);
}
});
return `${formatDate(date)} <br/> ${hoverText.join("<br/>")}`;
};
options.tooltip.axisPointer = {
type: "cross",
label: {
fontsize: 12,
precision: panelSchema.config?.decimals,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
formatter: function (params: any) {
try {
if (params?.axisDimension == "y")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
return Number.isInteger(params?.value)
? formatDate(new Date(params?.value))
: params?.value;
} catch (error) {
return params?.value ?? "";
}
},
},
formatter: function (params: any) {
try {
const date = new Date(params?.[0]?.value?.[0]);
return formatDate(date)?.toString() ?? "";
} catch (error) {
return "";
}
},
};
// set timeseries flag as a true
return true;
}
}
return false;
};
/**
* Handles custom SQL time series conversion.
* Detects whether the x-axis data is time-series or timestamp-based and
* converts series data and chart options (xAxis, tooltip, axisPointer) to
* time-series format.
*
* @param options - The mutable ECharts options object.
* @param panelSchema - The panel schema object.
* @param store - The Vuex store object.
* @param metadata - Query metadata (startTime, endTime, queries, timeRangeGap …).
* @param hoveredSeriesState - Ref tracking the currently-hovered series.
* @returns {boolean} - true if time-series mode was activated, false otherwise.
*/
export const applyCustomSQLTimeSeries = (
options: any,
panelSchema: any,
store: any,
metadata: any,
hoveredSeriesState: any,
): boolean => {
//custom SQL: check if it is timeseries or not
if (
panelSchema.type != "h-bar" &&
panelSchema.type != "h-stacked" &&
panelSchema.type != "heatmap" &&
panelSchema.type != "metric" &&
panelSchema.type != "pie" &&
panelSchema.type != "donut" &&
panelSchema?.queries[0]?.customQuery == true &&
Array.isArray(options.xAxis) &&
options.xAxis.length > 0 &&
options.xAxis[0].data.length > 0
) {
const sample = options.xAxis[0].data.slice(
0,
Math.min(20, options.xAxis[0].data.length),
);
const isTimeSeriesData = isTimeSeries(sample);
const isTimeStampData = isTimeStamp(sample, null);
if (isTimeSeriesData || isTimeStampData) {
options?.series?.forEach((seriesObj: any) => {
// if value field is not present in the data than use null
if (isTimeSeriesData) {
seriesObj.data = seriesObj?.data?.map((it: any, index: any) => [
// need to consider time range gap
toZonedTime(
new Date(options.xAxis[0].data[index] + "Z").getTime() +
(metadata?.queries[0]?.timeRangeGap.seconds ?? 0),
store.state.timezone,
),
it ?? null,
]);
} else if (isTimeStampData) {
seriesObj.data = seriesObj?.data?.map((it: any, index: any) => [
// need to consider time range gap
toZonedTime(
(new Date(options.xAxis[0].data[index]).getTime() +
(metadata?.queries[0]?.timeRangeGap.seconds ?? 0)) /
1000,
store.state.timezone,
),
it ?? null,
]);
}
});
// Trellis has multiple x axis
if (panelSchema.config.trellis?.layout) {
options.xAxis.forEach((axis: any) => {
axis.type = "time";
// For time-based x-axis, reset axis label rotation and truncation
if (axis.axisLabel) {
axis.axisLabel.rotate = 0;
axis.axisLabel.overflow = "none";
axis.axisLabel.width = undefined;
}
// Recalculate nameGap with 0 rotation for time-based axis
if (axis.name) {
axis.nameGap = calculateDynamicNameGap(0, 120, 12, 25, 10);
}
});
} else {
options.xAxis[0].type = "time";
// For time-based x-axis, reset axis label rotation and truncation
if (options.xAxis[0].axisLabel) {
options.xAxis[0].axisLabel.rotate = 0;
options.xAxis[0].axisLabel.overflow = "none";
options.xAxis[0].axisLabel.width = undefined;
}
// Recalculate nameGap with 0 rotation for time-based axis
if (options.xAxis[0].name) {
options.xAxis[0].nameGap = calculateDynamicNameGap(
0,
120,
12,
25,
10,
);
}
}
options.xAxis[0].data = [];
options.tooltip.formatter = function (name: any) {
try {
// if (
// showTrellisConfig(panelSchema.type) &&
// panelSchema.config.trellis?.layout &&
// breakDownKeys.length
// )
// name = [name[0]];
// show tooltip for hovered panel only for other we only need axis so just return empty string
if (
hoveredSeriesState?.value &&
panelSchema.id &&
hoveredSeriesState?.value?.panelId != panelSchema.id
)
return "";
if (name?.length == 0) return "";
const date = new Date(name?.[0]?.data?.[0]);
// sort tooltip array based on value
name?.sort((a: any, b: any) => {
return (b?.value?.[1] || 0) - (a?.value?.[1] || 0);
});
// if hovered series name is not null then move it to first position
if (hoveredSeriesState?.value?.hoveredSeriesName) {
// get the current series index from name
const currentSeriesIndex = name?.findIndex(
(it: any) =>
it.seriesName == hoveredSeriesState?.value?.hoveredSeriesName,
);
// if hovered series index is not -1 then take it to very first position
if (currentSeriesIndex != -1) {
// shift all series to next position and place current series at first position
const temp = name?.[currentSeriesIndex];
for (let i = currentSeriesIndex; i > 0; i--) {
name[i] = name?.[i - 1];
}
name[0] = temp;
}
}
const hoverText: string[] = [];
name?.forEach((it: any) => {
if (it?.data?.[1] != null) {
// check if the series is the current series being hovered
// if have than bold it
if (
it?.seriesName == hoveredSeriesState?.value?.hoveredSeriesName
)
hoverText.push(
`<strong>${it?.marker} ${it?.seriesName} : ${formatUnitValue(
getUnitValue(
it?.data?.[1],
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)} </strong>`,
);
// else normal text
else
hoverText.push(
`${it.marker} ${it.seriesName} : ${formatUnitValue(
getUnitValue(
it?.data?.[1],
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
)}`,
);
}
});
return `${formatDate(date)} <br/> ${hoverText.join("<br/>")}`;
} catch (error) {
return "";
}
};
options.tooltip.axisPointer = {
type: "cross",
label: {
fontsize: 12,
precision: panelSchema.config?.decimals,
backgroundColor: store.state.theme === "dark" ? "#333" : "",
formatter: function (params: any) {
try {
if (params?.axisDimension == "y")
return formatUnitValue(
getUnitValue(
params?.value,
panelSchema.config?.unit,
panelSchema.config?.unit_custom,
panelSchema.config?.decimals,
),
);
return formatDate(new Date(params?.value))?.toString() ?? "";
} catch (error) {
return params?.value ?? "";
}
},
},
formatter: function (params: any) {
try {
const date = new Date(params?.[0]?.value?.[0]);
return formatDate(date)?.toString() ?? "";
} catch (error) {
return "";
}
},
};
// set timeseries flag as a true
return true;
}
}
return false;
};
@@ -14,7 +14,7 @@ vi.mock("@/utils/zincutils", () => ({
getImageURL: vi.fn((path: string) => `mocked-${path}`),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.mock("@/utils/dashboard/panelValidation", () => ({
validateDashboardJson: vi.fn(() => []),
}));
@@ -192,7 +192,7 @@ describe("DashboardJsonEditor", () => {
// Test 8: handleEditorChange with validation errors from validateDashboardJson
it("should handle validation errors from validateDashboardJson", async () => {
// Mock the validateDashboardJson function to return an error
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue(["Test validation error"]);
wrapper = createWrapper();
@@ -266,7 +266,7 @@ describe("DashboardJsonEditor", () => {
// Test 14: saveChanges should handle validation errors during save
it("should handle validation errors during save", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue(["Save-time validation error"]);
wrapper = createWrapper();
@@ -281,7 +281,7 @@ describe("DashboardJsonEditor", () => {
// Test 15: saveChanges should successfully save valid JSON
it("should successfully save valid JSON", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue([]);
wrapper = createWrapper();
@@ -295,7 +295,7 @@ describe("DashboardJsonEditor", () => {
// Test 16: saveChanges should handle save execution errors
it("should handle save execution errors", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue([]);
const saveError = new Error("Save failed");
@@ -318,7 +318,7 @@ describe("DashboardJsonEditor", () => {
// Test 17: saveChanges should handle non-Error exceptions
it("should handle non-Error exceptions during save", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue([]);
mockSaveJsonDashboard.execute.mockRejectedValue("String error");
@@ -406,7 +406,7 @@ describe("DashboardJsonEditor", () => {
// Test 23: Save button click should call saveChanges
it("should call saveChanges when save button is clicked", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue([]);
wrapper = createWrapper();
@@ -462,7 +462,7 @@ describe("DashboardJsonEditor", () => {
// Test 27: Editor change without restricted fields
it("should allow valid JSON without restricted fields", async () => {
// Reset the validateDashboardJson mock to return no errors
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue([]);
wrapper = createWrapper();
@@ -551,7 +551,7 @@ describe("DashboardJsonEditor", () => {
// Test 34: Mixed validation errors - both custom validation and validateDashboardJson
it("should handle mixed validation errors", async () => {
const mockValidate = await import("@/utils/dashboard/convertDataIntoUnitValue");
const mockValidate = await import("@/utils/dashboard/panelValidation");
vi.mocked(mockValidate.validateDashboardJson).mockReturnValue(["Schema validation error"]);
wrapper = createWrapper();
@@ -80,7 +80,7 @@ import { useStore } from "vuex";
import { defineAsyncComponent } from "vue";
const QueryEditor = defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue"));
import { getImageURL } from "@/utils/zincutils";
import { validateDashboardJson } from "@/utils/dashboard/convertDataIntoUnitValue";
import { validateDashboardJson } from "@/utils/dashboard/panelValidation";
export default defineComponent({
name: "DashboardJsonEditor",
@@ -34,7 +34,7 @@ vi.mock("@/utils/dashboard/convertDashboardSchemaVersion", () => ({
convertDashboardSchemaVersion: vi.fn((dashboard) => dashboard),
}));
vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.mock("@/utils/dashboard/panelValidation", () => ({
validateDashboardJson: vi.fn(() => []),
}));
@@ -618,7 +618,7 @@ describe("ImportDashboard.vue", () => {
it("should test importFromJsonStr with validation errors", async () => {
// Mock validateDashboardJson to return validation errors
const mockValidateDashboardJson = vi.fn().mockReturnValue(["Invalid dashboard structure", "Missing required field"]);
vi.doMock("@/utils/dashboard/convertDataIntoUnitValue", () => ({
vi.doMock("@/utils/dashboard/panelValidation", () => ({
validateDashboardJson: mockValidateDashboardJson,
}));
+1 -1
View File
@@ -314,7 +314,7 @@ import { useRouter, useRoute } from "vue-router";
import dashboardService from "../../services/dashboards.js";
import axios from "axios";
import { convertDashboardSchemaVersion } from "@/utils/dashboard/convertDashboardSchemaVersion";
import { validateDashboardJson } from "@/utils/dashboard/convertDataIntoUnitValue";
import { validateDashboardJson } from "@/utils/dashboard/panelValidation";
import SelectFolderDropdown from "@/components/dashboards/SelectFolderDropdown.vue";
import useNotifications from "@/composables/useNotifications";
import AppTabs from "@/components/common/AppTabs.vue";