diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 3f15a6cf3d..92c5e2c655 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -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"; diff --git a/web/src/components/dashboards/addPanel/ConfigPanel.vue b/web/src/components/dashboards/addPanel/ConfigPanel.vue index 46d31a5347..059e23c455 100644 --- a/web/src/components/dashboards/addPanel/ConfigPanel.vue +++ b/web/src/components/dashboards/addPanel/ConfigPanel.vue @@ -147,7 +147,7 @@ along with this program. If not, see . ref="panelTimePickerRef" v-model="pickerValue" :auto-apply-dashboard="true" - :hide-relative-timezone="true" + :hide-relative-timezone="true" data-test="dashboard-config-panel-time-picker" /> .
.
.
. .
.
.
.
.
.
.
.
. @@ -1856,35 +1748,17 @@ along with this program. If not, see .
@@ -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, diff --git a/web/src/components/dashboards/panels/PromQLTableChart.spec.ts b/web/src/components/dashboards/panels/PromQLTableChart.spec.ts index 44f6b9698b..d149e4ba4e 100644 --- a/web/src/components/dashboards/panels/PromQLTableChart.spec.ts +++ b/web/src/components/dashboards/panels/PromQLTableChart.spec.ts @@ -25,7 +25,7 @@ vi.mock("@/composables/useNotifications", () => ({ }), })); -vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({ +vi.mock("@/utils/dashboard/panelValidation", () => ({ findFirstValidMappedValue: vi.fn().mockReturnValue(null), })); diff --git a/web/src/components/dashboards/panels/TableRenderer.spec.ts b/web/src/components/dashboards/panels/TableRenderer.spec.ts index 7ba9f0244b..74aae06d09 100644 --- a/web/src/components/dashboards/panels/TableRenderer.spec.ts +++ b/web/src/components/dashboards/panels/TableRenderer.spec.ts @@ -34,7 +34,7 @@ vi.mock("@/composables/useNotifications", () => ({ }), })); -vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({ +vi.mock("@/utils/dashboard/panelValidation", () => ({ findFirstValidMappedValue: vi.fn().mockReturnValue(null), })); diff --git a/web/src/components/dashboards/panels/TableRenderer.vue b/web/src/components/dashboards/panels/TableRenderer.vue index 22bfb435fb..ae25c9ff17 100644 --- a/web/src/components/dashboards/panels/TableRenderer.vue +++ b/web/src/components/dashboards/panels/TableRenderer.vue @@ -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"; diff --git a/web/src/composables/dashboard/useAnnotationsData.ts b/web/src/composables/dashboard/useAnnotationsData.ts index f95f01bc0c..79c6916c30 100644 --- a/web/src/composables/dashboard/useAnnotationsData.ts +++ b/web/src/composables/dashboard/useAnnotationsData.ts @@ -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, diff --git a/web/src/composables/dashboard/usePanelDataLoader.spec.ts b/web/src/composables/dashboard/usePanelDataLoader.spec.ts index ac02adf986..589836f22c 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.spec.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.spec.ts @@ -102,7 +102,7 @@ vi.mock("./usePanelCache", () => ({ })), })); -vi.mock("@/utils/dashboard/convertDataIntoUnitValue", () => ({ +vi.mock("@/utils/dashboard/dateTimeUtils", () => ({ convertOffsetToSeconds: vi.fn(() => ({ seconds: 3600, periodAsStr: "1h" })), })); diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts index 16270a7e79..b67f4f1aa8 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.ts @@ -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"; diff --git a/web/src/composables/useDashboardPanel.spec.ts b/web/src/composables/useDashboardPanel.spec.ts index 3f6dd23384..19a3b1feea 100644 --- a/web/src/composables/useDashboardPanel.spec.ts +++ b/web/src/composables/useDashboardPanel.spec.ts @@ -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(); }); }); diff --git a/web/src/composables/useDashboardPanel.ts b/web/src/composables/useDashboardPanel.ts index 6bbf0fb876..84cf6a3bb6 100644 --- a/web/src/composables/useDashboardPanel.ts +++ b/web/src/composables/useDashboardPanel.ts @@ -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"; diff --git a/web/src/utils/dashboard/chartColorUtils.spec.ts b/web/src/utils/dashboard/chartColorUtils.spec.ts new file mode 100644 index 0000000000..cd5f0a07b7 --- /dev/null +++ b/web/src/utils/dashboard/chartColorUtils.spec.ts @@ -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 . + +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\(/); + }); + }); +}); diff --git a/web/src/utils/dashboard/chartColorUtils.ts b/web/src/utils/dashboard/chartColorUtils.ts new file mode 100644 index 0000000000..ee13a22d82 --- /dev/null +++ b/web/src/utils/dashboard/chartColorUtils.ts @@ -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(); + const configuredColors = new Set(); + 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(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, + 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"; + } +}; diff --git a/web/src/utils/dashboard/chartDimensionUtils.spec.ts b/web/src/utils/dashboard/chartDimensionUtils.spec.ts new file mode 100644 index 0000000000..e11a07c4bb --- /dev/null +++ b/web/src/utils/dashboard/chartDimensionUtils.spec.ts @@ -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 . + +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); + }); + }); +}); diff --git a/web/src/utils/dashboard/chartDimensionUtils.ts b/web/src/utils/dashboard/chartDimensionUtils.ts new file mode 100644 index 0000000000..67bc8a04f3 --- /dev/null +++ b/web/src/utils/dashboard/chartDimensionUtils.ts @@ -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 + } +}; diff --git a/web/src/utils/dashboard/configUtils.spec.ts b/web/src/utils/dashboard/configUtils.spec.ts index 518cde9723..e29c68313f 100644 --- a/web/src/utils/dashboard/configUtils.spec.ts +++ b/web/src/utils/dashboard/configUtils.spec.ts @@ -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); + }); + }); }); diff --git a/web/src/utils/dashboard/configUtils.ts b/web/src/utils/dashboard/configUtils.ts index 2e3f00b94d..3dde2fb983 100644 --- a/web/src/utils/dashboard/configUtils.ts +++ b/web/src/utils/dashboard/configUtils.ts @@ -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" + ); +}; diff --git a/web/src/utils/dashboard/convertDataIntoUnitValue.spec.ts b/web/src/utils/dashboard/convertDataIntoUnitValue.spec.ts index 1d7e94972d..267a4f820c 100644 --- a/web/src/utils/dashboard/convertDataIntoUnitValue.spec.ts +++ b/web/src/utils/dashboard/convertDataIntoUnitValue.spec.ts @@ -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 = { diff --git a/web/src/utils/dashboard/convertDataIntoUnitValue.ts b/web/src/utils/dashboard/convertDataIntoUnitValue.ts index a4e05b9039..31c72cbb46 100644 --- a/web/src/utils/dashboard/convertDataIntoUnitValue.ts +++ b/web/src/utils/dashboard/convertDataIntoUnitValue.ts @@ -1,9 +1,3 @@ -import { date } from "quasar"; -import { CURRENT_DASHBOARD_SCHEMA_VERSION } from "@/utils/dashboard/convertDashboardSchemaVersion"; -import functionValidation from "@/components/dashboards/addPanel/dynamicFunction/functionValidation.json"; -import { getColorPalette } from "./colorPalette"; -import { fromZonedTime } from "date-fns-tz"; - const units: any = { bytes: [ { unit: "B", divisor: 1 }, @@ -239,86 +233,6 @@ export const getUnitValue = ( } }; -/** - * 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(); - const configuredColors = new Set(); - 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(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, - 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); - } - }); -}; /** * Formats a unit value. @@ -334,1501 +248,3 @@ export const formatUnitValue = (obj: any) => { } return `${obj.value}${obj.unit}`; }; - -/** - * 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: "", - }; - } -} - -// will find first valid mapped value based on given fieldToCheck -export const findFirstValidMappedValue = ( - value: any, - mappings: any[], - fieldToCheck: string, -) => { - return mappings?.find((v: any) => { - let isMatch = false; - - // Check based on type - if (v?.type == "value") { - isMatch = v?.value == value; - } else if (v?.type == "range") { - if ( - v?.from && - v?.to && - !Number.isNaN(+v?.from) && - !Number.isNaN(+v?.to) - ) { - isMatch = +v?.from <= +value && +v?.to >= +value; - } - } else if (v?.type == "regex") { - isMatch = new RegExp(v?.pattern ?? "").test(value); - } - - // If a match is found, check if the required field (color or text) is valid - if (isMatch && v[fieldToCheck]) { - return true; - } - - return false; - }); -}; - -/** - * 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 -}; - -/** - * Validates a single condition item - * @param condition The condition to validate - * @param errors Array to collect errors - */ -const validateConditionItem = (condition: any, errors: string[]) => { - if (condition.type === "list" && !condition.values?.length) { - errors.push( - `Filter: ${condition.column}: Select at least 1 item from the list`, - ); - } - - if (condition.type === "condition") { - if (condition.operator == null) { - errors.push(`Filter: ${condition.column}: Operator selection required`); - } - - if ( - !["Is Null", "Is Not Null"].includes(condition.operator) && - (condition.value == null || condition.value == "") - ) { - errors.push(`Filter: ${condition.column}: Condition value required`); - } - } -}; - -/** - * Validates a field-type argument - * @param arg The argument to validate - * @param fieldPath Path for error messages - * @param index Argument index - * @param errors Array to collect errors - */ -const validateFieldArgument = ( - arg: any, - fieldPath: string, - index: number, - errors: string[], -) => { - if (!arg.value || typeof arg.value !== "object" || !("field" in arg.value)) { - errors.push( - `${fieldPath}: Argument ${index + 1} is a field but haven't selected any field`, - ); - } -}; - -/** - * Validates a number-type argument - * @param arg The argument to validate - * @param fieldPath Path for error messages - * @param index Argument index - * @param errors Array to collect errors - */ -const validateNumberArgument = ( - arg: any, - fieldPath: string, - index: number, - errors: string[], -) => { - if (arg.value === null || arg.value === undefined || arg.value === "") { - errors.push( - `${fieldPath}: Argument ${index + 1} is a number but no value entered`, - ); - } else if (typeof arg.value !== "number" || isNaN(arg.value)) { - errors.push(`${fieldPath}: Argument ${index + 1} must be a valid number`); - } -}; - -/** - * Validates a string-type argument - * @param arg The argument to validate - * @param fieldPath Path for error messages - * @param index Argument index - * @param errors Array to collect errors - */ -const validateStringArgument = ( - arg: any, - fieldPath: string, - index: number, - errors: string[], -) => { - if (arg.value === null || arg.value === undefined) { - errors.push( - `${fieldPath}: Argument ${index + 1} is a string but no value entered`, - ); - } else if (typeof arg.value !== "string" || arg.value.trim() === "") { - errors.push( - `${fieldPath}: Argument ${index + 1} must be a non-empty string`, - ); - } -}; - -/** - * Validates a histogramInterval-type argument - * @param arg The argument to validate - * @param fieldPath Path for error messages - * @param index Argument index - * @param errors Array to collect errors - */ -const validateHistogramIntervalArgument = ( - arg: any, - fieldPath: string, - index: number, - errors: string[], -) => { - // if arg value is null, value not present or not a string - if (!(arg.value === null || !arg.value || typeof arg.value === "string")) { - errors.push( - `${fieldPath}: Argument ${index + 1} must be a valid histogram interval`, - ); - } -}; - -/** - * Validate the filters in the panel - * @param conditions the conditions array - * @param errors the array to push the errors to - */ -function validateConditions(conditions: any, errors: any) { - conditions.forEach((it: any) => { - if (it.filterType === "condition") { - validateConditionItem(it, errors); - } else if (it.filterType === "group") { - // Recursively validate the conditions in the group - validateConditions(it.conditions, errors); - } - }); -} - -/** - * Validates a function and its nested function arguments, or validates raw query fields - * - * Handles the following validation scenarios: - * - * 1. **Raw Query Fields**: Fields with `type: "raw"` must have non-empty rawQuery - * Example: Custom SQL query field must have valid query string - * - * 2. **Required Arguments**: Arguments with `required: true` must be present - * Example: count(field) - field is required - * - * 3. **Optional Arguments**: Arguments with `required: false` can be omitted - * Example: substring(field, start, length?) - length is optional - * Example: from_unixtime(timestamp, format?) - format is optional - * - * 4. **Variable Arguments**: Functions with `allowAddArgAt` can accept N arguments - * - `allowAddArgAt: "n"` means position 0 can repeat infinitely - * Example: concat(arg1, arg2, arg3, ..., argN) with min=2 - * - `allowAddArgAt: "n-1"` means (argsLength-1) position can repeat - * - Combined with `min` property to enforce minimum arg count - * - * 5. **Nested Functions**: Arguments with type "function" are validated recursively - * Example: sum(count(field)) - both sum and count are validated - * Example: concat(upper(field1), lower(field2)) - all 3 functions validated - * - * 6. **Type Validation**: Each argument type is validated against allowed types - * - field: Must have valid field selection - * - function: Recursively validated - * - number: Must be valid number - * - string: Must be non-empty string - * - histogramInterval: Must be valid interval string - * - * @param funcConfig - The function configuration to validate - * @param fieldPath - Path for error messages (e.g., "Field", "Field → Arg 2") - * @param errors - Array to collect errors - */ -const validateFunction = ( - funcConfig: any, - fieldPath: string, - errors: string[], -) => { - // Handle raw query fields - if (funcConfig.type === "raw") { - if ( - !funcConfig.rawQuery || - typeof funcConfig.rawQuery !== "string" || - funcConfig.rawQuery.trim() === "" - ) { - errors.push(`${fieldPath}: Raw query cannot be empty`); - } - return; - } - - // Get the selected function schema - const selectedFunction: any = functionValidation?.find( - (fn: any) => fn?.functionName === (funcConfig?.functionName ?? null), - ); - - // If function is not found, push error - if (!selectedFunction) { - errors.push(`${fieldPath}: Invalid aggregation function`); - return; // Skip further validation if function is invalid - } - - // Check if args are valid based on selected function schema - const args = funcConfig.args || []; - const argsDefinition = selectedFunction.args || []; - - // OPTIONAL ARGUMENTS: Handled by "required": false in argDefinition - // VARIABLE ARGUMENTS: Handled by "allowAddArgAt" property - // Examples: - // - concat: allowAddArgAt="n" with min=2 (can have 2+ args) - // - substring: 3rd arg has required=false (optional) - const allowAddArgAtValue = selectedFunction.allowAddArgAt; - const hasVariableArgs = !!allowAddArgAtValue; - - // Parse the allowAddArgAt value to determine variable argument position - // "n" means position 0 (all args can repeat) - // "n-1" means (argsLength - 1) position - // "n-2" means (argsLength - 2) position - let variableArgPosition = -1; - if (hasVariableArgs) { - if (allowAddArgAtValue === "n") { - variableArgPosition = 0; // All arguments can be variable - } else if (allowAddArgAtValue.startsWith("n-")) { - // Format is "n-1", "n-2", etc. - const offset = parseInt(allowAddArgAtValue.substring(2)); - variableArgPosition = argsDefinition.length - offset; - } - } - - // Special handling for functions with min requirements - // Find the argDefinition that has the min property - const minArgDef = argsDefinition.find((def: any) => "min" in def); - const minPosition = minArgDef ? argsDefinition.indexOf(minArgDef) : -1; - - // If min is specified and position is valid, check the requirement - if (minArgDef && minPosition !== -1) { - // For variable args, we count all arguments from the variable position - const relevantArgsCount = - hasVariableArgs && variableArgPosition <= minPosition - ? args.length - variableArgPosition + 1 // +1 because we count the variable position itself - : args.length; - - if (relevantArgsCount < minArgDef.min) { - errors.push(`${fieldPath}: Requires at least ${minArgDef.min} arguments`); - } - } - - // Validate all provided arguments have correct types - args.forEach((arg: any, index: number) => { - // Skip null/undefined args only if they're optional - if (!arg) { - // Check if this position is required - const isOptional = - index < argsDefinition.length && !argsDefinition[index]?.required; - if (!isOptional && !hasVariableArgs) { - // This is a required arg that's missing - will be caught in "missing required" check below - } - return; - } - - // Determine which arg definition to use for validation - let argDefIndex = index; - - // For variable arguments - if (hasVariableArgs && index >= variableArgPosition) { - // Use the definition at the variable position - argDefIndex = variableArgPosition; - } - - // Handle out-of-bounds index for non-variable args or unknown formats - if (argDefIndex >= argsDefinition.length) { - if (!hasVariableArgs) { - errors.push(`${fieldPath}: Too many arguments provided`); - return; - } - // Default to the variable argument definition - argDefIndex = variableArgPosition; - } - - const allowedTypes = argsDefinition[argDefIndex].type.map( - (t: any) => t.value, - ); - - // Check if current argument type is among the allowed types - if (arg && !allowedTypes.includes(arg.type)) { - errors.push( - `${fieldPath}: Argument ${index + 1} has invalid type (expected: ${allowedTypes.join(" or ")})`, - ); - return; - } - - // Handle different argument types - if (arg.type === "field") { - validateFieldArgument(arg, fieldPath, index, errors); - } else if (arg.type === "function") { - // RECURSIVE VALIDATION: If argument is a function, validate it recursively - if (!arg.value || typeof arg.value !== "object") { - errors.push( - `${fieldPath}: Argument ${index + 1} is a function but has invalid structure`, - ); - } else { - // Recursively validate the nested function - const nestedPath = `${fieldPath} → Arg ${index + 1}`; - validateFunction(arg.value, nestedPath, errors); - } - } else if (arg.type === "number") { - validateNumberArgument(arg, fieldPath, index, errors); - } else if (arg.type === "string") { - validateStringArgument(arg, fieldPath, index, errors); - } else if (arg.type === "histogramInterval") { - validateHistogramIntervalArgument(arg, fieldPath, index, errors); - } - }); - - // Check for missing required arguments - // This validates: - // 1. Required args that are missing (required: true) - // 2. Optional args are allowed to be missing (required: false) - // 3. Variable args beyond the first instance are allowed (allowAddArgAt) - argsDefinition.forEach((argDef: any, index: number) => { - // Skip checking variable arg positions beyond the first instance - // Example: concat(arg1, arg2, arg3, ...) - only check first 2, rest are variable - if (hasVariableArgs && index > variableArgPosition) return; - - // Check if required argument is missing or null/undefined - if (argDef.required && (index >= args.length || !args[index])) { - errors.push( - `${fieldPath}: Missing required argument at position ${index + 1}`, - ); - } - }); -}; - -/** - * Shared validation logic for panel field configuration based on chart type - * - * @param chartType The type of chart being validated - * @param fields The fields configuration to validate - * @param errors Array to collect error messages - * @param xAxisLabel Optional custom label for X-Axis in error messages - * @param yAxisLabel Optional custom label for Y-Axis in error messages - */ -const validateChartFieldsConfiguration = ( - chartType: string, - fields: any, - errors: string[], - xAxisLabel: string = "X-Axis", - yAxisLabel: string = "Y-Axis", - pageKey: string = "dashboard", -) => { - if (!chartType || !fields) { - return; - } - - switch (chartType) { - case "donut": - case "pie": { - if (fields?.y?.length > 1 || fields?.y?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one value field for donut and pie charts"; - errors.push(errorMsg); - } - - if (fields?.x?.length > 1 || fields?.x?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract label field from the query." - : "Add one label field for donut and pie charts"; - errors.push(errorMsg); - } - break; - } - case "metric": { - if (fields?.y?.length > 1 || fields?.y?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one value field for metric charts"; - errors.push(errorMsg); - } - - if (fields?.x?.length) { - const errorMsg = - pageKey === "logs" - ? "Grouping field is not allowed for Metric chart" - : `${xAxisLabel} field is not allowed for Metric chart`; - errors.push(errorMsg); - } - break; - } - case "gauge": { - if (fields?.y?.length !== 1) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one value field for gauge chart"; - errors.push(errorMsg); - } - // gauge can have zero or one label - if (fields?.x?.length !== 1 && fields?.x?.length !== 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract grouping field from the query." - : "Add one label field for gauge chart"; - errors.push(errorMsg); - } - break; - } - case "h-bar": - case "area": - case "line": - case "scatter": - case "bar": { - if (fields?.y?.length < 1) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : `Add at least one field for the ${yAxisLabel}`; - errors.push(errorMsg); - } - - if (fields?.x?.length > 1 || fields?.x?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract grouping field from the query." - : `Add one fields for the ${xAxisLabel}`; - errors.push(errorMsg); - } - break; - } - case "table": { - if (fields?.y?.length === 0 && fields?.x?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract fields from the query." - : `Add at least one field on ${xAxisLabel} or ${yAxisLabel}`; - errors.push(errorMsg); - } - break; - } - case "heatmap": { - if (fields?.y?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract grouping field from the query." - : `Add at least one field for the ${yAxisLabel}`; - errors.push(errorMsg); - } - - if (fields?.x?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract second level grouping field from the query." - : `Add one field for the ${xAxisLabel}`; - errors.push(errorMsg); - } - - if (fields?.z?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one field for the Z-Axis"; - errors.push(errorMsg); - } - break; - } - case "stacked": - case "h-stacked": { - if (fields?.y?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : `Add at least one field for the ${yAxisLabel}`; - errors.push(errorMsg); - } - if (fields?.x?.length !== 1 || fields?.breakdown?.length !== 1) { - const breakdownErrMsg = - pageKey === "logs" - ? "Unable to extract grouping field from the query." - : `Add exactly one field on the ${xAxisLabel} and breakdown for stacked and h-stacked charts`; - errors.push(breakdownErrMsg); - } - break; - } - case "area-stacked": { - if (fields?.y?.length > 1 || fields?.y?.length === 0) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : `Add exactly one field on ${yAxisLabel} for area-stacked charts`; - errors.push(errorMsg); - } - if (fields?.x?.length !== 1 || fields?.breakdown?.length !== 1) { - const breakdownErrMsg = - pageKey === "logs" - ? "Unable to extract grouping field from the query." - : `Add exactly one field on the ${xAxisLabel} and breakdown for area-stacked charts`; - errors.push(breakdownErrMsg); - } - break; - } - case "geomap": { - if (fields?.latitude == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract latitude field from the query." - : "Add one field for the latitude"; - errors.push(errorMsg); - } - if (fields?.longitude == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract longitude field from the query." - : "Add one field for the longitude"; - errors.push(errorMsg); - } - break; - } - case "sankey": { - if (fields?.source == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract source field from the query." - : "Add one field for the source"; - errors.push(errorMsg); - } - if (fields?.target == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract target field from the query." - : "Add one field for the target"; - errors.push(errorMsg); - } - if (fields?.value == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one field for the value"; - errors.push(errorMsg); - } - break; - } - case "maps": { - if (fields?.name == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract name field from the query." - : "Add one field for the name"; - errors.push(errorMsg); - } - if (fields?.value_for_maps == null) { - const errorMsg = - pageKey === "logs" - ? "Unable to extract value field from the query." - : "Add one field for the value"; - errors.push(errorMsg); - } - break; - } - default: - break; - } - - // need to validate all the fields based on the selected aggregation function - // get all the fields that are not derived and type is build - const aggregationFunctionError = [ - ...(fields?.y ?? []), - ...(fields?.x ?? []), - ...(fields?.breakdown ?? []), - ...(fields?.z ?? []), - fields?.source ?? null, - fields?.target ?? null, - fields?.value ?? null, - fields?.name ?? null, - fields?.value_for_maps ?? null, - fields?.latitude ?? null, - fields?.longitude ?? null, - ]?.filter((it: any) => it && !it?.isDerived); - - if (aggregationFunctionError?.length) { - // loop on each fields config - // compare with function validation schema - // if validation fails, push error - aggregationFunctionError?.forEach((it: any) => { - const fieldPath = it.alias || "Field"; - validateFunction(it, fieldPath, errors); - }); - } -}; - -/** - * Validates the fields configuration for SQL panels - * @param {object} panelData - The panel data object - * @param {number} queryIndex - The current query index - * @param {string} currentXLabel - Label for X-Axis (for error messages) - * @param {string} currentYLabel - Label for Y-Axis (for error messages) - * @param {array} errors - Array to collect errors - * @param {boolean} isFieldsValidationRequired - Whether field validation is required - */ -export const validateSQLPanelFields = ( - panelData: any, - queryIndex: number, - currentXLabel: string, - currentYLabel: string, - errors: string[], - isFieldsValidationRequired: boolean = true, - pageKey?: string, -) => { - const isPromQLMode = panelData?.queryType === "promql"; - if ( - !isPromQLMode && - !panelData?.queries?.[0]?.customQuery && - isFieldsValidationRequired - ) { - // Validate fields configuration based on chart type - validateChartFieldsConfiguration( - panelData?.type, - panelData?.queries?.[queryIndex]?.fields ?? {}, - errors, - currentXLabel, - currentYLabel, - pageKey, - ); - } -}; - -/** - * Validates that queries aren't empty - * @param queries Array of queries to validate - * @param errors Array to collect error messages - * @param customMessage Optional custom error message - */ -const validateQueriesNotEmpty = ( - queries: any[] = [], - errors: string[], - customMessage?: string, -) => { - queries.forEach((q: any, index: number) => { - if (q && q?.query === "") { - errors.push(customMessage || `Query-${index + 1} is empty`); - } - }); -}; - -/** - * Validates that a content field isn't empty - * @param content Content field to validate - * @param errors Array to collect error messages - * @param errorMessage Error message to add if validation fails - */ -const validateContentNotEmpty = ( - content: string = "", - errors: string[], - errorMessage: string, -) => { - if (content.trim() === "") { - errors.push(errorMessage); - } -}; - -/** - * Validates panel content based on panel type - * @param panel The panel to validate - * @param errors Array to collect error messages - */ -const validatePanelContentByType = (panel: any, errors: string[]) => { - // Check for promQL query type - if (panel?.queryType === "promql") { - validateQueriesNotEmpty(panel?.queries, errors); - } - - // Check by panel type - switch (panel?.type) { - case "geomap": - validateQueriesNotEmpty(panel?.queries, errors); - break; - case "html": - validateContentNotEmpty( - panel?.htmlContent, - errors, - "Please enter your HTML code", - ); - break; - case "markdown": - validateContentNotEmpty( - panel?.markdownContent, - errors, - "Please enter your markdown code", - ); - break; - case "custom_chart": - validateQueriesNotEmpty( - [panel?.queries?.[0]], - errors, - "Please enter query for custom chart", - ); - break; - } -}; - -const validateJoinField = (join: any, errors: string[], joinIndex: number) => { - // validate stream - if (!join?.stream) { - errors.push(`Join #${joinIndex + 1}: Stream is required`); - } - - // validate join type - if (!join?.joinType) { - errors.push(`Join #${joinIndex + 1}: Join type is required`); - } - - // validate clauses - // at least one clause is required - // and each clause should have leftField, rightField, operation - if (!join?.conditions || join?.conditions?.length === 0) { - errors.push(`Join #${joinIndex + 1}: At least one clause is required`); - } - - // validate each clause - join?.conditions?.forEach((condition: any, conditionIndex: number) => { - // validate leftField - if (!condition?.leftField?.field) { - errors.push( - `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Left field is required`, - ); - } - - // validate rightField - if (!condition?.rightField?.field) { - errors.push( - `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Right field is required`, - ); - } - - // validate operation - if (!condition?.operation) { - errors.push( - `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Operation is required`, - ); - } - }); -}; - -const validateJoinFields = (joins: any, errors: string[]) => { - // validate join fields - if (joins) { - joins.forEach((join: any, index: number) => - validateJoinField(join, errors, index), - ); - } -}; - -/** - * Validates panel fields without validating stream field existence - * - * @param panel The panel to validate - * @param errors Array to collect error messages - */ -const validatePanelFields = (panel: any, errors: string[] = []) => { - // Check if panel has promQL query type - const isPromQLMode = panel?.queryType === "promql" || panel?.queryType === "promql-builder"; - const currentQueryIndex = 0; // Default to first query - - // Validate panel content based on type - validatePanelContentByType(panel, errors); - - // validate fields if not promQL mode and customQuery is false - if ( - !isPromQLMode && - !panel?.queries?.[currentQueryIndex]?.customQuery && - panel.queries?.[currentQueryIndex]?.fields - ) { - // Validate fields configuration based on chart type - validateChartFieldsConfiguration( - panel?.type, - panel?.queries?.[currentQueryIndex]?.fields ?? {}, - errors, - ); - - // Check filter conditions validity - if ( - panel?.queries?.[currentQueryIndex]?.fields?.filter?.conditions?.length - ) { - // Validate the conditions - validateConditions( - panel?.queries?.[currentQueryIndex]?.fields?.filter?.conditions ?? [], - errors, - ); - } - } - - return errors; -}; - -/** - * Validates an individual panel's content - * Only checks basic structure, used by validateDashboardJson - * - * @param panel The panel object to validate - * @returns Array of validation errors - */ -const validatePanelContent = (panel: any): string[] => { - const errors: string[] = []; - - // Required fields validation - if (!panel?.type) { - errors.push(`Panel ${panel?.id}: Panel type is required`); - return errors; - } - - // Check if panel type is in the allowed types list - const allowedTypes = [ - "area", - "line", - "bar", - "scatter", - "area-stacked", - "donut", - "pie", - "h-bar", - "stacked", - "h-stacked", - "heatmap", - "metric", - "gauge", - "geomap", - "maps", - "table", - "sankey", - "custom_chart", - "html", - "markdown", - ]; - - if (!allowedTypes.includes(panel?.type)) { - errors.push( - `Panel ${panel?.id}: Chart type "${panel?.type}" is not supported.`, - ); - } - - if (!panel?.title) { - errors.push(`Panel ${panel?.id}: Panel title is required`); - } - - // Layout validation - if (!panel?.layout) { - errors.push(`Panel ${panel?.id}: Layout is required`); - } else { - if (typeof panel?.layout?.x !== "number") - errors.push(`Panel ${panel?.id}: Layout x must be a number`); - if (typeof panel?.layout?.y !== "number") - errors.push(`Panel ${panel?.id}: Layout y must be a number`); - if (typeof panel?.layout?.w !== "number") - errors.push(`Panel ${panel?.id}: Layout w must be a number`); - if (typeof panel?.layout?.h !== "number") - errors.push(`Panel ${panel?.id}: Layout h must be a number`); - } - - return errors; -}; - -/** - * Validates a dashboard panel's configuration - * @param {object} panelData - The panel data object to validate - * @param {array} errors - Array to collect errors - * @param {boolean} isFieldsValidationRequired - Whether to validate fields (default: true) - * @returns {array} An array of validation error messages - */ -export const validatePanel = ( - panelData: any, - errors: string[] = [], - isFieldsValidationRequired: boolean = true, - allStreamFields: any[] = [], - pageKey: string = "dashboard", - store: any, - checkTimestampAlias: any, -) => { - // Get current query index - const currentQueryIndex = panelData?.layout?.currentQueryIndex || 0; - - // Check if panel has promQL query type - const isPromQLMode = panelData?.data?.queryType === "promql" || panelData?.data?.queryType === "promql-builder"; - - // Validate panel content based on type - validatePanelContentByType(panelData?.data, errors); - - // Validate timestamp alias for SQL queries with custom query mode - if (panelData?.data?.queryType === "sql") { - const timestampColumn = - store.state.zoConfig.timestamp_column || "_timestamp"; - - panelData?.data?.queries?.forEach((queryObj: any, index: number) => { - if (queryObj?.query && queryObj?.customQuery) { - if (!checkTimestampAlias(queryObj.query)) { - errors.push( - `Alias '${timestampColumn}' is not allowed.`, - ); - } - } - }); - } - - if (isPromQLMode) { - // 1. Chart type: only specific chart types are supported for PromQL - const allowedChartTypes = [ - "area", - "line", - "bar", - "scatter", - "area-stacked", - "metric", - "gauge", - "html", - "markdown", - "custom_chart", - "table", - "maps", - "heatmap", - "geomap", - "donut", - "pie", - "h-bar", - "stacked", - "h-stacked", - ]; - if (!allowedChartTypes.includes(panelData?.data?.type)) { - errors.push( - "Selected chart type is not supported for PromQL. Only line chart is supported.", - ); - } - - // 2. x axis, y axis, filters should be blank for PromQL - if (panelData?.data?.queries?.[currentQueryIndex]?.fields?.x?.length > 0) { - errors.push( - "X-Axis is not supported for PromQL. Remove anything added to the X-Axis.", - ); - } - - if (panelData?.data?.queries?.[currentQueryIndex]?.fields?.y?.length > 0) { - errors.push( - "Y-Axis is not supported for PromQL. Remove anything added to the Y-Axis.", - ); - } - - if ( - panelData?.data?.queries?.[currentQueryIndex]?.fields?.filter?.conditions - ?.length > 0 - ) { - errors.push( - "Filters are not supported for PromQL. Remove anything added to the Filters.", - ); - } - } else { - // Calculate the x and y axis labels based on chart type - const currentXLabel = - panelData?.data?.type === "table" - ? "First Column" - : panelData?.data?.type === "h-bar" - ? "Y-Axis" - : "X-Axis"; - - const currentYLabel = - panelData?.data?.type === "table" - ? "Other Columns" - : panelData?.data?.type === "h-bar" - ? "X-Axis" - : "Y-Axis"; - - // Validate panel fields based on chart type - validateSQLPanelFields( - panelData?.data, - currentQueryIndex, - currentXLabel, - currentYLabel, - errors, - isFieldsValidationRequired, - pageKey, - ); - - // validate join fields - validateJoinFields( - panelData?.data?.queries?.[currentQueryIndex]?.joins, - errors, - ); - } - - return errors; -}; - -/** - * Validates the dashboard JSON structure - * - * @param dashboardJson The dashboard JSON to validate - * @returns Array of validation errors or empty array if valid - */ -export const validateDashboardJson = (dashboardJson: any): string[] => { - const errors: string[] = []; - - // Basic structure validation - if (!dashboardJson) { - errors.push("Dashboard JSON is empty or invalid"); - return errors; - } - - // Required fields validation - if (!dashboardJson?.dashboardId) { - errors.push("Dashboard ID is required"); - } - - if (!dashboardJson?.title) { - errors.push("Dashboard title is required"); - } - - // Version should be present and match current schema version - if (!dashboardJson?.version) { - errors.push("Dashboard version is required"); - } else if (dashboardJson.version !== CURRENT_DASHBOARD_SCHEMA_VERSION) { - errors.push( - `Dashboard version must be ${CURRENT_DASHBOARD_SCHEMA_VERSION}.`, - ); - } - - // Check tabs - if ( - !Array.isArray(dashboardJson?.tabs) || - dashboardJson?.tabs?.length === 0 - ) { - errors.push("Dashboard must have at least one tab"); - return errors; - } - - // Check for unique tab IDs - const tabIds = new Set(); - for (const tab of dashboardJson?.tabs) { - if (!tab?.tabId) { - errors.push("Each tab must have a tabId"); - } else if (tabIds.has(tab?.tabId)) { - errors.push(`Duplicate tab ID found: ${tab?.tabId}`); - } else { - tabIds.add(tab?.tabId); - } - - if (!tab?.name) { - errors.push(`Tab ${tab?.tabId} must have a name`); - } - } - - // Check for unique panel IDs across all tabs and validate each panel - const panelIds = new Set(); - const layoutIValues = new Map>(); - - for (const tab of dashboardJson.tabs) { - if (!Array.isArray(tab?.panels)) { - errors.push(`Tab ${tab?.tabId} must have a panels array`); - continue; - } - - // Create a set for layout i values for this tab - layoutIValues.set(tab?.tabId, new Set()); - - for (const panel of tab.panels) { - // Check panel ID uniqueness - if (!panel?.id) { - errors.push(`Panel in tab ${tab?.tabId} is missing an ID`); - } else if (panelIds.has(panel?.id)) { - errors.push(`Duplicate panel ID found: ${panel?.id}`); - } else { - panelIds.add(panel?.id); - } - - // Check layout i value uniqueness within the tab - if (!panel?.layout || !panel?.layout?.i) { - errors.push(`Panel ${panel?.id} is missing a layout.i value`); - } else { - const tabLayoutValues = layoutIValues.get(tab?.tabId); - if ( - tabLayoutValues && - tabLayoutValues.has(panel?.layout?.i?.toString()) - ) { - errors.push( - `Duplicate layout.i value found in tab ${tab?.tabId}: ${panel?.layout?.i}`, - ); - } else if (tabLayoutValues) { - tabLayoutValues.add(panel?.layout?.i?.toString()); - } - } - - // Validate basic panel structure - const panelStructureErrors = validatePanelContent(panel); - errors.push(...panelStructureErrors); - - // Validate panel fields but skip stream validation - if (panel?.type !== "markdown" && panel?.type !== "html") { - try { - const panelDetailErrors: string[] = []; - - // Only validate the panel fields (not stream field existence) - validatePanelFields(panel, panelDetailErrors); - - // Add panel identifier to each error - const prefixedErrors = panelDetailErrors.map( - (error) => `Panel ${panel?.id || "unknown"}: ${error}`, - ); - - errors.push(...prefixedErrors); - } catch (error) { - // If validation fails - errors.push( - `Panel ${panel?.id || "unknown"}: ${ - error instanceof Error - ? error?.message - : "Unable to validate panel configuration" - }`, - ); - } - } - } - } - - return errors; -}; - -// 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"; - } -}; - -// 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 -}; -/** - * 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 - } -}; diff --git a/web/src/utils/dashboard/convertPromQLData.spec.ts b/web/src/utils/dashboard/convertPromQLData.spec.ts index 73efe272df..d3239a398f 100644 --- a/web/src/utils/dashboard/convertPromQLData.spec.ts +++ b/web/src/utils/dashboard/convertPromQLData.spec.ts @@ -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)), })); diff --git a/web/src/utils/dashboard/convertPromQLData.ts b/web/src/utils/dashboard/convertPromQLData.ts index 0467ab05c6..bf068be57d 100644 --- a/web/src/utils/dashboard/convertPromQLData.ts +++ b/web/src/utils/dashboard/convertPromQLData.ts @@ -14,15 +14,19 @@ // along with this program. If not, see . 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", - }; - } -}; diff --git a/web/src/utils/dashboard/convertSQLData.spec.ts b/web/src/utils/dashboard/convertSQLData.spec.ts index cf3112d500..0cb1dbe982 100644 --- a/web/src/utils/dashboard/convertSQLData.spec.ts +++ b/web/src/utils/dashboard/convertSQLData.spec.ts @@ -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: [ diff --git a/web/src/utils/dashboard/convertSQLData.ts b/web/src/utils/dashboard/convertSQLData.ts index 51e89b07b1..cb00cdfb12 100644 --- a/web/src/utils/dashboard/convertSQLData.ts +++ b/web/src/utils/dashboard/convertSQLData.ts @@ -21,93 +21,7 @@ * @param {any} store - the store object * @return {Object} - the options object for rendering the chart */ -import { toZonedTime } from "date-fns-tz"; -import { dateBin } from "@/utils/dashboard/datetimeStartPoint"; -import { format } from "date-fns"; -import { - calculateOptimalFontSize, - calculateWidthText, - formatDate, - formatUnitValue, - getContrastColor, - applySeriesColorMappings, - getUnitValue, - isTimeSeries, - isTimeStamp, - calculateDynamicNameGap, - calculateRotatedLabelBottomSpace, -} from "./convertDataIntoUnitValue"; -import { - calculateGridPositions, - getTrellisGrid, -} from "./calculateGridForSubPlot"; -import { isGivenFieldInOrderBy } from "../query/sqlUtils"; -import { - ColorModeWithoutMinMax, - getSeriesColor, - getSQLMinMaxValue, -} from "./colorPalette"; -import { deepCopy } from "@/utils/zincutils"; -import { type SeriesObject } from "@/ts/interfaces/dashboard"; -import { getDataValue } from "./aliasUtils"; -import { getAnnotationsData } from "@/utils/dashboard/getAnnotationsData"; -import { - createBaseLegendConfig, - applyLegendConfiguration, - applyPieDonutChartAlignment, - getChartDimensions, - applyPieDonutCenterAdjustment, - calculateChartDimensions, - calculatePieChartRadius, - shouldApplyChartAlignment, - generateChartAlignmentProperties, -} from "./legendConfiguration"; - -/** - * Calculates chart container properties for pie/donut charts based on legend position and chart alignment - * @param {any} panelSchema - The panel schema containing configuration - * @param {number} chartWidth - Width of the chart container - * @param {number} chartHeight - Height of the chart container - * @param {any[]} seriesData - Series data for legend calculations - * @returns {object} Object containing grid properties and available dimensions - */ -const calculatePieChartContainer = ( - panelSchema: any, - chartWidth: number, - chartHeight: number, - seriesData: any[] = [], -) => { - const chartAlign = panelSchema.config?.chart_align; - const legendPosition = panelSchema.config?.legends_position; - - // Calculate available space using our centralized helper function - const dimensions = calculateChartDimensions( - panelSchema, - chartWidth, - chartHeight, - seriesData, - ); - - // Determine if chart alignment should be applied - const shouldApplyAlignment = shouldApplyChartAlignment( - panelSchema, - seriesData, - ); - - // Generate CSS grid properties for chart alignment - const gridProperties = generateChartAlignmentProperties( - chartAlign, - legendPosition, - shouldApplyAlignment, - ); - - return { - gridProperties, - availableWidth: dimensions.availableWidth, - availableHeight: dimensions.availableHeight, - shouldUseGridAlignment: shouldApplyAlignment, - }; -}; +import { convertSQLChartData } from "./sql"; export const convertMultiSQLData = async ( panelSchema: any, @@ -185,3192 +99,16 @@ export const convertSQLData = async ( chartPanelStyle: any, annotations: any, ) => { - // 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 - ) { - // console.timeEnd("convertSQLData"); - return { options: 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(); - - /** - * 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. - * @returns {any[]} - The processed data array. - */ - const processData = (data: any[], panelSchema: 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, item) => { - 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) => { - 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; - }; - - 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); - - const missingValue = () => { - // 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 = [ - ...getXAxisKeys(), - ...getYAxisKeys(), - ...getZAxisKeys(), - ...getBreakDownKeys(), - ]; - 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 = getXAxisKeys().filter( - (key: any) => key !== timeBasedKey, - ); - const breakdownAxisKeysWithoutTimeStamp = getBreakDownKeys().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 = { - [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 = { - [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; - }; - - const missingValueData = missingValue(); - // flag to check if the data is time series - let isTimeSeriesFlag = false; - - // 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; - }; - - 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 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); - }; - - /** - * 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 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"; - } - }; - - 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 [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( - `${it.marker} ${it.seriesName} : ${formatUnitValue( - getUnitValue( - it.value, - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )} `, - ); - // 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}
${hoverText.join("
")}`; - } 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); - } - } - - // ------- Series Building Methods ----------- - - /** - * 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. - * - * @param breakDownKeys - Array of strings representing x-axis keys - * @returns Array of unique values for 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 = [ - ...new Set( - missingValueData.map((obj: any) => getDataValue(obj, breakDownKey)), - ), - ].filter((value: any) => value != null || value != undefined); - - return uniqueValues; - } - - 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 { markLines, markAreas } = getAnnotationsData( - annotations, - store.state.timezone, - ); - - 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, - }; - }; - - // Build 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 - const buildDataLookupMap = () => { - const dataMap = new Map(); - - 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; - }; - - const dataLookupMap = buildDataLookupMap(); - - 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 = [], - seriesConfig: Record, - 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 = {}) => { - let stackedXAxisUniqueValue = []; - 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() || [] - ); - }; - - // ------- End of Series Building Methods ----------- - - // 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": { - //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); - const xAxisLabelWidth = panelSchema.config?.axis_label_truncate_width || 120; - 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])), - ); - // options.xAxis[0].data = Array.from(new Set(options.xAxis[0].data)); - } 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 ( - 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 (it.data != null) { - // check if the series is the current series being hovered - // if have than bold it - if ( - it?.seriesName == hoveredSeriesState?.value?.hoveredSeriesName - ) - hoverText.push( - `${it.marker} ${it.seriesName} : ${formatUnitValue( - getUnitValue( - it.data, - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )} `, - ); - // 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}
${hoverText.join("
")}`; - } 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(); - - break; - } - case "h-bar": { - //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; - } - - break; - } - case "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} : ${formatUnitValue( - getUnitValue( - name?.value, - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )}`; - } 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 = []; - break; - } - case "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} : ${formatUnitValue( - getUnitValue( - name?.value, - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )}`; - } 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 = []; - break; - } - case "stacked": { - 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 }); - - break; - } - case "heatmap": { - // 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(); - - 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 - }
${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; - break; - } - case "h-stacked": { - 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; - - break; - } - case "metric": { - 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 ""; - } - }, - }, - ]; - break; - } - case "gauge": { - const key1 = yAxisKeys[0]; - const yAxisValue = getAxisDataFromKey(key1); - // used for gague 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 gague 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, - }, - }; - }); - break; - } - - default: { - break; - } - } - - // 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) { - // set timeseries flag as a true - isTimeSeriesFlag = true; - - // 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( - `${it.marker} ${it.seriesName} : ${formatUnitValue( - getUnitValue( - it.data[1], - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )} `, - ); - // 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)}
${hoverText.join("
")}`; - }; - - 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 ""; - } - }, - }; - } - } - - //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) { - // set timeseries flag as a true - isTimeSeriesFlag = true; - - 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( - `${it?.marker} ${it?.seriesName} : ${formatUnitValue( - getUnitValue( - it?.data?.[1], - panelSchema.config?.unit, - panelSchema.config?.unit_custom, - panelSchema.config?.decimals, - ), - )} `, - ); - // 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)}
${hoverText.join("
")}`; - } 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 ""; - } - }, - }; - } - } - - // 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( + return convertSQLChartData( panelSchema, + searchQueryData, + store, chartPanelRef, hoveredSeriesState, - options, + resultMetaData, + metadata, + chartPanelStyle, + annotations, ); - //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, - }, - }; -}; - -/** - * Finds the largest label in the given data array. - * - * @param {any[]} data - An array of data. - * @return {any} The largest label in the data array. - */ -const largestLabel = (data: any) => { - const largestlabel = data.reduce((largest: any, label: any) => { - return label?.toString().length > largest?.toString().length - ? label - : largest; - }, ""); - - return largestlabel; -}; - - - -const showTrellisConfig = (type: string) => { - const supportedTypes = new Set(["area", "bar", "h-bar", "line", "scatter"]); - return supportedTypes.has(type); -}; - -/** - * Retrieves the properties for a given chart type and returns them as an object. - * - * @param {string} type - The type of chart. - * @return {Object} - The properties for the given chart type. - */ -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", - }; - } }; diff --git a/web/src/utils/dashboard/convertTableData.spec.ts b/web/src/utils/dashboard/convertTableData.spec.ts index 1e9d978e75..fac626746a 100644 --- a/web/src/utils/dashboard/convertTableData.spec.ts +++ b/web/src/utils/dashboard/convertTableData.spec.ts @@ -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), })); diff --git a/web/src/utils/dashboard/convertTableData.ts b/web/src/utils/dashboard/convertTableData.ts index 402a946eef..8464ac3dd8 100644 --- a/web/src/utils/dashboard/convertTableData.ts +++ b/web/src/utils/dashboard/convertTableData.ts @@ -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 diff --git a/web/src/utils/dashboard/dateTimeUtils.spec.ts b/web/src/utils/dashboard/dateTimeUtils.spec.ts new file mode 100644 index 0000000000..4a997bb6fc --- /dev/null +++ b/web/src/utils/dashboard/dateTimeUtils.spec.ts @@ -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 . + +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); + }); + }); +}); diff --git a/web/src/utils/dashboard/dateTimeUtils.ts b/web/src/utils/dashboard/dateTimeUtils.ts new file mode 100644 index 0000000000..f6e72b0dd5 --- /dev/null +++ b/web/src/utils/dashboard/dateTimeUtils.ts @@ -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 +}; diff --git a/web/src/utils/dashboard/legendConfiguration.spec.ts b/web/src/utils/dashboard/legendConfiguration.spec.ts index eaab1ae8dc..ac9f3813ea 100644 --- a/web/src/utils/dashboard/legendConfiguration.spec.ts +++ b/web/src/utils/dashboard/legendConfiguration.spec.ts @@ -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; diff --git a/web/src/utils/dashboard/legendConfiguration.ts b/web/src/utils/dashboard/legendConfiguration.ts index 9d7856858a..866d9acc7f 100644 --- a/web/src/utils/dashboard/legendConfiguration.ts +++ b/web/src/utils/dashboard/legendConfiguration.ts @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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, diff --git a/web/src/utils/dashboard/panelValidation.spec.ts b/web/src/utils/dashboard/panelValidation.spec.ts new file mode 100644 index 0000000000..e5b4922372 --- /dev/null +++ b/web/src/utils/dashboard/panelValidation.spec.ts @@ -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 . + +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); + }); + }); +}); diff --git a/web/src/utils/dashboard/panelValidation.ts b/web/src/utils/dashboard/panelValidation.ts new file mode 100644 index 0000000000..08bdd47a4e --- /dev/null +++ b/web/src/utils/dashboard/panelValidation.ts @@ -0,0 +1,1151 @@ +import { CURRENT_DASHBOARD_SCHEMA_VERSION } from "@/utils/dashboard/convertDashboardSchemaVersion"; +import functionValidation from "@/components/dashboards/addPanel/dynamicFunction/functionValidation.json"; + +// will find first valid mapped value based on given fieldToCheck +export const findFirstValidMappedValue = ( + value: any, + mappings: any[], + fieldToCheck: string, +) => { + return mappings?.find((v: any) => { + let isMatch = false; + + // Check based on type + if (v?.type == "value") { + isMatch = v?.value == value; + } else if (v?.type == "range") { + if ( + v?.from && + v?.to && + !Number.isNaN(+v?.from) && + !Number.isNaN(+v?.to) + ) { + isMatch = +v?.from <= +value && +v?.to >= +value; + } + } else if (v?.type == "regex") { + isMatch = new RegExp(v?.pattern ?? "").test(value); + } + + // If a match is found, check if the required field (color or text) is valid + if (isMatch && v[fieldToCheck]) { + return true; + } + + return false; + }); +}; + +/** + * Validates a single condition item + * @param condition The condition to validate + * @param errors Array to collect errors + */ +const validateConditionItem = (condition: any, errors: string[]) => { + if (condition.type === "list" && !condition.values?.length) { + errors.push( + `Filter: ${condition.column}: Select at least 1 item from the list`, + ); + } + + if (condition.type === "condition") { + if (condition.operator == null) { + errors.push(`Filter: ${condition.column}: Operator selection required`); + } + + if ( + !["Is Null", "Is Not Null"].includes(condition.operator) && + (condition.value == null || condition.value == "") + ) { + errors.push(`Filter: ${condition.column}: Condition value required`); + } + } +}; + +/** + * Validates a field-type argument + * @param arg The argument to validate + * @param fieldPath Path for error messages + * @param index Argument index + * @param errors Array to collect errors + */ +const validateFieldArgument = ( + arg: any, + fieldPath: string, + index: number, + errors: string[], +) => { + if (!arg.value || typeof arg.value !== "object" || !("field" in arg.value)) { + errors.push( + `${fieldPath}: Argument ${index + 1} is a field but haven't selected any field`, + ); + } +}; + +/** + * Validates a number-type argument + * @param arg The argument to validate + * @param fieldPath Path for error messages + * @param index Argument index + * @param errors Array to collect errors + */ +const validateNumberArgument = ( + arg: any, + fieldPath: string, + index: number, + errors: string[], +) => { + if (arg.value === null || arg.value === undefined || arg.value === "") { + errors.push( + `${fieldPath}: Argument ${index + 1} is a number but no value entered`, + ); + } else if (typeof arg.value !== "number" || isNaN(arg.value)) { + errors.push(`${fieldPath}: Argument ${index + 1} must be a valid number`); + } +}; + +/** + * Validates a string-type argument + * @param arg The argument to validate + * @param fieldPath Path for error messages + * @param index Argument index + * @param errors Array to collect errors + */ +const validateStringArgument = ( + arg: any, + fieldPath: string, + index: number, + errors: string[], +) => { + if (arg.value === null || arg.value === undefined) { + errors.push( + `${fieldPath}: Argument ${index + 1} is a string but no value entered`, + ); + } else if (typeof arg.value !== "string" || arg.value.trim() === "") { + errors.push( + `${fieldPath}: Argument ${index + 1} must be a non-empty string`, + ); + } +}; + +/** + * Validates a histogramInterval-type argument + * @param arg The argument to validate + * @param fieldPath Path for error messages + * @param index Argument index + * @param errors Array to collect errors + */ +const validateHistogramIntervalArgument = ( + arg: any, + fieldPath: string, + index: number, + errors: string[], +) => { + // if arg value is null, value not present or not a string + if (!(arg.value === null || !arg.value || typeof arg.value === "string")) { + errors.push( + `${fieldPath}: Argument ${index + 1} must be a valid histogram interval`, + ); + } +}; + +/** + * Validate the filters in the panel + * @param conditions the conditions array + * @param errors the array to push the errors to + */ +function validateConditions(conditions: any, errors: any) { + conditions.forEach((it: any) => { + if (it.filterType === "condition") { + validateConditionItem(it, errors); + } else if (it.filterType === "group") { + // Recursively validate the conditions in the group + validateConditions(it.conditions, errors); + } + }); +} + +/** + * Validates a function and its nested function arguments, or validates raw query fields + * + * Handles the following validation scenarios: + * + * 1. **Raw Query Fields**: Fields with `type: "raw"` must have non-empty rawQuery + * Example: Custom SQL query field must have valid query string + * + * 2. **Required Arguments**: Arguments with `required: true` must be present + * Example: count(field) - field is required + * + * 3. **Optional Arguments**: Arguments with `required: false` can be omitted + * Example: substring(field, start, length?) - length is optional + * Example: from_unixtime(timestamp, format?) - format is optional + * + * 4. **Variable Arguments**: Functions with `allowAddArgAt` can accept N arguments + * - `allowAddArgAt: "n"` means position 0 can repeat infinitely + * Example: concat(arg1, arg2, arg3, ..., argN) with min=2 + * - `allowAddArgAt: "n-1"` means (argsLength-1) position can repeat + * - Combined with `min` property to enforce minimum arg count + * + * 5. **Nested Functions**: Arguments with type "function" are validated recursively + * Example: sum(count(field)) - both sum and count are validated + * Example: concat(upper(field1), lower(field2)) - all 3 functions validated + * + * 6. **Type Validation**: Each argument type is validated against allowed types + * - field: Must have valid field selection + * - function: Recursively validated + * - number: Must be valid number + * - string: Must be non-empty string + * - histogramInterval: Must be valid interval string + * + * @param funcConfig - The function configuration to validate + * @param fieldPath - Path for error messages (e.g., "Field", "Field → Arg 2") + * @param errors - Array to collect errors + */ +const validateFunction = ( + funcConfig: any, + fieldPath: string, + errors: string[], +) => { + // Handle raw query fields + if (funcConfig.type === "raw") { + if ( + !funcConfig.rawQuery || + typeof funcConfig.rawQuery !== "string" || + funcConfig.rawQuery.trim() === "" + ) { + errors.push(`${fieldPath}: Raw query cannot be empty`); + } + return; + } + + // Get the selected function schema + const selectedFunction: any = functionValidation?.find( + (fn: any) => fn?.functionName === (funcConfig?.functionName ?? null), + ); + + // If function is not found, push error + if (!selectedFunction) { + errors.push(`${fieldPath}: Invalid aggregation function`); + return; // Skip further validation if function is invalid + } + + // Check if args are valid based on selected function schema + const args = funcConfig.args || []; + const argsDefinition = selectedFunction.args || []; + + // OPTIONAL ARGUMENTS: Handled by "required": false in argDefinition + // VARIABLE ARGUMENTS: Handled by "allowAddArgAt" property + // Examples: + // - concat: allowAddArgAt="n" with min=2 (can have 2+ args) + // - substring: 3rd arg has required=false (optional) + const allowAddArgAtValue = selectedFunction.allowAddArgAt; + const hasVariableArgs = !!allowAddArgAtValue; + + // Parse the allowAddArgAt value to determine variable argument position + // "n" means position 0 (all args can repeat) + // "n-1" means (argsLength - 1) position + // "n-2" means (argsLength - 2) position + let variableArgPosition = -1; + if (hasVariableArgs) { + if (allowAddArgAtValue === "n") { + variableArgPosition = 0; // All arguments can be variable + } else if (allowAddArgAtValue.startsWith("n-")) { + // Format is "n-1", "n-2", etc. + const offset = parseInt(allowAddArgAtValue.substring(2)); + variableArgPosition = argsDefinition.length - offset; + } + } + + // Special handling for functions with min requirements + // Find the argDefinition that has the min property + const minArgDef = argsDefinition.find((def: any) => "min" in def); + const minPosition = minArgDef ? argsDefinition.indexOf(minArgDef) : -1; + + // If min is specified and position is valid, check the requirement + if (minArgDef && minPosition !== -1) { + // For variable args, we count all arguments from the variable position + const relevantArgsCount = + hasVariableArgs && variableArgPosition <= minPosition + ? args.length - variableArgPosition + 1 // +1 because we count the variable position itself + : args.length; + + if (relevantArgsCount < minArgDef.min) { + errors.push(`${fieldPath}: Requires at least ${minArgDef.min} arguments`); + } + } + + // Validate all provided arguments have correct types + args.forEach((arg: any, index: number) => { + // Skip null/undefined args only if they're optional + if (!arg) { + // Check if this position is required + const isOptional = + index < argsDefinition.length && !argsDefinition[index]?.required; + if (!isOptional && !hasVariableArgs) { + // This is a required arg that's missing - will be caught in "missing required" check below + } + return; + } + + // Determine which arg definition to use for validation + let argDefIndex = index; + + // For variable arguments + if (hasVariableArgs && index >= variableArgPosition) { + // Use the definition at the variable position + argDefIndex = variableArgPosition; + } + + // Handle out-of-bounds index for non-variable args or unknown formats + if (argDefIndex >= argsDefinition.length) { + if (!hasVariableArgs) { + errors.push(`${fieldPath}: Too many arguments provided`); + return; + } + // Default to the variable argument definition + argDefIndex = variableArgPosition; + } + + const allowedTypes = argsDefinition[argDefIndex].type.map( + (t: any) => t.value, + ); + + // Check if current argument type is among the allowed types + if (arg && !allowedTypes.includes(arg.type)) { + errors.push( + `${fieldPath}: Argument ${index + 1} has invalid type (expected: ${allowedTypes.join(" or ")})`, + ); + return; + } + + // Handle different argument types + if (arg.type === "field") { + validateFieldArgument(arg, fieldPath, index, errors); + } else if (arg.type === "function") { + // RECURSIVE VALIDATION: If argument is a function, validate it recursively + if (!arg.value || typeof arg.value !== "object") { + errors.push( + `${fieldPath}: Argument ${index + 1} is a function but has invalid structure`, + ); + } else { + // Recursively validate the nested function + const nestedPath = `${fieldPath} → Arg ${index + 1}`; + validateFunction(arg.value, nestedPath, errors); + } + } else if (arg.type === "number") { + validateNumberArgument(arg, fieldPath, index, errors); + } else if (arg.type === "string") { + validateStringArgument(arg, fieldPath, index, errors); + } else if (arg.type === "histogramInterval") { + validateHistogramIntervalArgument(arg, fieldPath, index, errors); + } + }); + + // Check for missing required arguments + // This validates: + // 1. Required args that are missing (required: true) + // 2. Optional args are allowed to be missing (required: false) + // 3. Variable args beyond the first instance are allowed (allowAddArgAt) + argsDefinition.forEach((argDef: any, index: number) => { + // Skip checking variable arg positions beyond the first instance + // Example: concat(arg1, arg2, arg3, ...) - only check first 2, rest are variable + if (hasVariableArgs && index > variableArgPosition) return; + + // Check if required argument is missing or null/undefined + if (argDef.required && (index >= args.length || !args[index])) { + errors.push( + `${fieldPath}: Missing required argument at position ${index + 1}`, + ); + } + }); +}; + +/** + * Shared validation logic for panel field configuration based on chart type + * + * @param chartType The type of chart being validated + * @param fields The fields configuration to validate + * @param errors Array to collect error messages + * @param xAxisLabel Optional custom label for X-Axis in error messages + * @param yAxisLabel Optional custom label for Y-Axis in error messages + */ +const validateChartFieldsConfiguration = ( + chartType: string, + fields: any, + errors: string[], + xAxisLabel: string = "X-Axis", + yAxisLabel: string = "Y-Axis", + pageKey: string = "dashboard", +) => { + if (!chartType || !fields) { + return; + } + + switch (chartType) { + case "donut": + case "pie": { + if (fields?.y?.length > 1 || fields?.y?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one value field for donut and pie charts"; + errors.push(errorMsg); + } + + if (fields?.x?.length > 1 || fields?.x?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract label field from the query." + : "Add one label field for donut and pie charts"; + errors.push(errorMsg); + } + break; + } + case "metric": { + if (fields?.y?.length > 1 || fields?.y?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one value field for metric charts"; + errors.push(errorMsg); + } + + if (fields?.x?.length) { + const errorMsg = + pageKey === "logs" + ? "Grouping field is not allowed for Metric chart" + : `${xAxisLabel} field is not allowed for Metric chart`; + errors.push(errorMsg); + } + break; + } + case "gauge": { + if (fields?.y?.length !== 1) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one value field for gauge chart"; + errors.push(errorMsg); + } + // gauge can have zero or one label + if (fields?.x?.length !== 1 && fields?.x?.length !== 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract grouping field from the query." + : "Add one label field for gauge chart"; + errors.push(errorMsg); + } + break; + } + case "h-bar": + case "area": + case "line": + case "scatter": + case "bar": { + if (fields?.y?.length < 1) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : `Add at least one field for the ${yAxisLabel}`; + errors.push(errorMsg); + } + + if (fields?.x?.length > 1 || fields?.x?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract grouping field from the query." + : `Add one fields for the ${xAxisLabel}`; + errors.push(errorMsg); + } + break; + } + case "table": { + if (fields?.y?.length === 0 && fields?.x?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract fields from the query." + : `Add at least one field on ${xAxisLabel} or ${yAxisLabel}`; + errors.push(errorMsg); + } + break; + } + case "heatmap": { + if (fields?.y?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract grouping field from the query." + : `Add at least one field for the ${yAxisLabel}`; + errors.push(errorMsg); + } + + if (fields?.x?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract second level grouping field from the query." + : `Add one field for the ${xAxisLabel}`; + errors.push(errorMsg); + } + + if (fields?.z?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one field for the Z-Axis"; + errors.push(errorMsg); + } + break; + } + case "stacked": + case "h-stacked": { + if (fields?.y?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : `Add at least one field for the ${yAxisLabel}`; + errors.push(errorMsg); + } + if (fields?.x?.length !== 1 || fields?.breakdown?.length !== 1) { + const breakdownErrMsg = + pageKey === "logs" + ? "Unable to extract grouping field from the query." + : `Add exactly one field on the ${xAxisLabel} and breakdown for stacked and h-stacked charts`; + errors.push(breakdownErrMsg); + } + break; + } + case "area-stacked": { + if (fields?.y?.length > 1 || fields?.y?.length === 0) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : `Add exactly one field on ${yAxisLabel} for area-stacked charts`; + errors.push(errorMsg); + } + if (fields?.x?.length !== 1 || fields?.breakdown?.length !== 1) { + const breakdownErrMsg = + pageKey === "logs" + ? "Unable to extract grouping field from the query." + : `Add exactly one field on the ${xAxisLabel} and breakdown for area-stacked charts`; + errors.push(breakdownErrMsg); + } + break; + } + case "geomap": { + if (fields?.latitude == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract latitude field from the query." + : "Add one field for the latitude"; + errors.push(errorMsg); + } + if (fields?.longitude == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract longitude field from the query." + : "Add one field for the longitude"; + errors.push(errorMsg); + } + break; + } + case "sankey": { + if (fields?.source == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract source field from the query." + : "Add one field for the source"; + errors.push(errorMsg); + } + if (fields?.target == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract target field from the query." + : "Add one field for the target"; + errors.push(errorMsg); + } + if (fields?.value == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one field for the value"; + errors.push(errorMsg); + } + break; + } + case "maps": { + if (fields?.name == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract name field from the query." + : "Add one field for the name"; + errors.push(errorMsg); + } + if (fields?.value_for_maps == null) { + const errorMsg = + pageKey === "logs" + ? "Unable to extract value field from the query." + : "Add one field for the value"; + errors.push(errorMsg); + } + break; + } + default: + break; + } + + // need to validate all the fields based on the selected aggregation function + // get all the fields that are not derived and type is build + const aggregationFunctionError = [ + ...(fields?.y ?? []), + ...(fields?.x ?? []), + ...(fields?.breakdown ?? []), + ...(fields?.z ?? []), + fields?.source ?? null, + fields?.target ?? null, + fields?.value ?? null, + fields?.name ?? null, + fields?.value_for_maps ?? null, + fields?.latitude ?? null, + fields?.longitude ?? null, + ]?.filter((it: any) => it && !it?.isDerived); + + if (aggregationFunctionError?.length) { + // loop on each fields config + // compare with function validation schema + // if validation fails, push error + aggregationFunctionError?.forEach((it: any) => { + const fieldPath = it.alias || "Field"; + validateFunction(it, fieldPath, errors); + }); + } +}; + +/** + * Validates the fields configuration for SQL panels + * @param {object} panelData - The panel data object + * @param {number} queryIndex - The current query index + * @param {string} currentXLabel - Label for X-Axis (for error messages) + * @param {string} currentYLabel - Label for Y-Axis (for error messages) + * @param {array} errors - Array to collect errors + * @param {boolean} isFieldsValidationRequired - Whether field validation is required + */ +export const validateSQLPanelFields = ( + panelData: any, + queryIndex: number, + currentXLabel: string, + currentYLabel: string, + errors: string[], + isFieldsValidationRequired: boolean = true, + pageKey?: string, +) => { + const isPromQLMode = panelData?.queryType === "promql"; + if ( + !isPromQLMode && + !panelData?.queries?.[0]?.customQuery && + isFieldsValidationRequired + ) { + // Validate fields configuration based on chart type + validateChartFieldsConfiguration( + panelData?.type, + panelData?.queries?.[queryIndex]?.fields ?? {}, + errors, + currentXLabel, + currentYLabel, + pageKey, + ); + } +}; + +/** + * Validates that queries aren't empty + * @param queries Array of queries to validate + * @param errors Array to collect error messages + * @param customMessage Optional custom error message + */ +const validateQueriesNotEmpty = ( + queries: any[] = [], + errors: string[], + customMessage?: string, +) => { + queries.forEach((q: any, index: number) => { + if (q && q?.query === "") { + errors.push(customMessage || `Query-${index + 1} is empty`); + } + }); +}; + +/** + * Validates that a content field isn't empty + * @param content Content field to validate + * @param errors Array to collect error messages + * @param errorMessage Error message to add if validation fails + */ +const validateContentNotEmpty = ( + content: string = "", + errors: string[], + errorMessage: string, +) => { + if (content.trim() === "") { + errors.push(errorMessage); + } +}; + +/** + * Validates panel content based on panel type + * @param panel The panel to validate + * @param errors Array to collect error messages + */ +const validatePanelContentByType = (panel: any, errors: string[]) => { + // Check for promQL query type + if (panel?.queryType === "promql") { + validateQueriesNotEmpty(panel?.queries, errors); + } + + // Check by panel type + switch (panel?.type) { + case "geomap": + validateQueriesNotEmpty(panel?.queries, errors); + break; + case "html": + validateContentNotEmpty( + panel?.htmlContent, + errors, + "Please enter your HTML code", + ); + break; + case "markdown": + validateContentNotEmpty( + panel?.markdownContent, + errors, + "Please enter your markdown code", + ); + break; + case "custom_chart": + validateQueriesNotEmpty( + [panel?.queries?.[0]], + errors, + "Please enter query for custom chart", + ); + break; + } +}; + +const validateJoinField = (join: any, errors: string[], joinIndex: number) => { + // validate stream + if (!join?.stream) { + errors.push(`Join #${joinIndex + 1}: Stream is required`); + } + + // validate join type + if (!join?.joinType) { + errors.push(`Join #${joinIndex + 1}: Join type is required`); + } + + // validate clauses + // at least one clause is required + // and each clause should have leftField, rightField, operation + if (!join?.conditions || join?.conditions?.length === 0) { + errors.push(`Join #${joinIndex + 1}: At least one clause is required`); + } + + // validate each clause + join?.conditions?.forEach((condition: any, conditionIndex: number) => { + // validate leftField + if (!condition?.leftField?.field) { + errors.push( + `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Left field is required`, + ); + } + + // validate rightField + if (!condition?.rightField?.field) { + errors.push( + `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Right field is required`, + ); + } + + // validate operation + if (!condition?.operation) { + errors.push( + `Join #${joinIndex + 1}: Clause ${conditionIndex + 1}: Operation is required`, + ); + } + }); +}; + +const validateJoinFields = (joins: any, errors: string[]) => { + // validate join fields + if (joins) { + joins.forEach((join: any, index: number) => + validateJoinField(join, errors, index), + ); + } +}; + +/** + * Validates panel fields without validating stream field existence + * + * @param panel The panel to validate + * @param errors Array to collect error messages + */ +const validatePanelFields = (panel: any, errors: string[] = []) => { + // Check if panel has promQL query type + const isPromQLMode = panel?.queryType === "promql" || panel?.queryType === "promql-builder"; + const currentQueryIndex = 0; // Default to first query + + // Validate panel content based on type + validatePanelContentByType(panel, errors); + + // validate fields if not promQL mode and customQuery is false + if ( + !isPromQLMode && + !panel?.queries?.[currentQueryIndex]?.customQuery && + panel.queries?.[currentQueryIndex]?.fields + ) { + // Validate fields configuration based on chart type + validateChartFieldsConfiguration( + panel?.type, + panel?.queries?.[currentQueryIndex]?.fields ?? {}, + errors, + ); + + // Check filter conditions validity + if ( + panel?.queries?.[currentQueryIndex]?.fields?.filter?.conditions?.length + ) { + // Validate the conditions + validateConditions( + panel?.queries?.[currentQueryIndex]?.fields?.filter?.conditions ?? [], + errors, + ); + } + } + + return errors; +}; + +/** + * Validates an individual panel's content + * Only checks basic structure, used by validateDashboardJson + * + * @param panel The panel object to validate + * @returns Array of validation errors + */ +const validatePanelContent = (panel: any): string[] => { + const errors: string[] = []; + + // Required fields validation + if (!panel?.type) { + errors.push(`Panel ${panel?.id}: Panel type is required`); + return errors; + } + + // Check if panel type is in the allowed types list + const allowedTypes = [ + "area", + "line", + "bar", + "scatter", + "area-stacked", + "donut", + "pie", + "h-bar", + "stacked", + "h-stacked", + "heatmap", + "metric", + "gauge", + "geomap", + "maps", + "table", + "sankey", + "custom_chart", + "html", + "markdown", + ]; + + if (!allowedTypes.includes(panel?.type)) { + errors.push( + `Panel ${panel?.id}: Chart type "${panel?.type}" is not supported.`, + ); + } + + if (!panel?.title) { + errors.push(`Panel ${panel?.id}: Panel title is required`); + } + + // Layout validation + if (!panel?.layout) { + errors.push(`Panel ${panel?.id}: Layout is required`); + } else { + if (typeof panel?.layout?.x !== "number") + errors.push(`Panel ${panel?.id}: Layout x must be a number`); + if (typeof panel?.layout?.y !== "number") + errors.push(`Panel ${panel?.id}: Layout y must be a number`); + if (typeof panel?.layout?.w !== "number") + errors.push(`Panel ${panel?.id}: Layout w must be a number`); + if (typeof panel?.layout?.h !== "number") + errors.push(`Panel ${panel?.id}: Layout h must be a number`); + } + + return errors; +}; + +/** + * Validates a dashboard panel's configuration + * @param {object} panelData - The panel data object to validate + * @param {array} errors - Array to collect errors + * @param {boolean} isFieldsValidationRequired - Whether to validate fields (default: true) + * @returns {array} An array of validation error messages + */ +export const validatePanel = ( + panelData: any, + errors: string[] = [], + isFieldsValidationRequired: boolean = true, + allStreamFields: any[] = [], + pageKey: string = "dashboard", + store: any, + checkTimestampAlias: any, +) => { + // Get current query index + const currentQueryIndex = panelData?.layout?.currentQueryIndex || 0; + + // Check if panel has promQL query type + const isPromQLMode = panelData?.data?.queryType === "promql" || panelData?.data?.queryType === "promql-builder"; + + // Validate panel content based on type + validatePanelContentByType(panelData?.data, errors); + + // Validate timestamp alias for SQL queries with custom query mode + if (panelData?.data?.queryType === "sql") { + const timestampColumn = + store.state.zoConfig.timestamp_column || "_timestamp"; + + panelData?.data?.queries?.forEach((queryObj: any, index: number) => { + if (queryObj?.query && queryObj?.customQuery) { + if (!checkTimestampAlias(queryObj.query)) { + errors.push( + `Alias '${timestampColumn}' is not allowed.`, + ); + } + } + }); + } + + if (isPromQLMode) { + // 1. Chart type: only specific chart types are supported for PromQL + const allowedChartTypes = [ + "area", + "line", + "bar", + "scatter", + "area-stacked", + "metric", + "gauge", + "html", + "markdown", + "custom_chart", + "table", + "maps", + "heatmap", + "geomap", + "donut", + "pie", + "h-bar", + "stacked", + "h-stacked", + ]; + if (!allowedChartTypes.includes(panelData?.data?.type)) { + errors.push( + "Selected chart type is not supported for PromQL. Only line chart is supported.", + ); + } + + // 2. x axis, y axis, filters should be blank for PromQL + if (panelData?.data?.queries?.[currentQueryIndex]?.fields?.x?.length > 0) { + errors.push( + "X-Axis is not supported for PromQL. Remove anything added to the X-Axis.", + ); + } + + if (panelData?.data?.queries?.[currentQueryIndex]?.fields?.y?.length > 0) { + errors.push( + "Y-Axis is not supported for PromQL. Remove anything added to the Y-Axis.", + ); + } + + if ( + panelData?.data?.queries?.[currentQueryIndex]?.fields?.filter?.conditions + ?.length > 0 + ) { + errors.push( + "Filters are not supported for PromQL. Remove anything added to the Filters.", + ); + } + } else { + // Calculate the x and y axis labels based on chart type + const currentXLabel = + panelData?.data?.type === "table" + ? "First Column" + : panelData?.data?.type === "h-bar" + ? "Y-Axis" + : "X-Axis"; + + const currentYLabel = + panelData?.data?.type === "table" + ? "Other Columns" + : panelData?.data?.type === "h-bar" + ? "X-Axis" + : "Y-Axis"; + + // Validate panel fields based on chart type + validateSQLPanelFields( + panelData?.data, + currentQueryIndex, + currentXLabel, + currentYLabel, + errors, + isFieldsValidationRequired, + pageKey, + ); + + // validate join fields + validateJoinFields( + panelData?.data?.queries?.[currentQueryIndex]?.joins, + errors, + ); + } + + return errors; +}; + +/** + * Validates the dashboard JSON structure + * + * @param dashboardJson The dashboard JSON to validate + * @returns Array of validation errors or empty array if valid + */ +export const validateDashboardJson = (dashboardJson: any): string[] => { + const errors: string[] = []; + + // Basic structure validation + if (!dashboardJson) { + errors.push("Dashboard JSON is empty or invalid"); + return errors; + } + + // Required fields validation + if (!dashboardJson?.dashboardId) { + errors.push("Dashboard ID is required"); + } + + if (!dashboardJson?.title) { + errors.push("Dashboard title is required"); + } + + // Version should be present and match current schema version + if (!dashboardJson?.version) { + errors.push("Dashboard version is required"); + } else if (dashboardJson.version !== CURRENT_DASHBOARD_SCHEMA_VERSION) { + errors.push( + `Dashboard version must be ${CURRENT_DASHBOARD_SCHEMA_VERSION}.`, + ); + } + + // Check tabs + if ( + !Array.isArray(dashboardJson?.tabs) || + dashboardJson?.tabs?.length === 0 + ) { + errors.push("Dashboard must have at least one tab"); + return errors; + } + + // Check for unique tab IDs + const tabIds = new Set(); + for (const tab of dashboardJson?.tabs) { + if (!tab?.tabId) { + errors.push("Each tab must have a tabId"); + } else if (tabIds.has(tab?.tabId)) { + errors.push(`Duplicate tab ID found: ${tab?.tabId}`); + } else { + tabIds.add(tab?.tabId); + } + + if (!tab?.name) { + errors.push(`Tab ${tab?.tabId} must have a name`); + } + } + + // Check for unique panel IDs across all tabs and validate each panel + const panelIds = new Set(); + const layoutIValues = new Map>(); + + for (const tab of dashboardJson.tabs) { + if (!Array.isArray(tab?.panels)) { + errors.push(`Tab ${tab?.tabId} must have a panels array`); + continue; + } + + // Create a set for layout i values for this tab + layoutIValues.set(tab?.tabId, new Set()); + + for (const panel of tab.panels) { + // Check panel ID uniqueness + if (!panel?.id) { + errors.push(`Panel in tab ${tab?.tabId} is missing an ID`); + } else if (panelIds.has(panel?.id)) { + errors.push(`Duplicate panel ID found: ${panel?.id}`); + } else { + panelIds.add(panel?.id); + } + + // Check layout i value uniqueness within the tab + if (!panel?.layout || !panel?.layout?.i) { + errors.push(`Panel ${panel?.id} is missing a layout.i value`); + } else { + const tabLayoutValues = layoutIValues.get(tab?.tabId); + if ( + tabLayoutValues && + tabLayoutValues.has(panel?.layout?.i?.toString()) + ) { + errors.push( + `Duplicate layout.i value found in tab ${tab?.tabId}: ${panel?.layout?.i}`, + ); + } else if (tabLayoutValues) { + tabLayoutValues.add(panel?.layout?.i?.toString()); + } + } + + // Validate basic panel structure + const panelStructureErrors = validatePanelContent(panel); + errors.push(...panelStructureErrors); + + // Validate panel fields but skip stream validation + if (panel?.type !== "markdown" && panel?.type !== "html") { + try { + const panelDetailErrors: string[] = []; + + // Only validate the panel fields (not stream field existence) + validatePanelFields(panel, panelDetailErrors); + + // Add panel identifier to each error + const prefixedErrors = panelDetailErrors.map( + (error) => `Panel ${panel?.id || "unknown"}: ${error}`, + ); + + errors.push(...prefixedErrors); + } catch (error) { + // If validation fails + errors.push( + `Panel ${panel?.id || "unknown"}: ${ + error instanceof Error + ? error?.message + : "Unable to validate panel configuration" + }`, + ); + } + } + } + } + + return errors; +}; diff --git a/web/src/utils/dashboard/promql/convertPromQLTableChart.spec.ts b/web/src/utils/dashboard/promql/convertPromQLTableChart.spec.ts index b558be19e0..8d86859273 100644 --- a/web/src/utils/dashboard/promql/convertPromQLTableChart.spec.ts +++ b/web/src/utils/dashboard/promql/convertPromQLTableChart.spec.ts @@ -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[] = [ diff --git a/web/src/utils/dashboard/promql/convertPromQLTableChart.ts b/web/src/utils/dashboard/promql/convertPromQLTableChart.ts index ce806001cf..fbc81e8a2e 100644 --- a/web/src/utils/dashboard/promql/convertPromQLTableChart.ts +++ b/web/src/utils/dashboard/promql/convertPromQLTableChart.ts @@ -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"; /** diff --git a/web/src/utils/dashboard/promqlChartSeriesProps.spec.ts b/web/src/utils/dashboard/promqlChartSeriesProps.spec.ts new file mode 100644 index 0000000000..c4f5837834 --- /dev/null +++ b/web/src/utils/dashboard/promqlChartSeriesProps.spec.ts @@ -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 . + +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"); + }); + }); + }); +}); diff --git a/web/src/utils/dashboard/promqlChartSeriesProps.ts b/web/src/utils/dashboard/promqlChartSeriesProps.ts new file mode 100644 index 0000000000..3572b0a60b --- /dev/null +++ b/web/src/utils/dashboard/promqlChartSeriesProps.ts @@ -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 . + +/** + * 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", + }; + } +}; diff --git a/web/src/utils/dashboard/sql/charts/convertSQLCharts.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLCharts.spec.ts new file mode 100644 index 0000000000..51c84e382b --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLCharts.spec.ts @@ -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 . + +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 { + 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(); + }); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.spec.ts new file mode 100644 index 0000000000..e7da1587f6 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.spec.ts @@ -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 . + +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 { + 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); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.ts new file mode 100644 index 0000000000..0568a865a5 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLGaugeChart.ts @@ -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 . + +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, + }, + }; + }); +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.spec.ts new file mode 100644 index 0000000000..f6ed0952eb --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.spec.ts @@ -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 . + +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 { + 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"); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.ts new file mode 100644 index 0000000000..eeef413dfe --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHBarChart.ts @@ -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 . + +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; + } +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.spec.ts new file mode 100644 index 0000000000..8e0eec4fe9 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.spec.ts @@ -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 . + +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 { + 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([]); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.ts new file mode 100644 index 0000000000..efb4a072ce --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHStackedChart.ts @@ -0,0 +1,59 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { 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; +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts new file mode 100644 index 0000000000..9066b120d9 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts @@ -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 . + +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 { + 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"); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts new file mode 100644 index 0000000000..46ec03cfb7 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts @@ -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 . + +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(); + + 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 + }
${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; +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.spec.ts new file mode 100644 index 0000000000..8b56027017 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.spec.ts @@ -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 . + +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 { + 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(""); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.ts new file mode 100644 index 0000000000..9a0702002d --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLLineAreaChart.ts @@ -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 . + +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( + `${it.marker} ${it.seriesName} : ${formatUnitValue( + getUnitValue( + it.data, + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )} `, + ); + // 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}
${hoverText.join("
")}`; + } 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(); +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.spec.ts new file mode 100644 index 0000000000..19e2308238 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.spec.ts @@ -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 . + +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 { + 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"); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.ts new file mode 100644 index 0000000000..7c8e682a7f --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLMetricChart.ts @@ -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 . + +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 ""; + } + }, + }, + ]; +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.spec.ts new file mode 100644 index 0000000000..2af58dd815 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.spec.ts @@ -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 . + +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 { + 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"); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.ts new file mode 100644 index 0000000000..4c3aa27b42 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLPieDonutChart.ts @@ -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 . + +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} : ${formatUnitValue( + getUnitValue( + name?.value, + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )}`; + } 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} : ${formatUnitValue( + getUnitValue( + name?.value, + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )}`; + } 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 = []; + } +} diff --git a/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.spec.ts new file mode 100644 index 0000000000..626c0019dd --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.spec.ts @@ -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 . + +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 { + 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"); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.ts new file mode 100644 index 0000000000..ab3bafd4b6 --- /dev/null +++ b/web/src/utils/dashboard/sql/charts/convertSQLStackedChart.ts @@ -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 . + +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 }); +} diff --git a/web/src/utils/dashboard/sql/convertSQLChartData.spec.ts b/web/src/utils/dashboard/sql/convertSQLChartData.spec.ts new file mode 100644 index 0000000000..0a4e5bdd5e --- /dev/null +++ b/web/src/utils/dashboard/sql/convertSQLChartData.spec.ts @@ -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 . + +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); + }); +}); diff --git a/web/src/utils/dashboard/sql/convertSQLChartData.ts b/web/src/utils/dashboard/sql/convertSQLChartData.ts new file mode 100644 index 0000000000..c39c8d91ed --- /dev/null +++ b/web/src/utils/dashboard/sql/convertSQLChartData.ts @@ -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 . + +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, + }, + }; +}; diff --git a/web/src/utils/dashboard/sql/index.ts b/web/src/utils/dashboard/sql/index.ts new file mode 100644 index 0000000000..98d31bf56e --- /dev/null +++ b/web/src/utils/dashboard/sql/index.ts @@ -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 . + +export { convertSQLChartData } from "./convertSQLChartData"; diff --git a/web/src/utils/dashboard/sql/shared/contextBuilder.spec.ts b/web/src/utils/dashboard/sql/shared/contextBuilder.spec.ts new file mode 100644 index 0000000000..c81306184d --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/contextBuilder.spec.ts @@ -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 . + +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"); + }); + }); +}); diff --git a/web/src/utils/dashboard/sql/shared/contextBuilder.ts b/web/src/utils/dashboard/sql/shared/contextBuilder.ts new file mode 100644 index 0000000000..17e1f59134 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/contextBuilder.ts @@ -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 . + +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( + `${it.marker} ${it.seriesName} : ${formatUnitValue( + getUnitValue( + it.value, + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )} `, + ); + // 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}
${hoverText.join("
")}`; + } 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; +} diff --git a/web/src/utils/dashboard/sql/shared/seriesBuilder.spec.ts b/web/src/utils/dashboard/sql/shared/seriesBuilder.spec.ts new file mode 100644 index 0000000000..da47507361 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/seriesBuilder.spec.ts @@ -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 . + +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); + }); + }); +}); diff --git a/web/src/utils/dashboard/sql/shared/seriesBuilder.ts b/web/src/utils/dashboard/sql/shared/seriesBuilder.ts new file mode 100644 index 0000000000..18132c9bb5 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/seriesBuilder.ts @@ -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 . + +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; + 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 { + const dataMap = new Map(); + + 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 = [], + seriesConfig: Record, + 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 = {}) => { + 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, + }; +} diff --git a/web/src/utils/dashboard/sql/shared/trellisConfig.spec.ts b/web/src/utils/dashboard/sql/shared/trellisConfig.spec.ts new file mode 100644 index 0000000000..258c2ffa99 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/trellisConfig.spec.ts @@ -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 . + +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 { + 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(); + }); +}); diff --git a/web/src/utils/dashboard/sql/shared/trellisConfig.ts b/web/src/utils/dashboard/sql/shared/trellisConfig.ts new file mode 100644 index 0000000000..39961cd6f2 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/trellisConfig.ts @@ -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 . + +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, + }; +} diff --git a/web/src/utils/dashboard/sql/shared/types.ts b/web/src/utils/dashboard/sql/shared/types.ts new file mode 100644 index 0000000000..5684aca994 --- /dev/null +++ b/web/src/utils/dashboard/sql/shared/types.ts @@ -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 . + +/** + * 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) => 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; +} diff --git a/web/src/utils/dashboard/sqlChartSeriesProps.spec.ts b/web/src/utils/dashboard/sqlChartSeriesProps.spec.ts new file mode 100644 index 0000000000..4a9c29de43 --- /dev/null +++ b/web/src/utils/dashboard/sqlChartSeriesProps.spec.ts @@ -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 . + +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"); + }); + }); + }); +}); diff --git a/web/src/utils/dashboard/sqlChartSeriesProps.ts b/web/src/utils/dashboard/sqlChartSeriesProps.ts new file mode 100644 index 0000000000..c29c171a17 --- /dev/null +++ b/web/src/utils/dashboard/sqlChartSeriesProps.ts @@ -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 . + +/** + * 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", + }; + } +}; diff --git a/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts b/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts new file mode 100644 index 0000000000..7be86c6935 --- /dev/null +++ b/web/src/utils/dashboard/sqlMissingValueFiller.spec.ts @@ -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 . + +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, + }); + }); +}); diff --git a/web/src/utils/dashboard/sqlMissingValueFiller.ts b/web/src/utils/dashboard/sqlMissingValueFiller.ts new file mode 100644 index 0000000000..1a4904ea3b --- /dev/null +++ b/web/src/utils/dashboard/sqlMissingValueFiller.ts @@ -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 . + +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; +}; diff --git a/web/src/utils/dashboard/sqlProcessData.spec.ts b/web/src/utils/dashboard/sqlProcessData.spec.ts new file mode 100644 index 0000000000..83ef6e5940 --- /dev/null +++ b/web/src/utils/dashboard/sqlProcessData.spec.ts @@ -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 . + +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"); + }); + }); +}); diff --git a/web/src/utils/dashboard/sqlProcessData.ts b/web/src/utils/dashboard/sqlProcessData.ts new file mode 100644 index 0000000000..096f9d4e39 --- /dev/null +++ b/web/src/utils/dashboard/sqlProcessData.ts @@ -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 . + +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; +}; diff --git a/web/src/utils/dashboard/sqlTimeSeriesConverter.spec.ts b/web/src/utils/dashboard/sqlTimeSeriesConverter.spec.ts new file mode 100644 index 0000000000..69f4b8aa23 --- /dev/null +++ b/web/src/utils/dashboard/sqlTimeSeriesConverter.spec.ts @@ -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 . + +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); + }); +}); diff --git a/web/src/utils/dashboard/sqlTimeSeriesConverter.ts b/web/src/utils/dashboard/sqlTimeSeriesConverter.ts new file mode 100644 index 0000000000..fb0f205cce --- /dev/null +++ b/web/src/utils/dashboard/sqlTimeSeriesConverter.ts @@ -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 . + +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( + `${it.marker} ${it.seriesName} : ${formatUnitValue( + getUnitValue( + it.data[1], + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )} `, + ); + // 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)}
${hoverText.join("
")}`; + }; + + 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( + `${it?.marker} ${it?.seriesName} : ${formatUnitValue( + getUnitValue( + it?.data?.[1], + panelSchema.config?.unit, + panelSchema.config?.unit_custom, + panelSchema.config?.decimals, + ), + )} `, + ); + // 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)}
${hoverText.join("
")}`; + } 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; +}; diff --git a/web/src/views/Dashboards/DashboardJsonEditor.spec.ts b/web/src/views/Dashboards/DashboardJsonEditor.spec.ts index 1ec1caffea..633f0ecc37 100644 --- a/web/src/views/Dashboards/DashboardJsonEditor.spec.ts +++ b/web/src/views/Dashboards/DashboardJsonEditor.spec.ts @@ -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(); diff --git a/web/src/views/Dashboards/DashboardJsonEditor.vue b/web/src/views/Dashboards/DashboardJsonEditor.vue index 67386581c0..82fe076b7a 100644 --- a/web/src/views/Dashboards/DashboardJsonEditor.vue +++ b/web/src/views/Dashboards/DashboardJsonEditor.vue @@ -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", diff --git a/web/src/views/Dashboards/ImportDashboard.spec.ts b/web/src/views/Dashboards/ImportDashboard.spec.ts index 8a0af31090..8e8472d75c 100644 --- a/web/src/views/Dashboards/ImportDashboard.spec.ts +++ b/web/src/views/Dashboards/ImportDashboard.spec.ts @@ -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, })); diff --git a/web/src/views/Dashboards/ImportDashboard.vue b/web/src/views/Dashboards/ImportDashboard.vue index 733bde9ed7..6aceac9f2c 100644 --- a/web/src/views/Dashboards/ImportDashboard.vue +++ b/web/src/views/Dashboards/ImportDashboard.vue @@ -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";