From dcbb49370dcf366a33c62e24ff549886ece3ee41 Mon Sep 17 00:00:00 2001 From: Prabhat Sharma Date: Mon, 26 Oct 2026 16:00:18 +0000 Subject: [PATCH] feat(dashboards): heatmap colour, axis, histogram and interaction improvements (#15784) Co-authored-by: ktx-vaidehi Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com> --- scripts/translations/.translation_state.json | 56 ++ src/api/management/src/models/dashboards.rs | 11 +- src/config/src/meta/dashboards/v8/mod.rs | 175 ++++ .../PanelEditor/PanelEditor.spec.ts | 47 + .../dashboards/PanelEditor/PanelEditor.vue | 23 +- .../dashboards/PanelSchemaRenderer.vue | 48 + .../dashboards/addPanel/ConfigPanel.spec.ts | 376 +++++++- .../dashboards/addPanel/ConfigPanel.vue | 448 ++++++++- .../dashboards/exemplars/ExemplarCard.vue | 2 +- .../dashboards/panels/ChartRenderer.spec.ts | 176 ++++ .../dashboards/panels/ChartRenderer.vue | 97 +- .../dashboard/useConfigPanel.spec.ts | 152 +++- .../composables/dashboard/useConfigPanel.ts | 74 +- .../dashboard/useDashboardPanel.spec.ts | 34 + .../dashboard/useDashboardPanel.ts | 19 + .../dashboard/usePanelDrilldown.spec.ts | 205 +++++ .../dashboard/usePanelDrilldown.ts | 73 +- .../usePanelExemplarInteraction.spec.ts | 63 ++ .../dashboard/usePanelExemplarInteraction.ts | 104 ++- web/src/locales/languages/ar-SA.json | 56 ++ web/src/locales/languages/de-DE.json | 56 ++ web/src/locales/languages/en-US.json | 56 ++ web/src/locales/languages/es-ES.json | 56 ++ web/src/locales/languages/fr-FR.json | 56 ++ web/src/locales/languages/it-IT.json | 56 ++ web/src/locales/languages/ja-JP.json | 56 ++ web/src/locales/languages/ko-KR.json | 56 ++ web/src/locales/languages/nl-NL.json | 56 ++ web/src/locales/languages/pl-PL.json | 56 ++ web/src/locales/languages/pt-PT.json | 56 ++ web/src/locales/languages/ru-RU.json | 56 ++ web/src/locales/languages/tr-TR.json | 56 ++ web/src/locales/languages/vi-VN.json | 56 ++ web/src/locales/languages/zh-CN.json | 56 ++ web/src/locales/languages/zh-TW.json | 56 ++ web/src/utils/dashboard/configUtils.spec.ts | 9 + web/src/utils/dashboard/configUtils.ts | 4 + .../utils/dashboard/convertPromQLData.spec.ts | 39 + web/src/utils/dashboard/convertPromQLData.ts | 1 + .../utils/dashboard/convertSQLData.spec.ts | 304 +++++++ web/src/utils/dashboard/convertSQLData.ts | 53 +- .../dashboardAutoQueryBuilder.spec.ts | 145 +++ .../dashboard/dashboardAutoQueryBuilder.ts | 43 +- .../exemplars/applyExemplarSeries.ts | 18 +- .../exemplars/exemplarEligibility.spec.ts | 29 +- .../exemplars/exemplarEligibility.ts | 2 +- .../exemplars/heatmapExemplars.spec.ts | 288 ++++++ .../dashboard/exemplars/heatmapExemplars.ts | 227 +++++ .../utils/dashboard/heatmapDefaults.spec.ts | 414 ++++++++- web/src/utils/dashboard/heatmapDefaults.ts | 238 ++++- web/src/utils/dashboard/heatmapPalettes.ts | 190 ++++ .../utils/dashboard/heatmapTimeAxis.spec.ts | 279 ++++++ web/src/utils/dashboard/heatmapTimeAxis.ts | 205 +++++ web/src/utils/dashboard/heatmapYAxis.spec.ts | 280 ++++++ web/src/utils/dashboard/heatmapYAxis.ts | 262 ++++++ .../utils/dashboard/panelValidation.spec.ts | 52 ++ web/src/utils/dashboard/panelValidation.ts | 36 + .../promql/convertPromQLChartData.spec.ts | 24 + .../promql/convertPromQLChartData.ts | 16 +- .../promql/convertPromQLHeatmapChart.spec.ts | 776 +++++++++++++++- .../promql/convertPromQLHeatmapChart.ts | 859 +++++++++++++----- .../promql/shared/dataProcessor.spec.ts | 35 + .../dashboard/promql/shared/dataProcessor.ts | 24 +- .../promql/shared/histogramBuckets.spec.ts | 107 ++- .../promql/shared/histogramBuckets.ts | 77 +- .../utils/dashboard/promql/shared/spectral.ts | 159 ---- .../utils/dashboard/promql/shared/types.ts | 6 + .../sql/charts/convertSQLHeatmapChart.spec.ts | 601 ++++++++++++ .../sql/charts/convertSQLHeatmapChart.ts | 316 +++++-- .../dashboard/sql/convertSQLChartData.ts | 6 +- 70 files changed, 8548 insertions(+), 655 deletions(-) create mode 100644 web/src/utils/dashboard/exemplars/heatmapExemplars.spec.ts create mode 100644 web/src/utils/dashboard/exemplars/heatmapExemplars.ts create mode 100644 web/src/utils/dashboard/heatmapPalettes.ts create mode 100644 web/src/utils/dashboard/heatmapTimeAxis.spec.ts create mode 100644 web/src/utils/dashboard/heatmapTimeAxis.ts create mode 100644 web/src/utils/dashboard/heatmapYAxis.spec.ts create mode 100644 web/src/utils/dashboard/heatmapYAxis.ts delete mode 100644 web/src/utils/dashboard/promql/shared/spectral.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 692b4e34d9..0d7e76fff6 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -8710,6 +8710,8 @@ "bitsPerSecond": "f3b0dd77d1d3149a", "bottom": "479ad21ac9096236", "browserDeniedFileDownload": "c29bc3d4a1ceed2b", + "bucketUnit": "9c255386ebf5b06b", + "bucketUnitCustom": "a7ea7b936ab593f2", "byDefaultSelect": "aa982b44f09f7b08", "byValue": "a19e7aa8b60237e8", "bytes": "4d8000301fccdede", @@ -9122,7 +9124,49 @@ "groupMultiYAxisTrellisTooltipTitle": "aada45c55de79ec1", "header": "a53bcafb67d960df", "heatmap": "3aa6826d7f101089", + "heatmapColorMax": "bfae618fedb3fa64", + "heatmapColorMaxTooltipDescription": "fe4a2bcdee0d4ee9", + "heatmapColorMaxTooltipTitle": "f3d4b106d12d8e70", + "heatmapColorMin": "d5b83100ef5ff288", + "heatmapColorMinTooltipDescription": "a8aa0ea2c7310c13", + "heatmapColorMinTooltipTitle": "1076cc22486f4369", + "heatmapColorRangeInvalid": "0c0f319c48207e57", + "heatmapColorScale": "5be91a789b988bcb", + "heatmapColorScaleLinear": "e6950b45d25c2027", + "heatmapColorScaleLog": "21e49eb2b299c9f7", + "heatmapColorScaleSqrt": "2af4da15396b82fd", + "heatmapHistogramMode": "5354801b0969e164", + "heatmapHistogramModeTooltip": "c2691ad5fa506105", "heatmapLabel": "3aa6826d7f101089", + "heatmapPalette": "620811351d2e4f6d", + "heatmapPaletteBlues": "a87be8077f00caa6", + "heatmapPaletteGreens": "668d88f56ce2efc7", + "heatmapPaletteInferno": "79b1a3795041f402", + "heatmapPaletteMagma": "4475ce64bb06e7a7", + "heatmapPaletteRdYlBu": "84f945a1ed8c1cc7", + "heatmapPaletteReds": "8e17b682b6fedf5d", + "heatmapPaletteReverse": "cd3b45e7cad6035f", + "heatmapPaletteSpectral": "4356463e3e0aa70b", + "heatmapPaletteViridis": "189af6c0f919a5e7", + "heatmapYBucket": "25bf3fe1b37be7e8", + "heatmapYBucketBase": "ca6c410ef5ab05a3", + "heatmapYBucketLogMode": "21e49eb2b299c9f7", + "heatmapYBucketNone": "dc937b59892604f5", + "heatmapYBucketSize": "663b1cb2b6b696ff", + "heatmapYBucketSizeInvalid": "014f9a06d58e5ba6", + "heatmapYBucketSizeMode": "1a1f61aec86e8dd3", + "heatmapYBucketTooMany": "a0aa7c20e0d2b78a", + "heatmapYBucketTooltip": "203b5bbb2027a03a", + "heatmapYMax": "2d47a7134fe1b9d4", + "heatmapYMaxTooltip": "c6d85c24f9fce39f", + "heatmapYMin": "c08cf36b2cd35420", + "heatmapYMinTooltip": "78aa68a2a093c9de", + "heatmapYRangeInvalid": "a5eaf0251546c1df", + "heatmapYSort": "137e878e285f2bce", + "heatmapYSortAsc": "77184595bde3befc", + "heatmapYSortData": "b08beeec66dbead1", + "heatmapYSortDesc": "79479a6c76d8416a", + "heatmapYSortTooltip": "c6f5b503c56655e4", "hiddenColumns": "87c29bb0aa1e1b4f", "hideOnDashboard": "0fdedcc2271760f1", "hideQueryResults": "040f771661130892", @@ -10074,6 +10118,18 @@ "forbiddenIdentifierAccess": "089119bd17f6c43f", "forbiddenInsideSetTimeout": "5d3df88c8171f6ce", "groupingNotAllowedForMetric": "ef87f3502afb2ba9", + "heatmapColorRangeInverted": "3729c6f8807bce35", + "heatmapColumnsCapped": "e755669c9af90a2f", + "heatmapDuplicateCells": "49eac9e70f48d920", + "heatmapEmptyCategory": "d29a0939bcb51176", + "heatmapHistogramColumnsCapped": "269e820aa7a60185", + "heatmapHistogramsCapped": "20c506659c2e63e6", + "heatmapHistogramsHeightCapped": "2925025e224d453f", + "heatmapHistogramsSeriesLimited": "167579303754a978", + "heatmapRowsCapped": "5b409d50966b936a", + "heatmapYBucketSizeInvalid": "026524387c87c3cc", + "heatmapYBucketTooMany": "bfd51b17ff0c6f9d", + "heatmapYRangeInverted": "25eb8bd05f09de92", "hoursAgo": "87c4f7b5afe061eb", "infiniteForLoop": "0cf08c50f915e957", "infiniteWhileLoop": "df590425ba0396e6", diff --git a/src/api/management/src/models/dashboards.rs b/src/api/management/src/models/dashboards.rs index 0ebe0994e9..741014874e 100644 --- a/src/api/management/src/models/dashboards.rs +++ b/src/api/management/src/models/dashboards.rs @@ -435,7 +435,16 @@ mod tests { let fields = serde_json::json!({ "heatmap_mode": "prometheus_histogram", "bucket_unit": "seconds", - "bucket_unit_custom": "req" + "bucket_unit_custom": "req", + "heatmap_color_min": 1.5, + "heatmap_color_max": 40.0, + "heatmap_palette": "magma", + "heatmap_palette_reverse": true, + "heatmap_color_scale": "linear", + "heatmap_y_sort": "asc", + "heatmap_y_bucket": { "mode": "log", "base": 2.0 }, + "heatmap_y_min": 1.0, + "heatmap_y_max": 4096.0 }); let config = value["tabs"][0]["panels"][0]["config"] .as_object_mut() diff --git a/src/config/src/meta/dashboards/v8/mod.rs b/src/config/src/meta/dashboards/v8/mod.rs index 37e2f6e76e..38f2416b37 100644 --- a/src/config/src/meta/dashboards/v8/mod.rs +++ b/src/config/src/meta/dashboards/v8/mod.rs @@ -618,6 +618,28 @@ pub struct PanelConfig { #[serde(skip_serializing_if = "Option::is_none")] bucket_unit_custom: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + heatmap_color_min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + heatmap_color_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + heatmap_palette: Option, + #[serde(skip_serializing_if = "Option::is_none")] + heatmap_palette_reverse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + heatmap_color_scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + heatmap_y_sort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + heatmap_y_bucket: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + heatmap_y_min: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + heatmap_y_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] thresholds: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option)] @@ -636,6 +658,20 @@ pub struct PanelConfig { tooltip_mode: Option, } +/// Heatmap y buckets: "size" mode is fixed-width, "log" mode one per power of `base` (2 or 10). +#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, ToSchema, Default)] +#[serde(default)] +pub struct HeatmapYBucket { + #[serde(skip_serializing_if = "Option::is_none")] + mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + base: Option, +} + #[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, ToSchema, Default)] #[serde(default)] #[serde(rename_all = "camelCase")] @@ -2196,6 +2232,145 @@ mod tests { assert!(json.get("bucket_unit_custom").is_none()); } + #[test] + fn test_panel_config_heatmap_color_range_round_trip() { + for (min, max) in [ + (Some(-2.5), Some(40.0)), + (Some(0.0), None), + (None, Some(1e9)), + ] { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": true, + "legends_position": null, + "base_map": null, + "map_view": null, + "heatmap_color_min": min, + "heatmap_color_max": max + })) + .unwrap(); + assert_eq!(cfg.heatmap_color_min, min.map(OrdF64::from)); + assert_eq!(cfg.heatmap_color_max, max.map(OrdF64::from)); + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!(json.get("heatmap_color_min").and_then(|v| v.as_f64()), min); + assert_eq!(json.get("heatmap_color_max").and_then(|v| v.as_f64()), max); + let back: PanelConfig = serde_json::from_value(json).unwrap(); + assert_eq!(back, cfg); + } + } + + #[test] + fn test_panel_config_heatmap_color_range_absent_when_none() { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": false, + "legends_position": null, + "base_map": null, + "map_view": null, + "heatmap_color_min": null + })) + .unwrap(); + assert_eq!(cfg.heatmap_color_min, None); + assert_eq!(cfg.heatmap_color_max, None); + let json = serde_json::to_value(&cfg).unwrap(); + assert!(json.get("heatmap_color_min").is_none()); + assert!(json.get("heatmap_color_max").is_none()); + } + + #[test] + fn test_panel_config_heatmap_palette_and_scale_round_trip() { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": true, + "legends_position": null, + "base_map": null, + "map_view": null, + "heatmap_palette": "viridis", + "heatmap_palette_reverse": true, + "heatmap_color_scale": "log" + })) + .unwrap(); + assert_eq!(cfg.heatmap_palette.as_deref(), Some("viridis")); + assert_eq!(cfg.heatmap_palette_reverse, Some(true)); + assert_eq!(cfg.heatmap_color_scale.as_deref(), Some("log")); + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!(json["heatmap_palette"], serde_json::json!("viridis")); + assert_eq!(json["heatmap_palette_reverse"], serde_json::json!(true)); + assert_eq!(json["heatmap_color_scale"], serde_json::json!("log")); + let back: PanelConfig = serde_json::from_value(json).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn test_panel_config_heatmap_palette_and_scale_absent_when_none() { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": false, + "legends_position": null, + "base_map": null, + "map_view": null + })) + .unwrap(); + assert_eq!(cfg.heatmap_palette, None); + assert_eq!(cfg.heatmap_palette_reverse, None); + assert_eq!(cfg.heatmap_color_scale, None); + let json = serde_json::to_value(&cfg).unwrap(); + assert!(json.get("heatmap_palette").is_none()); + assert!(json.get("heatmap_palette_reverse").is_none()); + assert!(json.get("heatmap_color_scale").is_none()); + } + + #[test] + fn test_panel_config_heatmap_y_axis_round_trip() { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": true, + "legends_position": null, + "base_map": null, + "map_view": null, + "heatmap_y_sort": "desc", + "heatmap_y_bucket": { "mode": "size", "size": 2.5, "base": 10 }, + "heatmap_y_min": -10.0, + "heatmap_y_max": 500.0 + })) + .unwrap(); + assert_eq!(cfg.heatmap_y_sort.as_deref(), Some("desc")); + let bucket = cfg.heatmap_y_bucket.as_ref().unwrap(); + assert_eq!(bucket.mode.as_deref(), Some("size")); + assert_eq!(bucket.size, Some(OrdF64::from(2.5))); + assert_eq!(bucket.base, Some(OrdF64::from(10.0))); + assert_eq!(cfg.heatmap_y_min, Some(OrdF64::from(-10.0))); + assert_eq!(cfg.heatmap_y_max, Some(OrdF64::from(500.0))); + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!(json["heatmap_y_sort"], serde_json::json!("desc")); + assert_eq!( + json["heatmap_y_bucket"], + serde_json::json!({ "mode": "size", "size": 2.5, "base": 10.0 }) + ); + assert_eq!(json["heatmap_y_min"], serde_json::json!(-10.0)); + assert_eq!(json["heatmap_y_max"], serde_json::json!(500.0)); + let back: PanelConfig = serde_json::from_value(json).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn test_panel_config_heatmap_y_axis_absent_when_none() { + let cfg: PanelConfig = serde_json::from_value(serde_json::json!({ + "show_legends": false, + "legends_position": null, + "base_map": null, + "map_view": null, + "heatmap_y_bucket": { "mode": "log" } + })) + .unwrap(); + assert_eq!(cfg.heatmap_y_sort, None); + assert_eq!(cfg.heatmap_y_min, None); + assert_eq!(cfg.heatmap_y_max, None); + let json = serde_json::to_value(&cfg).unwrap(); + assert_eq!( + json["heatmap_y_bucket"], + serde_json::json!({ "mode": "log" }) + ); + for key in ["heatmap_y_sort", "heatmap_y_min", "heatmap_y_max"] { + assert!(json.get(key).is_none(), "{key} should be omitted"); + } + } + #[test] fn test_panel_config_thresholds_round_trip() { for base_color in [None, Some("#22C55E")] { diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts index cdea7ec434..feac9f8646 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts @@ -79,9 +79,14 @@ const mockDashboardPanelData = reactive({ }, }); +const mockMakeAutoSQLQuery = vi.fn(async () => undefined); +const mockRegenerateOtherBuilderQueries = vi.fn(); + vi.mock("@/composables/dashboard/useDashboardPanel", () => ({ default: () => ({ dashboardPanelData: mockDashboardPanelData, + makeAutoSQLQuery: mockMakeAutoSQLQuery, + regenerateOtherBuilderQueries: mockRegenerateOtherBuilderQueries, resetAggregationFunction: vi.fn(), resetDashboardPanelData: vi.fn(), updateGroupedFields: vi.fn(), @@ -914,4 +919,46 @@ describe("PanelEditor.vue", () => { expect(selectorDialog!.attributes("data-open")).toBe("false"); }); }); + + describe("builder SQL regeneration on heatmap y settings", () => { + it.each([ + ["heatmap_y_bucket", { mode: "size", size: 10 }], + ["heatmap_y_min", 5], + ["heatmap_y_max", 50], + ])("rebuilds the query when %s changes", async (key, value) => { + const config = mockDashboardPanelData.data.config as Record; + mockDashboardPanelData.data.type = "heatmap"; + wrapper = shallowMount(PanelEditor, { + props: { pageType: "dashboard", editMode: false }, + global: mountGlobal, + }); + await nextTick(); + mockMakeAutoSQLQuery.mockClear(); + mockRegenerateOtherBuilderQueries.mockClear(); + + config[key] = value; + await nextTick(); + expect(mockMakeAutoSQLQuery).toHaveBeenCalledTimes(1); + expect(mockRegenerateOtherBuilderQueries).toHaveBeenCalledTimes(1); + delete config[key]; + wrapper.unmount(); + }); + + it("rebuilds the query when the bucket size is edited in place", async () => { + const config = mockDashboardPanelData.data.config as Record; + config.heatmap_y_bucket = { mode: "size", size: 10 }; + wrapper = shallowMount(PanelEditor, { + props: { pageType: "dashboard", editMode: false }, + global: mountGlobal, + }); + await nextTick(); + mockMakeAutoSQLQuery.mockClear(); + + config.heatmap_y_bucket.size = 20; + await nextTick(); + expect(mockMakeAutoSQLQuery).toHaveBeenCalledTimes(1); + delete config.heatmap_y_bucket; + wrapper.unmount(); + }); + }); }); diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.vue b/web/src/components/dashboards/PanelEditor/PanelEditor.vue index 8a78dbc439..5bc48bd642 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.vue +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.vue @@ -820,8 +820,13 @@ const resolvedConfig = computed(() => resolveConfig(props)); // Get dashboard panel data composable const pageKey = computed(() => props.pageType); -const { dashboardPanelData, resetAggregationFunction, makeAutoSQLQuery, validatePanel } = - useDashboardPanelData(pageKey.value, t); +const { + dashboardPanelData, + resetAggregationFunction, + makeAutoSQLQuery, + regenerateOtherBuilderQueries, + validatePanel, +} = useDashboardPanelData(pageKey.value, t); // Provide page key for child components provide("dashboardPanelDataPageKey", pageKey.value); @@ -1353,6 +1358,10 @@ watch( dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]?.config?.limit, dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]?.joins, dashboardPanelData.data.type, + // The builder generates the heatmap y bucketing and range into the SQL itself. + dashboardPanelData.data.config?.heatmap_y_bucket, + dashboardPanelData.data.config?.heatmap_y_min, + dashboardPanelData.data.config?.heatmap_y_max, ], async () => { // Only auto-generate SQL if in builder mode (customQuery = false) @@ -1372,6 +1381,16 @@ watch( { deep: true }, ); +watch( + () => [ + dashboardPanelData.data.config?.heatmap_y_bucket, + dashboardPanelData.data.config?.heatmap_y_min, + dashboardPanelData.data.config?.heatmap_y_max, + ], + () => regenerateOtherBuilderQueries(), + { deep: true }, +); + // Watch for customQuery mode changes to notify parent watch( () => dashboardPanelData.data.queries[dashboardPanelData.layout.currentQueryIndex]?.customQuery, diff --git a/web/src/components/dashboards/PanelSchemaRenderer.vue b/web/src/components/dashboards/PanelSchemaRenderer.vue index 04b2afae91..24708ae098 100644 --- a/web/src/components/dashboards/PanelSchemaRenderer.vue +++ b/web/src/components/dashboards/PanelSchemaRenderer.vue @@ -361,6 +361,10 @@ import { toChartTimeMs, } from "@/utils/dashboard/exemplars/applyExemplarSeries"; import { isExemplarEligible } from "@/utils/dashboard/exemplars/exemplarEligibility"; +import { + applyHeatmapExemplarSeries, + cachedHeatmapExemplarPlacement, +} from "@/utils/dashboard/exemplars/heatmapExemplars"; import { buildExemplarTraceRoute } from "@/utils/dashboard/exemplars/exemplarTraceRoute"; import { exemplarBaseUnit, @@ -1097,6 +1101,18 @@ export default defineComponent({ ); }; + // A heatmap's markers sit on its cells, so they follow the converter's columns and rows. + const placeHeatmapCells = cachedHeatmapExemplarPlacement(toExemplarChartX); + const heatmapExemplarPlace = computed(() => { + const extras = panelData.value?.extras; + if (panelSchema.value?.type !== "heatmap" || !extras?.heatmapExemplarLayout) return null; + if (!extras.heatmapTimeAxis) return null; + return placeHeatmapCells( + extras.heatmapExemplarLayout, + extras.heatmapTimeAxis, + exemplarMarkers.value, + ); + }); const exemplarInteraction = usePanelExemplarInteraction({ chartRendererRef, markers: exemplarMarkers, @@ -1106,6 +1122,7 @@ export default defineComponent({ toChartX: toExemplarChartX, navigate: navigateToExemplarTrace, placeY: placeExemplarY, + gridPlacer: heatmapExemplarPlace, }); const hoveredQueryAnnotation = ref<{ marker: QueryAnnotationMarker; @@ -2004,9 +2021,40 @@ export default defineComponent({ // Determines what data to pass to ChartRenderer. // noData check is evaluated first so promql panels with no results // also get the transparent background instead of showing a skeleton. + // Reused until the data, markers or colours change, since a new object makes the chart redraw every cell. + const heatmapExemplarData = computed( + (previous?: { source: unknown; place: unknown; colors: string; data: unknown }) => { + const source = panelData.value; + const place = heatmapExemplarPlace.value; + const queryColors = exemplarQueryColors.value; + const haloColor = (void isDark.value, chartColor("--color-surface-base")); + const edgeColor = (void isDark.value, chartColor("--color-text-heading")); + const colors = [...queryColors, haloColor, edgeColor].join("|"); + if ( + previous && + previous.source === source && + previous.place === place && + previous.colors === colors + ) { + return previous; + } + const data = place + ? { + ...source, + options: applyHeatmapExemplarSeries(source.options, exemplarMarkers.value, place, { + queryColors, + haloColor, + edgeColor, + }), + } + : source; + return { source, place, colors, data }; + }, + ); const withExemplars = (value: any) => { if (!exemplarMarkers.value.length || !value?.options) return value; if (!exemplarsEligible.value && !injectedExemplars.value) return value; + if (panelSchema.value?.type === "heatmap") return heatmapExemplarData.value.data; return { ...value, options: applyExemplarSeries(value.options, exemplarMarkers.value, { diff --git a/web/src/components/dashboards/addPanel/ConfigPanel.spec.ts b/web/src/components/dashboards/addPanel/ConfigPanel.spec.ts index 918cef22fa..a4a70f9910 100644 --- a/web/src/components/dashboards/addPanel/ConfigPanel.spec.ts +++ b/web/src/components/dashboards/addPanel/ConfigPanel.spec.ts @@ -378,21 +378,24 @@ describe("ConfigPanel", () => { }, }); - it("appears off by default for an eligible PromQL panel and binds config.show_exemplars", async () => { - const panel = promqlPanel("line"); - wrapper = createWrapper({ dashboardPanelData: panel }, { promqlMode: true }); - const toggle = wrapper.findComponent('[data-test="dashboard-config-show-exemplars"]'); - expect(toggle.exists()).toBe(true); - expect(toggle.props("modelValue")).toBeFalsy(); - await toggle.vm.$emit("update:modelValue", true); - expect(panel.data.config.show_exemplars).toBe(true); - }); + it.each(["line", "heatmap"])( + "appears off by default for an eligible PromQL %s panel and binds config.show_exemplars", + async (type) => { + const panel = promqlPanel(type); + wrapper = createWrapper({ dashboardPanelData: panel }, { promqlMode: true }); + const toggle = wrapper.findComponent('[data-test="dashboard-config-show-exemplars"]'); + expect(toggle.exists()).toBe(true); + expect(toggle.props("modelValue")).toBeFalsy(); + await toggle.vm.$emit("update:modelValue", true); + expect(panel.data.config.show_exemplars).toBe(true); + }, + ); it.each([ ["h-bar", "range"], ["stacked", "range"], ["table", "range"], - ["heatmap", "range"], + ["heatmap", "instant"], ["line", "instant"], ])("is absent for %s (%s)", (type, queryType) => { wrapper = createWrapper( @@ -447,6 +450,359 @@ describe("ConfigPanel", () => { }); }); + describe("Heatmap colour scale min / max", () => { + const panelOf = (type: string, config: Record = {}) => + reactive({ + ...mockDashboardPanelData, + data: { + ...mockDashboardPanelData.data, + type, + config: { ...mockDashboardPanelData.data.config, ...config }, + }, + }); + const inputOf = (end: "min" | "max") => + wrapper.findComponent(`[data-test="dashboard-config-heatmap_color_${end}"]`); + + it("shows both inputs with an Auto placeholder for a heatmap", () => { + wrapper = createWrapper({ dashboardPanelData: panelOf("heatmap") }); + for (const end of ["min", "max"] as const) { + expect(inputOf(end).exists()).toBe(true); + expect(inputOf(end).props("placeholder")).toBe("Auto"); + } + expect(wrapper.text()).toContain("Color scale min"); + expect(wrapper.text()).toContain("Color scale max"); + }); + + it.each(["line", "bar", "table"])("is absent for %s", (type) => { + wrapper = createWrapper({ dashboardPanelData: panelOf(type) }); + expect(inputOf("min").exists()).toBe(false); + expect(inputOf("max").exists()).toBe(false); + }); + + it("writes the numbers to config and clears an emptied input to null", async () => { + const panel = panelOf("heatmap"); + wrapper = createWrapper({ dashboardPanelData: panel }); + await inputOf("min").vm.$emit("update:modelValue", 5); + await inputOf("max").vm.$emit("update:modelValue", 50); + expect(panel.data.config.heatmap_color_min).toBe(5); + expect(panel.data.config.heatmap_color_max).toBe(50); + await inputOf("max").vm.$emit("update:modelValue", ""); + expect(panel.data.config.heatmap_color_max).toBeNull(); + }); + + it("stores typed text as a number, and an emptied input as null", async () => { + const panel = panelOf("heatmap"); + wrapper = createWrapper({ dashboardPanelData: panel }); + const field = inputOf("min").find("input"); + await field.setValue("12.5"); + expect(panel.data.config.heatmap_color_min).toBe(12.5); + await field.setValue(""); + expect(panel.data.config.heatmap_color_min).toBeNull(); + }); + + it("flags an inverted pair on both inputs and explains it once", async () => { + const panel = panelOf("heatmap", { heatmap_color_min: 50, heatmap_color_max: 10 }); + wrapper = createWrapper({ dashboardPanelData: panel }); + expect(inputOf("min").props("error")).toBe(true); + expect(inputOf("max").props("error")).toBe(true); + expect(wrapper.text()).toContain("Min must be less than max"); + + panel.data.config.heatmap_color_max = 100; + await flushPromises(); + expect(inputOf("min").props("error")).toBe(false); + expect(inputOf("max").props("error")).toBe(false); + }); + + it.each([ + [null, 10], + [10, null], + [10, 10.5], + ])("does not flag min %s with max %s", (min, max) => { + wrapper = createWrapper({ + dashboardPanelData: panelOf("heatmap", { heatmap_color_min: min, heatmap_color_max: max }), + }); + expect(inputOf("min").props("error")).toBe(false); + }); + }); + + describe("Heatmap palette, reverse and colour scale", () => { + const panelOf = (type: string, config: Record = {}) => + reactive({ + ...mockDashboardPanelData, + data: { + ...mockDashboardPanelData.data, + type, + config: { ...mockDashboardPanelData.data.config, ...config }, + }, + }); + // By the attribute the control receives, as OToggleGroup does not render it on its root. + const control = (field: string) => + ["OSelect", "OSwitch", "OToggleGroup", "OInput"] + .flatMap((name) => wrapper.findAllComponents({ name })) + .find((found: any) => found.vm.$attrs["data-test"] === `dashboard-config-${field}`) ?? { + exists: () => false, + }; + + it.each([false, true])("shows all three for a heatmap (PromQL %s)", (promqlMode) => { + wrapper = createWrapper({ dashboardPanelData: panelOf("heatmap") }, { promqlMode }); + for (const field of ["heatmap_palette", "heatmap_palette_reverse", "heatmap_color_scale"]) { + expect(control(field).exists()).toBe(true); + } + }); + + it.each(["line", "bar", "table"])("is absent for %s", (type) => { + wrapper = createWrapper({ dashboardPanelData: panelOf(type) }); + for (const field of ["heatmap_palette", "heatmap_palette_reverse", "heatmap_color_scale"]) { + expect(control(field).exists()).toBe(false); + } + }); + + it("reads Spectral and square root for a panel that never set them", () => { + wrapper = createWrapper({ dashboardPanelData: panelOf("heatmap") }); + expect(control("heatmap_palette").props("modelValue")).toBe("spectral"); + expect(control("heatmap_color_scale").props("modelValue")).toBe("sqrt"); + expect( + control("heatmap_palette") + .props("options") + .map((o: any) => o.label), + ).toEqual([ + "Spectral", + "Viridis", + "Magma", + "Inferno", + "Blues", + "Greens", + "Reds", + "Red-yellow-blue", + ]); + expect(control("heatmap_color_scale").text()).toContain("LinearSquare rootLog"); + }); + + it("writes the palette, reverse flag and scale to config", async () => { + const panel = panelOf("heatmap"); + wrapper = createWrapper({ dashboardPanelData: panel }); + await control("heatmap_palette").vm.$emit("update:modelValue", "viridis"); + await control("heatmap_palette_reverse").vm.$emit("update:modelValue", true); + await control("heatmap_color_scale").vm.$emit("update:modelValue", "log"); + expect(panel.data.config.heatmap_palette).toBe("viridis"); + expect(panel.data.config.heatmap_palette_reverse).toBe(true); + expect(panel.data.config.heatmap_color_scale).toBe("log"); + }); + }); + + describe("Heatmap Prometheus histogram mode and bucket unit", () => { + const panelOf = (type: string, config: Record = {}) => + reactive({ + ...mockDashboardPanelData, + data: { + ...mockDashboardPanelData.data, + type, + queryType: "promql", + config: { ...mockDashboardPanelData.data.config, ...config }, + }, + }); + // By the attribute the control receives, as OToggleGroup does not render it on its root. + const control = (field: string) => + ["OSelect", "OSwitch", "OToggleGroup", "OInput"] + .flatMap((name) => wrapper.findAllComponents({ name })) + .find((found: any) => found.vm.$attrs["data-test"] === `dashboard-config-${field}`) ?? { + exists: () => false, + }; + + it("offers the mode switch only for a PromQL heatmap", () => { + wrapper = createWrapper({ dashboardPanelData: panelOf("heatmap") }, { promqlMode: true }); + expect(control("heatmap_mode").exists()).toBe(true); + wrapper.unmount(); + wrapper = createWrapper({ dashboardPanelData: panelOf("heatmap") }, { promqlMode: false }); + expect(control("heatmap_mode").exists()).toBe(false); + wrapper.unmount(); + wrapper = createWrapper({ dashboardPanelData: panelOf("line") }, { promqlMode: true }); + expect(control("heatmap_mode").exists()).toBe(false); + }); + + it("toggles heatmap_mode between prometheus_histogram and unset", async () => { + const panel = panelOf("heatmap"); + wrapper = createWrapper({ dashboardPanelData: panel }, { promqlMode: true }); + expect(control("heatmap_mode").props("modelValue")).toBe(false); + await control("heatmap_mode").vm.$emit("update:modelValue", true); + expect(panel.data.config.heatmap_mode).toBe("prometheus_histogram"); + await control("heatmap_mode").vm.$emit("update:modelValue", false); + expect(panel.data.config.heatmap_mode).toBeNull(); + }); + + it("shows the bucket unit only while histogram mode is on", async () => { + const panel = panelOf("heatmap"); + wrapper = createWrapper({ dashboardPanelData: panel }, { promqlMode: true }); + expect(control("bucket_unit").exists()).toBe(false); + panel.data.config.heatmap_mode = "prometheus_histogram"; + await flushPromises(); + expect(control("bucket_unit").exists()).toBe(true); + expect(control("bucket_unit").props("options")).toEqual( + expect.arrayContaining([expect.objectContaining({ value: "seconds" })]), + ); + }); + + it("writes bucket_unit and asks for bucket_unit_custom only for a custom unit", async () => { + const panel = panelOf("heatmap", { heatmap_mode: "prometheus_histogram" }); + wrapper = createWrapper({ dashboardPanelData: panel }, { promqlMode: true }); + await control("bucket_unit").vm.$emit("update:modelValue", "seconds"); + expect(panel.data.config.bucket_unit).toBe("seconds"); + expect(control("bucket_unit_custom").exists()).toBe(false); + + await control("bucket_unit").vm.$emit("update:modelValue", "custom"); + await flushPromises(); + await control("bucket_unit_custom").vm.$emit("update:modelValue", "req"); + expect(panel.data.config.bucket_unit_custom).toBe("req"); + }); + }); + + describe("Heatmap y-axis order, buckets and range", () => { + const panelOf = (config: Record = {}, yType = "Int64", customQuery = false) => + reactive({ + ...mockDashboardPanelData, + data: { + ...mockDashboardPanelData.data, + type: "heatmap", + queries: [ + { + ...mockDashboardPanelData.data.queries[0], + customQuery, + fields: { + breakdown: [], + y: [ + { + alias: "y_axis_1", + functionName: null, + args: [{ type: "field", value: { field: "age" } }], + }, + ], + }, + }, + ], + config: { ...mockDashboardPanelData.data.config, ...config }, + }, + meta: { + ...mockDashboardPanelData.meta, + streamFields: { + groupedFields: [{ stream_alias: null, schema: [{ name: "age", type: yType }] }], + }, + }, + }); + // By the attribute the control receives, as OToggleGroup does not render it on its root. + const control = (field: string) => + ["OSelect", "OSwitch", "OToggleGroup", "OInput"] + .flatMap((name) => wrapper.findAllComponents({ name })) + .find((found: any) => found.vm.$attrs["data-test"] === `dashboard-config-${field}`) ?? { + exists: () => false, + }; + + it("offers data order, ascending and descending, defaulting to data order", async () => { + const panel = panelOf(); + wrapper = createWrapper({ dashboardPanelData: panel }); + expect(control("heatmap_y_sort").props("modelValue")).toBe("data"); + expect(control("heatmap_y_sort").text()).toContain("Data orderAscendingDescending"); + await control("heatmap_y_sort").vm.$emit("update:modelValue", "asc"); + expect(panel.data.config.heatmap_y_sort).toBe("asc"); + await control("heatmap_y_sort").vm.$emit("update:modelValue", "data"); + expect(panel.data.config.heatmap_y_sort).toBeNull(); + }); + + it("shows the ascending order bucketed rows always take, and no data order, while bucketing is on", async () => { + const panel = panelOf({ heatmap_y_bucket: { mode: "size", size: 10 } }); + wrapper = createWrapper({ dashboardPanelData: panel }); + expect(control("heatmap_y_sort").props("modelValue")).toBe("asc"); + expect(control("heatmap_y_sort").text()).toContain("AscendingDescending"); + expect(control("heatmap_y_sort").text()).not.toContain("Data order"); + panel.data.config.heatmap_y_sort = "desc"; + await flushPromises(); + expect(control("heatmap_y_sort").props("modelValue")).toBe("desc"); + }); + + it("offers bucketing only for a numeric builder y", () => { + wrapper = createWrapper({ dashboardPanelData: panelOf({}, "Utf8") }); + expect(control("heatmap_y_bucket").exists()).toBe(false); + wrapper.unmount(); + wrapper = createWrapper({ dashboardPanelData: panelOf({}, "Int64", true) }); + expect(control("heatmap_y_bucket").exists()).toBe(false); + wrapper.unmount(); + wrapper = createWrapper({ dashboardPanelData: panelOf() }); + expect(control("heatmap_y_bucket").props("modelValue")).toBe("none"); + expect(control("heatmap_y_bucket_size").exists()).toBe(false); + expect(control("heatmap_y_min").exists()).toBe(false); + }); + + it("asks for a size > 0 in size mode and writes it to config", async () => { + const panel = panelOf(); + wrapper = createWrapper({ dashboardPanelData: panel }); + await control("heatmap_y_bucket").vm.$emit("update:modelValue", "size"); + await flushPromises(); + expect(panel.data.config.heatmap_y_bucket).toEqual({ mode: "size" }); + expect(control("heatmap_y_bucket_size").props("error")).toBe(true); + expect(wrapper.text()).toContain("Enter a bucket size greater than 0"); + + await control("heatmap_y_bucket_size").vm.$emit("update:modelValue", 10); + await flushPromises(); + expect(panel.data.config.heatmap_y_bucket).toEqual({ mode: "size", size: 10 }); + expect(control("heatmap_y_bucket_size").props("error")).toBe(false); + }); + + it("explains a size that makes more buckets than the heatmap can draw", () => { + wrapper = createWrapper({ + dashboardPanelData: panelOf({ + heatmap_y_bucket: { mode: "size", size: 0.001 }, + heatmap_y_min: 0, + heatmap_y_max: 1000, + }), + }); + expect(control("heatmap_y_bucket_size").props("error")).toBe(true); + expect(wrapper.text()).toContain("This makes 1,000,001 buckets, more than the 416"); + }); + + it("does not offer the axis border toggle, as heatmaps always draw their axis lines", () => { + const line = panelOf(); + line.data.type = "line"; + wrapper = createWrapper({ dashboardPanelData: line }); + expect(control("axis-border").exists()).toBe(true); + wrapper.unmount(); + wrapper = createWrapper({ dashboardPanelData: panelOf() }); + expect(control("axis-border").exists()).toBe(false); + }); + + it("defaults log to base 10, switches base, and clears everything for none", async () => { + const panel = panelOf({ heatmap_y_min: 1, heatmap_y_max: 10 }); + wrapper = createWrapper({ dashboardPanelData: panel }); + await control("heatmap_y_bucket").vm.$emit("update:modelValue", "log"); + await flushPromises(); + expect(panel.data.config.heatmap_y_bucket).toEqual({ mode: "log", base: 10 }); + expect(control("heatmap_y_bucket_base").props("modelValue")).toBe("10"); + await control("heatmap_y_bucket_base").vm.$emit("update:modelValue", "2"); + expect(panel.data.config.heatmap_y_bucket).toEqual({ mode: "log", base: 2 }); + + await control("heatmap_y_bucket").vm.$emit("update:modelValue", "none"); + expect(panel.data.config.heatmap_y_bucket).toBeNull(); + expect(panel.data.config.heatmap_y_min).toBeNull(); + expect(panel.data.config.heatmap_y_max).toBeNull(); + }); + + it("writes the y range and flags an inverted pair", async () => { + const panel = panelOf({ heatmap_y_bucket: { mode: "size", size: 10 } }); + wrapper = createWrapper({ dashboardPanelData: panel }); + await control("heatmap_y_min").vm.$emit("update:modelValue", 50); + await control("heatmap_y_max").vm.$emit("update:modelValue", 10); + await flushPromises(); + expect(panel.data.config.heatmap_y_min).toBe(50); + expect(panel.data.config.heatmap_y_max).toBe(10); + expect(control("heatmap_y_min").props("error")).toBe(true); + expect(control("heatmap_y_max").props("error")).toBe(true); + expect(wrapper.text()).toContain("Min must be less than max; the full range is used instead"); + + await control("heatmap_y_max").vm.$emit("update:modelValue", ""); + await flushPromises(); + expect(panel.data.config.heatmap_y_max).toBeNull(); + expect(control("heatmap_y_min").props("error")).toBe(false); + }); + }); + describe("Time shift (Comparison against)", () => { const shiftedPanel = (queryType: string) => ({ ...mockDashboardPanelData, diff --git a/web/src/components/dashboards/addPanel/ConfigPanel.vue b/web/src/components/dashboards/addPanel/ConfigPanel.vue index 79bc437313..b2ff1fb646 100644 --- a/web/src/components/dashboards/addPanel/ConfigPanel.vue +++ b/web/src/components/dashboards/addPanel/ConfigPanel.vue @@ -569,6 +569,143 @@ along with this program. If not, see . data-test="dashboard-config-decimals" /> +
+ + + + + + +
+ + + + + + + {{ opt.label }} + + + + + + + + + + @@ -902,7 +1039,7 @@ along with this program. If not, see . /> . size="lg" /> + + + {{ opt.label }} + + + + + getUnitOptions(t, dashboardPanelData.data.config.bucket_unit).map((o) => ({ + ...o, + label: raw(o.label), + })), + ); + + const heatmapPaletteLabels: Record = { + spectral: t("dashboard.heatmapPaletteSpectral"), + viridis: t("dashboard.heatmapPaletteViridis"), + magma: t("dashboard.heatmapPaletteMagma"), + inferno: t("dashboard.heatmapPaletteInferno"), + blues: t("dashboard.heatmapPaletteBlues"), + greens: t("dashboard.heatmapPaletteGreens"), + reds: t("dashboard.heatmapPaletteReds"), + "rd-yl-bu": t("dashboard.heatmapPaletteRdYlBu"), + }; + const heatmapPaletteOptions = Object.keys(HEATMAP_PALETTES).map((value) => ({ + label: heatmapPaletteLabels[value], + value, + })); + const heatmapPaletteModel = computed({ + get: () => dashboardPanelData.data.config.heatmap_palette ?? DEFAULT_HEATMAP_PALETTE, + set: (value: string) => { + dashboardPanelData.data.config.heatmap_palette = value; + }, + }); + const heatmapColorScaleLabels: Record = { + linear: t("dashboard.heatmapColorScaleLinear"), + sqrt: t("dashboard.heatmapColorScaleSqrt"), + log: t("dashboard.heatmapColorScaleLog"), + }; + const heatmapColorScaleOptions = HEATMAP_COLOR_SCALES.map((value) => ({ + label: heatmapColorScaleLabels[value], + value, + })); + const heatmapColorScaleModel = computed({ + get: (): string => + dashboardPanelData.data.config.heatmap_color_scale ?? DEFAULT_HEATMAP_COLOR_SCALE, + set: (value: string) => { + dashboardPanelData.data.config.heatmap_color_scale = value; + }, + }); + const heatmapHistogramModeModel = computed({ + get: () => dashboardPanelData.data.config.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE, + set: (on: SwitchValue) => { + dashboardPanelData.data.config.heatmap_mode = on ? PROMETHEUS_HISTOGRAM_MODE : null; + }, + }); + const heatmapYSortLabels: Record = { + data: t("dashboard.heatmapYSortData"), + asc: t("dashboard.heatmapYSortAsc"), + desc: t("dashboard.heatmapYSortDesc"), + }; + // The converter always orders bucket rows by value, so data order is not on offer then. + const heatmapYBucketsActive = computed( + () => + shouldShowHeatmapYBucket(dashboardPanelData, !!promqlMode.value) && + !!heatmapYBucketing(dashboardPanelData.data.config), + ); + const heatmapYSortOptions = computed(() => + HEATMAP_Y_SORTS.filter((value) => !(heatmapYBucketsActive.value && value === "data")).map( + (value) => ({ label: heatmapYSortLabels[value], value }), + ), + ); + const heatmapYSortModel = computed({ + get: (): string => { + const sort = dashboardPanelData.data.config.heatmap_y_sort; + if (heatmapYBucketsActive.value) return sort === "desc" ? "desc" : "asc"; + return sort ?? "data"; + }, + set: (value: string) => { + dashboardPanelData.data.config.heatmap_y_sort = value === "data" ? null : value; + }, + }); + const heatmapYBucketModeOptions = [ + { label: t("dashboard.heatmapYBucketNone"), value: "none" }, + { label: t("dashboard.heatmapYBucketSizeMode"), value: "size" }, + { label: t("dashboard.heatmapYBucketLogMode"), value: "log" }, + ]; + const toNumberOrNull = (value: unknown): number | null => + value === "" || value === null || value === undefined ? null : Number(value); + const setHeatmapYBucket = (patch: Record) => { + dashboardPanelData.data.config.heatmap_y_bucket = { + ...(dashboardPanelData.data.config.heatmap_y_bucket ?? {}), + ...patch, + }; + }; + const heatmapYBucketModeModel = computed({ + get: (): string => dashboardPanelData.data.config.heatmap_y_bucket?.mode ?? "none", + set: (mode: string) => { + if (mode === "none") { + dashboardPanelData.data.config.heatmap_y_bucket = null; + dashboardPanelData.data.config.heatmap_y_min = null; + dashboardPanelData.data.config.heatmap_y_max = null; + } else if (mode === "log") { + setHeatmapYBucket({ + mode, + base: dashboardPanelData.data.config.heatmap_y_bucket?.base ?? HEATMAP_Y_LOG_BASES[0], + }); + } else { + setHeatmapYBucket({ mode }); + } + }, + }); + const heatmapYBucketBaseModel = computed({ + get: (): string => + String(dashboardPanelData.data.config.heatmap_y_bucket?.base ?? HEATMAP_Y_LOG_BASES[0]), + set: (base: string) => setHeatmapYBucket({ base: Number(base) }), + }); const labelPositionOptions = [ { @@ -2980,6 +3382,30 @@ export default defineComponent({ const decimalsTouched = ref(false); + const isHeatmapColorRangeInvalid = computed(() => + isHeatmapColorRangeInverted(dashboardPanelData.data.config), + ); + + const isHeatmapYRangeInvalid = computed(() => + isHeatmapYRangeInverted(dashboardPanelData.data.config), + ); + + // Mirrors the builder, which generates no query while these settings are rejected. + const heatmapYBucketErrorMessage = computed(() => { + const config = dashboardPanelData.data.config; + const error = heatmapYBucketError(config); + if (error === "invalidSize") return t("dashboard.heatmapYBucketSizeInvalid"); + if (error !== "tooManyBuckets") return undefined; + const count = heatmapYBucketCount( + { mode: "size", size: Number(config.heatmap_y_bucket.size) }, + heatmapYRangeOf(config), + ); + return t("dashboard.heatmapYBucketTooMany", { + count: addCommasToNumber(count ?? 0), + max: addCommasToNumber(HEATMAP_MAX_ROWS), + }); + }); + const thresholdStepCount = computed( () => dashboardPanelData.data.config.thresholds?.steps?.length ?? 0, ); @@ -3010,6 +3436,7 @@ export default defineComponent({ return { raw, + isHeatmapColorRangeInvalid, axisLabelModeModel, axisLabelModeOptions, legendsPositionModel, @@ -3033,6 +3460,22 @@ export default defineComponent({ tooltipModeModel, tooltipModeOptions, unitOptions, + bucketUnitOptions, + heatmapPaletteOptions, + heatmapPaletteModel, + heatmapColorScaleOptions, + heatmapColorScaleModel, + heatmapHistogramModeModel, + heatmapYSortOptions, + heatmapYSortModel, + heatmapYBucketModeOptions, + heatmapYBucketModeModel, + heatmapYBucketBaseModel, + heatmapYLogBases: HEATMAP_Y_LOG_BASES, + heatmapYBucketErrorMessage, + isHeatmapYRangeInvalid, + setHeatmapYBucket, + toNumberOrNull, labelPositionOptions, showSymbol, lineInterpolationOptions, @@ -3066,6 +3509,7 @@ export default defineComponent({ shouldShowAreaLineStyleConfig, shouldShowNoValueReplacement, shouldShowAxisConfig, + shouldShowAxisBorderConfig, shouldShowCartesianAxisConfig, shouldShowAxisLabelConfig, shouldShowLineThickness, @@ -3092,6 +3536,8 @@ export default defineComponent({ isPivotMode, decimalsTouched, SECTION_ICONS, + shouldShowHeatmapYBucket, + shouldShowHeatmapYSort, shouldShowThresholds, thresholdStepCount, hasColouredValueMappings, diff --git a/web/src/components/dashboards/exemplars/ExemplarCard.vue b/web/src/components/dashboards/exemplars/ExemplarCard.vue index d652d8f3e4..0c21390abe 100644 --- a/web/src/components/dashboards/exemplars/ExemplarCard.vue +++ b/web/src/components/dashboards/exemplars/ExemplarCard.vue @@ -127,7 +127,7 @@ const position = computed(() => { @@ -121,6 +115,11 @@ import { import { LabelLayout, UniversalTransition } from "echarts/features"; import { CanvasRenderer, SVGRenderer } from "echarts/renderers"; import { withChartFont } from "@/utils/fonts"; +import { + heatmapHoverTime, + heatmapSyncPointer, + heatmapZoomRange, +} from "@/utils/dashboard/heatmapTimeAxis"; import { STATE_TIMELINE_ROW_ZOOM_IDS, readRowWindow, @@ -312,8 +311,8 @@ export default defineComponent({ params.seriesIndex = params?.dataIndex; } - // if sankey chart then do not set seriesName and seriesIndex - if (params?.componentSubType === "sankey") { + // Sankey and heatmap series are not lines to single out, and unnamed heatmaps share ECharts' default name. + if (params?.componentSubType === "sankey" || params?.componentSubType === "heatmap") { params.seriesName = ""; params.seriesIndex = -1; } @@ -589,6 +588,31 @@ export default defineComponent({ } }; + const HOVER_OVERLAYS = ".o2-echarts-tooltip, .o2-exemplar-card"; + + // Entering the tooltip or exemplar card keeps the hover; a reset restores, and so repaints, every panel. + const onChartMouseLeave = (event: MouseEvent) => { + const overlay = (event?.relatedTarget as Element | null)?.closest?.(HOVER_OVERLAYS); + if (overlay) { + followOverlayLeave(overlay); + return; + } + if (hoveredSeriesState) hoveredSeriesState.setIndex(-1, -1, -1, null); + }; + + // The overlays are teleported out of the chart, so leaving one for anywhere but the chart is leaving the chart. + const followOverlayLeave = (overlay: Element) => { + overlay.addEventListener( + "mouseleave", + (event) => { + const to = (event as MouseEvent).relatedTarget as Node | null; + if (to && chartRef.value?.contains(to)) return; + onChartMouseLeave(event as MouseEvent); + }, + { once: true }, + ); + }; + // Handle native DOM contextmenu event for alert creation // This handles right-clicks anywhere on the chart (including empty space) const handleNativeContextMenu = async (event: MouseEvent) => { @@ -674,7 +698,8 @@ export default defineComponent({ // and chart must be timeseries chart if ( props.data.extras?.panelId == hoveredSeriesState?.value?.panelId && - props?.data?.extras?.isTimeSeries === true + props?.data?.extras?.isTimeSeries === true && + !props.data.extras?.heatmapTimeAxis ) { const seriesIndex = params?.batch?.[0]?.seriesIndex; const dataIndex = Math.max(params?.batch?.[0]?.dataIndex, 0); @@ -703,12 +728,21 @@ export default defineComponent({ } //if batch then emit dataZoom event if (params?.batch) { - emit("updated:dataZoom", { - start: params?.batch[0]?.startValue || 0, - end: params?.batch[0]?.endValue || 0, - start1: params?.batch[1]?.startValue || 0, - end1: params?.batch[1]?.endValue || 0, - }); + // A heatmap zooms by category index, which only its columns can turn into times. + const heatmapRange = heatmapZoomRange( + props.data?.extras?.heatmapTimeAxis, + params.batch[0]?.startValue, + params.batch[0]?.endValue, + ); + emit( + "updated:dataZoom", + heatmapRange ?? { + start: params?.batch[0]?.startValue || 0, + end: params?.batch[0]?.endValue || 0, + start1: params?.batch[1]?.startValue || 0, + end1: params?.batch[1]?.endValue || 0, + }, + ); const rows = lastRowWindow; restoreChart(); // Restore also rewinds the rows, so the scrolled window is put back. @@ -741,6 +775,19 @@ export default defineComponent({ chart?.on("mouseover", function (params: any) { if (isLineStrokeEvent(params)) return; emit("mouseover", params); + const heatmapTime = heatmapHoverTime(props.data?.extras?.heatmapTimeAxis, params); + if ( + heatmapTime !== undefined && + props.data.extras?.panelId === hoveredSeriesState?.value?.panelId + ) { + // The column, so a panel on the same time grid finds the instant at that index directly. + throttledSetHoveredSeriesIndex( + Number(params.value[0]), + params.seriesIndex, + props.data.extras.panelId || -1, + heatmapTime, + ); + } }); chart?.on("finished", () => { @@ -771,13 +818,21 @@ export default defineComponent({ // if hovered series is not same as currently hovered series // and hovered series is not -1 // and chart is time series - if ( + const followsHoveredPanel = isChartVisible && props?.data?.extras?.panelId && props?.data?.extras?.panelId != hoveredSeriesState?.value?.panelId && hoveredSeriesState?.value?.panelId != -1 && - props?.data?.extras?.isTimeSeries === true - ) { + props?.data?.extras?.isTimeSeries === true; + if (followsHoveredPanel && props.data.extras.heatmapTimeAxis) { + chart?.dispatchAction( + heatmapSyncPointer( + props.data.extras.heatmapTimeAxis, + hoveredSeriesState.value?.hoveredTime, + (column) => chart?.convertToPixel({ gridIndex: 0 }, [column, 0]), + ), + ); + } else if (followsHoveredPanel) { // need to check index should not be greater than series length const hoveredSeriesIndex = props.data.extras?.crosshairSeriesIndex ?? @@ -823,6 +878,9 @@ export default defineComponent({ chart?.dispatchAction({ type: "updateAxisPointer", currTrigger: "leave" }); } else { restoreChart(); + if (props.data?.extras?.heatmapTimeAxis) { + chart?.dispatchAction({ type: "updateAxisPointer", currTrigger: "leave" }); + } } } }, @@ -1145,6 +1203,7 @@ export default defineComponent({ chartId, hoveredSeriesState, handleNativeContextMenu, + onChartMouseLeave, reportPointerTime, get chart() { return chart; diff --git a/web/src/composables/dashboard/useConfigPanel.spec.ts b/web/src/composables/dashboard/useConfigPanel.spec.ts index 98356ddee2..cc25013406 100644 --- a/web/src/composables/dashboard/useConfigPanel.spec.ts +++ b/web/src/composables/dashboard/useConfigPanel.spec.ts @@ -40,6 +40,7 @@ vi.mock("@/utils/dashboard/configUtils", () => ({ shouldShowAreaLineStyleConfig: vi.fn(() => false), shouldShowNoValueReplacement: vi.fn(() => false), shouldShowAxisConfig: vi.fn(() => false), + shouldShowAxisBorderConfig: vi.fn(() => false), shouldShowCartesianAxisConfig: vi.fn(() => false), shouldShowGridlines: vi.fn(() => false), shouldShowAxisLabelConfig: vi.fn(() => false), @@ -648,12 +649,12 @@ describe("useConfigPanel – axis section", () => { expect(c.isConfigOptionVisible("axis", "axis-width")).toBe(true); }); - it("axis-border visibility delegates to shouldShowAxisConfig", async () => { - const { shouldShowAxisConfig } = await import("@/utils/dashboard/configUtils"); - vi.mocked(shouldShowAxisConfig).mockReturnValue(true); + it("axis-border visibility delegates to shouldShowAxisBorderConfig", async () => { + const { shouldShowAxisBorderConfig } = await import("@/utils/dashboard/configUtils"); + vi.mocked(shouldShowAxisBorderConfig).mockReturnValue(true); const c = makeComposable(); expect(c.isConfigOptionVisible("axis", "axis-border")).toBe(true); - vi.mocked(shouldShowAxisConfig).mockReturnValue(false); + vi.mocked(shouldShowAxisBorderConfig).mockReturnValue(false); }); it("y-axis visibility delegates to shouldShowCartesianAxisConfig", async () => { @@ -1174,17 +1175,22 @@ describe("useConfigPanel – show exemplars option", () => { return panel; }; - it("is found by searching 'exemplars' on an eligible PromQL panel", async () => { - const c = makeComposable(promqlPanel("line"), ref(true)); - c.searchQuery.value = "exemplars"; - await nextTick(); - expect(c.isConfigOptionVisible("general", "show-exemplars")).toBe(true); - }); + it.each(["line", "heatmap"])( + "is found by searching 'exemplars' on an eligible PromQL %s panel", + async (type) => { + const c = makeComposable(promqlPanel(type), ref(true)); + c.searchQuery.value = "exemplars"; + await nextTick(); + expect(c.isConfigOptionVisible("general", "show-exemplars")).toBe(true); + }, + ); it.each([ ["h-bar", "range", true], ["line", "instant", true], + ["heatmap", "instant", true], ["line", "range", false], + ["heatmap", "range", false], ])("stays hidden for %s/%s with promqlMode=%s", (type, queryType, promql) => { const c = makeComposable(promqlPanel(type, queryType), ref(promql)); expect(c.isConfigOptionVisible("general", "show-exemplars")).toBe(false); @@ -1209,3 +1215,129 @@ describe("filterOption – plural queries", () => { expect(filterOption({ label }, query)).toBe(false); }); }); + +describe("useConfigPanel – heatmap colour range", () => { + it("is visible only for heatmap panels", () => { + expect( + makeComposable(makePanelData("heatmap")).isConfigOptionVisible("data", "heatmap-color-range"), + ).toBe(true); + expect( + makeComposable(makePanelData("bar")).isConfigOptionVisible("data", "heatmap-color-range"), + ).toBe(false); + }); + + it.each(["heatmap-palette", "heatmap-palette-reverse", "heatmap-color-scale"])( + "shows %s for heatmap panels only, SQL and PromQL alike", + (option) => { + expect(makeComposable(makePanelData("heatmap")).isConfigOptionVisible("data", option)).toBe( + true, + ); + expect( + makeComposable(makePanelData("heatmap"), ref(true)).isConfigOptionVisible("data", option), + ).toBe(true); + expect(makeComposable(makePanelData("bar")).isConfigOptionVisible("data", option)).toBe( + false, + ); + }, + ); + + it("shows the histogram mode switch for PromQL heatmaps only", () => { + const visible = (type: string, promql: boolean) => + makeComposable(makePanelData(type), ref(promql)).isConfigOptionVisible( + "data", + "heatmap-histogram-mode", + ); + expect(visible("heatmap", true)).toBe(true); + expect(visible("heatmap", false)).toBe(false); + expect(visible("line", true)).toBe(false); + }); + + it("shows the bucket unit only in histogram mode, and its custom text only for a custom unit", () => { + const visible = (config: Record, option: string) => + makeComposable(makePanelData("heatmap", config), ref(true)).isConfigOptionVisible( + "data", + option, + ); + expect(visible({}, "bucket-unit")).toBe(false); + expect(visible({ heatmap_mode: "prometheus_histogram" }, "bucket-unit")).toBe(true); + expect(visible({ heatmap_mode: "prometheus_histogram" }, "bucket-unit-custom")).toBe(false); + expect( + visible( + { heatmap_mode: "prometheus_histogram", bucket_unit: "custom" }, + "bucket-unit-custom", + ), + ).toBe(true); + }); + + it("is found by searching either label", () => { + const c = makeComposable(makePanelData("heatmap")); + // The i18n mock returns keys, so the search matches the key text. + c.searchQuery.value = "heatmapcolormax"; + expect(c.isConfigOptionVisible("data", "heatmap-color-range")).toBe(true); + c.searchQuery.value = "legend"; + expect(c.isConfigOptionVisible("data", "heatmap-color-range")).toBe(false); + }); +}); + +describe("useConfigPanel – heatmap y axis", () => { + const numericY = { + fields: { + y: [{ alias: "y", functionName: null, args: [{ type: "field", value: { field: "age" } }] }], + }, + }; + const withSchema = (panel: any, type = "Int64") => ({ + ...panel, + meta: { + streamFields: { groupedFields: [{ stream_alias: null, schema: [{ name: "age", type }] }] }, + }, + }); + const visible = (panel: any, option: string, promql = false) => + makeComposable(panel, ref(promql)).isConfigOptionVisible("axis", option); + + it("shows the y sort for SQL and generic PromQL heatmaps, not histogram mode or other charts", () => { + expect(visible(makePanelData("heatmap"), "heatmap-y-sort")).toBe(true); + expect(visible(makePanelData("heatmap", {}, { customQuery: true }), "heatmap-y-sort")).toBe( + true, + ); + expect(visible(makePanelData("heatmap"), "heatmap-y-sort", true)).toBe(true); + expect( + visible( + makePanelData("heatmap", { heatmap_mode: "prometheus_histogram" }), + "heatmap-y-sort", + true, + ), + ).toBe(false); + expect(visible(makePanelData("bar"), "heatmap-y-sort")).toBe(false); + }); + + it("shows y bucketing only for a builder heatmap whose y is a numeric column", () => { + expect(visible(withSchema(makePanelData("heatmap", {}, numericY)), "heatmap-y-bucket")).toBe( + true, + ); + expect( + visible(withSchema(makePanelData("heatmap", {}, numericY), "Utf8"), "heatmap-y-bucket"), + ).toBe(false); + expect( + visible( + withSchema(makePanelData("heatmap", {}, { ...numericY, customQuery: true })), + "heatmap-y-bucket", + ), + ).toBe(false); + expect( + visible(withSchema(makePanelData("heatmap", {}, numericY)), "heatmap-y-bucket", true), + ).toBe(false); + expect(visible(withSchema(makePanelData("bar", {}, numericY)), "heatmap-y-bucket")).toBe(false); + }); + + it("shows the y range only while bucketing is on", () => { + expect(visible(withSchema(makePanelData("heatmap", {}, numericY)), "heatmap-y-range")).toBe( + false, + ); + expect( + visible( + withSchema(makePanelData("heatmap", { heatmap_y_bucket: { mode: "log" } }, numericY)), + "heatmap-y-range", + ), + ).toBe(true); + }); +}); diff --git a/web/src/composables/dashboard/useConfigPanel.ts b/web/src/composables/dashboard/useConfigPanel.ts index ffdab86541..93c765faab 100644 --- a/web/src/composables/dashboard/useConfigPanel.ts +++ b/web/src/composables/dashboard/useConfigPanel.ts @@ -11,6 +11,7 @@ import { shouldShowAreaLineStyleConfig, shouldShowNoValueReplacement, shouldShowAxisConfig, + shouldShowAxisBorderConfig, shouldShowCartesianAxisConfig, shouldShowGridlines, shouldShowAxisLabelConfig, @@ -24,6 +25,8 @@ import { DEFAULT_EXPANDED_SECTIONS, } from "@/utils/dashboard/searchLabelsConfig"; import { isExemplarEligible } from "@/utils/dashboard/exemplars/exemplarEligibility"; +import { PROMETHEUS_HISTOGRAM_MODE } from "@/utils/dashboard/promql/convertPromQLHeatmapChart"; +import { isNumericYField } from "@/utils/dashboard/heatmapYAxis"; // --------------------------------------------------------------------------- // Types @@ -75,6 +78,22 @@ export const SERIES_OVERRIDE_CHART_TYPES: readonly string[] = [ export const shouldShowSeriesOverrides = (dashboardPanelData: any): boolean => SERIES_OVERRIDE_CHART_TYPES.includes(dashboardPanelData?.data?.type); +// Histogram mode always orders rows by le, so the sort control would do nothing there. +export const shouldShowHeatmapYSort = (dashboardPanelData: any, promqlMode: boolean): boolean => + dashboardPanelData?.data?.type === "heatmap" && + !(promqlMode && dashboardPanelData?.data?.config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE); + +// Bucketing is generated in the builder's SQL, so custom SQL and PromQL keep the y values they return. +export const shouldShowHeatmapYBucket = (dashboardPanelData: any, promqlMode: boolean): boolean => { + const query = dashboardPanelData?.data?.queries?.[dashboardPanelData?.layout?.currentQueryIndex]; + return ( + !promqlMode && + dashboardPanelData?.data?.type === "heatmap" && + !query?.customQuery && + isNumericYField(query?.fields?.y?.[0], dashboardPanelData) + ); +}; + // --------------------------------------------------------------------------- // Pure filter helpers (exported so they can be unit-tested independently) // --------------------------------------------------------------------------- @@ -259,6 +278,41 @@ export function useConfigPanel( visible: !isStateTimeline.value && dashboardPanelData.data.config.unit === "custom", }, decimals: { label: t("dashboard.decimals"), visible: !isStateTimeline.value }, + "heatmap-color-range": { + label: [t("dashboard.heatmapColorMin"), t("dashboard.heatmapColorMax")], + visible: dashboardPanelData.data.type === "heatmap", + }, + "heatmap-palette": { + label: t("dashboard.heatmapPalette"), + visible: dashboardPanelData.data.type === "heatmap", + }, + "heatmap-palette-reverse": { + label: t("dashboard.heatmapPaletteReverse"), + visible: dashboardPanelData.data.type === "heatmap", + }, + "heatmap-color-scale": { + label: t("dashboard.heatmapColorScale"), + visible: dashboardPanelData.data.type === "heatmap", + }, + "heatmap-histogram-mode": { + label: t("dashboard.heatmapHistogramMode"), + visible: !!promqlMode.value && dashboardPanelData.data.type === "heatmap", + }, + "bucket-unit": { + label: t("dashboard.bucketUnit"), + visible: + !!promqlMode.value && + dashboardPanelData.data.type === "heatmap" && + dashboardPanelData.data.config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE, + }, + "bucket-unit-custom": { + label: t("dashboard.bucketUnitCustom"), + visible: + !!promqlMode.value && + dashboardPanelData.data.type === "heatmap" && + dashboardPanelData.data.config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE && + dashboardPanelData.data.config?.bucket_unit === "custom", + }, limit: { label: t("dashboard.queryLimit"), visible: @@ -318,9 +372,27 @@ export function useConfigPanel( label: t("common.axisWidth"), visible: shouldShowAxisConfig(dashboardPanelData), }, + "heatmap-y-sort": { + label: t("dashboard.heatmapYSort"), + visible: shouldShowHeatmapYSort(dashboardPanelData, !!promqlMode.value), + }, + "heatmap-y-bucket": { + label: [ + t("dashboard.heatmapYBucket"), + t("dashboard.heatmapYBucketSize"), + t("dashboard.heatmapYBucketBase"), + ], + visible: shouldShowHeatmapYBucket(dashboardPanelData, !!promqlMode.value), + }, + "heatmap-y-range": { + label: [t("dashboard.heatmapYMin"), t("dashboard.heatmapYMax")], + visible: + shouldShowHeatmapYBucket(dashboardPanelData, !!promqlMode.value) && + !!dashboardPanelData.data.config?.heatmap_y_bucket?.mode, + }, "axis-border": { label: t("dashboard.showBorder"), - visible: shouldShowAxisConfig(dashboardPanelData), + visible: shouldShowAxisBorderConfig(dashboardPanelData), }, "y-axis": { label: [t("common.yAxisMin"), t("common.yAxisMax")], diff --git a/web/src/composables/dashboard/useDashboardPanel.spec.ts b/web/src/composables/dashboard/useDashboardPanel.spec.ts index e8cb391445..411263967c 100644 --- a/web/src/composables/dashboard/useDashboardPanel.spec.ts +++ b/web/src/composables/dashboard/useDashboardPanel.spec.ts @@ -197,6 +197,40 @@ describe("useDashboardPanel", () => { expect(panel.dashboardPanelData.data.queries[1].config).not.toHaveProperty("formula"); }); + it("rebuilds every other builder tab of a heatmap with its y bucketing, leaving custom SQL alone", () => { + const panel = useDashboardPanelData("dashboard-panel-test-heatmap-tabs"); + const data = panel.dashboardPanelData; + data.data.type = "heatmap"; + data.data.queryType = "sql"; + data.data.config.heatmap_y_bucket = { mode: "size", size: 10 }; + data.meta.streamFields.groupedFields = [ + { name: "logs", stream_alias: null, schema: [{ name: "took", type: "Int64" }] }, + ]; + const builderQuery = () => ({ + customQuery: false, + query: "stale", + joins: [], + fields: { + stream: "logs", + stream_type: "logs", + x: [], + y: [{ alias: "y_axis_1", args: [{ type: "field", value: { field: "took" } }] }], + z: [], + breakdown: [], + filter: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }, + config: {}, + }); + data.data.queries = [builderQuery(), builderQuery(), { ...builderQuery(), customQuery: true }]; + data.layout.currentQueryIndex = 0; + + panel.regenerateOtherBuilderQueries(); + + expect(data.data.queries[0].query).toBe("stale"); + expect(data.data.queries[1].query).toContain("floor(CAST(took AS DOUBLE) / 10) * 10"); + expect(data.data.queries[2].query).toBe("stale"); + }); + it("uses user defined schema when enabled", () => { const panel = useDashboardPanelData("dashboard-panel-test-4"); diff --git a/web/src/composables/dashboard/useDashboardPanel.ts b/web/src/composables/dashboard/useDashboardPanel.ts index 658415af6d..b033e7d658 100644 --- a/web/src/composables/dashboard/useDashboardPanel.ts +++ b/web/src/composables/dashboard/useDashboardPanel.ts @@ -1021,6 +1021,24 @@ const useDashboardPanelData = (pageKey: string = "dashboard", t: TranslateFn) => } return; }; + // Heatmap y bucketing is panel config, so every builder tab's SQL must carry it, not only the active tab's. + const regenerateOtherBuilderQueries = () => { + if ( + dashboardPanelData.data.type !== "heatmap" || + dashboardPanelData.data.queryType === "promql" + ) { + return; + } + if (!dashboardPanelData?.meta?.streamFields?.groupedFields?.length) return; + dashboardPanelData.data.queries.forEach((queryData: any, index: number) => { + if (index === dashboardPanelData.layout.currentQueryIndex || queryData?.customQuery) return; + queryData.query = buildSQLChartQuery({ + queryData, + chartType: dashboardPanelData.data.type, + dashboardPanelData, + }); + }); + }; const { checkTimestampAlias } = logsUtils(); // Replace the existing validatePanel function with a wrapper that calls the generic function const validatePanelWrapper = (errors: string[], isFieldsValidationRequired: boolean = true) => { @@ -1619,6 +1637,7 @@ const useDashboardPanelData = (pageKey: string = "dashboard", t: TranslateFn) => getDefaultQueries, validatePanel: validatePanelWrapper, makeAutoSQLQuery, + regenerateOtherBuilderQueries, currentXLabel, currentYLabel, generateLabelFromName, diff --git a/web/src/composables/dashboard/usePanelDrilldown.spec.ts b/web/src/composables/dashboard/usePanelDrilldown.spec.ts index 79f8cd2002..efaad945f7 100644 --- a/web/src/composables/dashboard/usePanelDrilldown.spec.ts +++ b/web/src/composables/dashboard/usePanelDrilldown.spec.ts @@ -235,6 +235,18 @@ describe("usePanelDrilldown", () => { expect(deps.router.push).not.toHaveBeenCalled(); }); + it("hands a stacked heatmap grid's exemplar marker click to the exemplar handler", async () => { + const deps = makeDeps(); + const onExemplarClick = vi.fn(); + const api = usePanelDrilldown({ ...deps, onExemplarClick } as any); + const params = { seriesId: "__exemplars__:2", data: { exemplar: { id: "e1" } } }; + + await api.onChartClick(params); + + expect(onExemplarClick).toHaveBeenCalledWith(params); + expect(api.drilldownArray.value).toEqual([]); + }); + it("does not treat an exemplar click as an annotation in add-annotation mode", async () => { const deps = makeDeps(); deps.allowAnnotationsAdd.value = true; @@ -306,6 +318,199 @@ describe("usePanelDrilldown", () => { ); }); + describe("heatmap drilldown", () => { + const heatmapDeps = (options: any, extras: any = {}) => { + const deps = makeDeps(); + deps.panelSchema.value.type = "heatmap"; + deps.panelSchema.value.config.drilldown = [ + { + name: "Cell", + type: "byUrl", + data: { + url: "https://example.com/?row=${series.__name}&col=${series.__axisValue}&v=${series.__value}", + }, + }, + ]; + (deps.panelData as any).value = { options, extras }; + return deps; + }; + + const clickAndOpen = async (deps: any, params: any) => { + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const api = usePanelDrilldown(deps as any); + await api.onChartClick({ + componentType: "series", + event: { offsetX: 1, offsetY: 1 }, + ...params, + }); + await api.openDrilldown(0); + const url = openSpy.mock.calls[0]?.[0]; + openSpy.mockRestore(); + return url; + }; + + it("passes the clicked row as the series name and the clicked column as the axis value", async () => { + const deps = heatmapDeps({ + xAxis: [{ data: ["10:00:00", "10:01:00"] }], + yAxis: { data: ["svc-a", "svc-b"] }, + series: [{ type: "heatmap" }], + }); + + const url = await clickAndOpen(deps, { seriesIndex: 0, name: "10:01:00", value: [1, 0, 7] }); + + expect(url).toBe("https://example.com/?row=svc-a&col=10:01:00&v=7"); + }); + + it("passes the raw row value, not the label drawn for an empty category", async () => { + const deps = heatmapDeps( + { + xAxis: [{ data: ["GET"] }], + yAxis: { data: ["(empty)", "svc-b"] }, + series: [{ type: "heatmap" }], + }, + { heatmapRowValues: [["", "svc-b"]] }, + ); + + const url = await clickAndOpen(deps, { seriesIndex: 0, name: "GET", value: [0, 0, 7] }); + + expect(url).toBe("https://example.com/?row=&col=GET&v=7"); + }); + + it("opens a URL drilldown on a time column, passing the column start in epoch ms", async () => { + const start = Date.UTC(2026, 2, 18, 0, 30); + const deps = heatmapDeps( + { + xAxis: [{ data: ["2026-03-18 00:30:00"] }], + yAxis: { data: ["svc-a"] }, + series: [{ type: "heatmap" }], + }, + { + heatmapTimeAxis: { + endInclusive: false, + columns: [{ start, end: start + 60_000, x: null }], + }, + }, + ); + + const url = await clickAndOpen(deps, { + seriesIndex: 0, + name: "2026-03-18 00:30:00", + value: [0, 0, 7], + }); + + expect(url).toBe(`https://example.com/?row=svc-a&col=${start}&v=7`); + }); + + it("reads the row from the clicked grid of a stacked histogram heatmap", async () => { + const deps = heatmapDeps({ + xAxis: [{ data: ["10:00:00"] }, { gridIndex: 1, data: ["10:00:00"] }], + yAxis: [{ data: ["0.1", "+Inf"] }, { gridIndex: 1, data: ["1", "5", "+Inf"] }], + series: [ + { type: "heatmap", xAxisIndex: 0, yAxisIndex: 0 }, + { type: "heatmap", xAxisIndex: 1, yAxisIndex: 1 }, + ], + }); + + const url = await clickAndOpen(deps, { seriesIndex: 1, name: "10:00:00", value: [0, 1, 3] }); + + expect(url).toBe("https://example.com/?row=5&col=10:00:00&v=3"); + }); + + it.each([ + ["a SQL histogram column", 60_000, [[{ histogram_interval: 1 }]]], + ["a PromQL step, which has no histogram interval", 30_000, []], + ["a column clipped by the query range", 12_000, [[{ histogram_interval: 60 }]]], + ])("opens Logs over exactly the clicked column for %s", async (_name, width, meta) => { + const start = Date.UTC(2026, 2, 18, 0, 30); + const deps = heatmapDeps( + { + xAxis: [{ data: ["00:29:00", "00:30:00"] }], + yAxis: { data: ["svc-a"] }, + series: [{ type: "heatmap" }], + }, + { + heatmapTimeAxis: { + endInclusive: false, + columns: [ + { start: start - 60_000, end: start, x: null }, + { start, end: start + width, x: null }, + ], + }, + }, + ); + deps.resultMetaData.value = meta as any; + deps.panelSchema.value.config.drilldown = [ + { + name: "Logs", + type: "logs", + targetBlank: false, + data: { + logsMode: "custom", + logsQuery: 'SELECT * FROM "default" WHERE svc = ${series.__name}', + }, + } as any, + ]; + const api = usePanelDrilldown(deps as any); + + await api.onChartClick({ + componentType: "series", + event: { offsetX: 1, offsetY: 1 }, + seriesIndex: 0, + name: "00:30:00", + value: [1, 0, 4], + }); + await api.openDrilldown(0); + + await vi.waitFor(() => + expect(deps.router.push).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + from: String(start * 1000), + to: String((start + width) * 1000), + query: `b64(SELECT * FROM "default" WHERE svc = 'svc-a')`, + }), + }), + ), + ); + }); + + it("opens Logs over the dashboard range for a heatmap without a time axis", async () => { + const deps = heatmapDeps({ + xAxis: [{ data: ["GET", "POST"] }], + yAxis: { data: ["svc-a"] }, + series: [{ type: "heatmap" }], + }); + deps.panelSchema.value.config.drilldown = [ + { + name: "Logs", + type: "logs", + targetBlank: false, + data: { logsMode: "custom", logsQuery: 'SELECT * FROM "default"' }, + } as any, + ]; + const api = usePanelDrilldown(deps as any); + + await api.onChartClick({ + componentType: "series", + event: {}, + seriesIndex: 0, + value: [1, 0, 4], + }); + await api.openDrilldown(0); + + await vi.waitFor(() => + expect(deps.router.push).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + from: String(Date.UTC(2026, 2, 18)), + to: String(Date.UTC(2026, 2, 18, 1)), + }), + }), + ), + ); + }); + }); + describe("state timeline segment", () => { const segmentClick = { componentType: "series", diff --git a/web/src/composables/dashboard/usePanelDrilldown.ts b/web/src/composables/dashboard/usePanelDrilldown.ts index 689842f386..3df18ad1f8 100644 --- a/web/src/composables/dashboard/usePanelDrilldown.ts +++ b/web/src/composables/dashboard/usePanelDrilldown.ts @@ -33,7 +33,7 @@ import { SQL_PARSE_MAX_DEPTH, stripWherePredicate, } from "@/utils/query/sqlComplexity"; -import { EXEMPLAR_SERIES_ID } from "@/utils/dashboard/exemplars/applyExemplarSeries"; +import { isExemplarSeriesId } from "@/utils/dashboard/exemplars/applyExemplarSeries"; import { stateTimelineBreakdownColumn, stateTimelineDrilldownSeries, @@ -201,6 +201,48 @@ export function usePanelDrilldown({ ); }; + // A heatmap's value[0] is a column index, so its time range is that column's bounds on the heatmap time axis. + const heatmapColumnRange = (params: any) => { + const column = panelData?.value?.extras?.heatmapTimeAxis?.columns?.[params?.value?.[0]]; + if (!column) return null; + const startTime = getUTCTimestampFromZonedTimestamp(column.start, store.state.timezone); + const endTime = getUTCTimestampFromZonedTimestamp(column.end, store.state.timezone); + return startTime && endTime ? { startTime, endTime } : null; + }; + + // A heatmap cell's value is [column index, row index, z]; its labels live on the axes of the grid its series is drawn in, its raw row values in extras. + const heatmapCellValues = (params: any): { column: any; row: any } => { + const options = panelData?.value?.options; + const series = options?.series?.[params?.seriesIndex]; + const axisOf = (axes: any, index: number) => (Array.isArray(axes) ? axes[index] : axes); + const [column, row] = Array.isArray(params?.value) ? params.value : []; + const yAxisIndex = series?.yAxisIndex ?? 0; + const rawRow = panelData?.value?.extras?.heatmapRowValues?.[yAxisIndex]?.[row]; + // A time column is its start in epoch ms, as state timelines give it: a formatted time has a space a URL cannot carry. + const columnRange = heatmapColumnRange(params); + return { + column: columnRange + ? columnRange.startTime / 1000 + : (axisOf(options?.xAxis, series?.xAxisIndex ?? 0)?.data?.[column] ?? params?.name), + row: rawRow !== undefined ? rawRow : axisOf(options?.yAxis, yAxisIndex)?.data?.[row], + }; + }; + + const seriesDrilldownVariables = (params: any) => { + const value = Array.isArray(params?.value) + ? params.value[params.value.length - 1] + : params?.value; + if (panelSchema.value.type === "heatmap") { + const { column, row } = heatmapCellValues(params); + return { __name: row ?? params?.name, __value: value, __axisValue: column }; + } + return { + __name: ["pie", "donut"].includes(panelSchema.value.type) ? params?.name : params?.seriesName, + __value: value, + __axisValue: params?.value?.[0] ?? params?.name, + }; + }; + // get offset from parent function getOffsetFromParent(parent: any, child: any) { const parentRect = parent.getBoundingClientRect(); @@ -529,7 +571,7 @@ export function usePanelDrilldown({ const onChartClick = async (params: any, ...args: any) => { // An exemplar marker opens its trace, never the drilldown menu or an annotation. - if (params?.seriesId === EXEMPLAR_SERIES_ID) { + if (isExemplarSeriesId(params?.seriesId)) { await onExemplarClick?.(params); return; } @@ -774,7 +816,8 @@ export function usePanelDrilldown({ getOriginalQueryAndStream(queryDetails, metadata) || {}; if (!originalQuery || !streamName) return; - const hoveredTime = drilldownParams[0]?.value?.[0]; + const isHeatmap = panelSchema.value.type === "heatmap"; + const hoveredTime = isHeatmap ? null : drilldownParams[0]?.value?.[0]; const hoveredTimestamp = hoveredTime ? getUTCTimestampFromZonedTimestamp(hoveredTime, store.state.timezone) : null; @@ -784,7 +827,8 @@ export function usePanelDrilldown({ const segment = isStateTimeline ? stateTimelineDrilldownSeries(drilldownParams[0]) : null; const calculatedTimeRange = segment ? { startTime: segment.__start * 1000, endTime: segment.__end * 1000 } - : calculateTimeRange(hoveredTimestamp, intervalMicro.value); + : ((isHeatmap ? heatmapColumnRange(drilldownParams[0]) : null) ?? + calculateTimeRange(hoveredTimestamp, intervalMicro.value)); let modifiedQuery = originalQuery; @@ -889,15 +933,7 @@ export function usePanelDrilldown({ } else if (isStateTimeline) { drilldownVariables.series = segment; } else { - drilldownVariables.series = { - __name: ["pie", "donut", "heatmap"].includes(panelSchema.value.type) - ? drilldownParams[0].name - : drilldownParams[0].seriesName, - __value: Array.isArray(drilldownParams[0].value) - ? drilldownParams[0].value[drilldownParams[0].value.length - 1] - : drilldownParams[0].value, - __axisValue: drilldownParams?.[0]?.value?.[0] ?? drilldownParams?.[0]?.name, - }; + drilldownVariables.series = seriesDrilldownVariables(drilldownParams[0]); } variablesData?.value?.values?.forEach((variable: any) => { @@ -1020,16 +1056,7 @@ export function usePanelDrilldown({ } else if (panelSchema.value.type == "state-timeline") { drilldownVariables.series = stateTimelineDrilldownSeries(drilldownParams[0]); } else { - // we have an series object - drilldownVariables.series = { - __name: ["pie", "donut", "heatmap"].includes(panelSchema.value.type) - ? drilldownParams[0].name - : drilldownParams[0].seriesName, - __value: Array.isArray(drilldownParams[0].value) - ? drilldownParams[0].value[drilldownParams[0].value.length - 1] - : drilldownParams[0].value, - __axisValue: drilldownParams?.[0]?.value?.[0] ?? drilldownParams?.[0]?.name, - }; + drilldownVariables.series = seriesDrilldownVariables(drilldownParams[0]); } variablesData?.value?.values?.forEach((variable: any) => { diff --git a/web/src/composables/dashboard/usePanelExemplarInteraction.spec.ts b/web/src/composables/dashboard/usePanelExemplarInteraction.spec.ts index a3c3de7f60..5da625d804 100644 --- a/web/src/composables/dashboard/usePanelExemplarInteraction.spec.ts +++ b/web/src/composables/dashboard/usePanelExemplarInteraction.spec.ts @@ -19,6 +19,7 @@ import { EXEMPLAR_CARD_CLOSE_MS, EXEMPLAR_LOOKUP_DWELL_MS, usePanelExemplarInteraction, + type ExemplarGridPlacer, } from "./usePanelExemplarInteraction"; import { EXEMPLAR_SERIES_ID } from "@/utils/dashboard/exemplars/applyExemplarSeries"; import type { ExemplarMarker, TraceVerdict } from "@/ts/interfaces/exemplars"; @@ -46,6 +47,7 @@ const setup = ( markers: ExemplarMarker[], verdict: TraceVerdict = found, placeY?: (m: ExemplarMarker) => number | null, + gridPlacer?: ExemplarGridPlacer | null, ) => { const cache = new Map(); let release: () => void = () => {}; @@ -87,6 +89,7 @@ const setup = ( toChartX: (ts) => ts, navigate, placeY, + gridPlacer: ref(gridPlacer ?? null), }), )!; return { api, lookupFn, navigate, chart, release, scope }; @@ -284,4 +287,64 @@ describe("usePanelExemplarInteraction", () => { api.onMouseOver({ seriesId: "cpu", data: { exemplar: { id: "a" } } } as any); expect(api.activeMarker.value).toBeNull(); }); + + describe("on heatmap cells", () => { + const placer: ExemplarGridPlacer = (m) => + m.id === "off" ? null : { seriesId: `${EXEMPLAR_SERIES_ID}:1`, value: [3, 2] }; + + it("reads pixels at the marker's cell on its grid's series and leaves the extents alone", () => { + const { api, chart } = setup([marker("a")], found, undefined, placer); + chart.convertToPixel.mockImplementation(((_finder: unknown, [x, y]: number[]) => [ + 10 + 8 * x, + 200 - 15 * y, + ]) as any); + api.onFinished(); + expect(chart.convertToPixel).toHaveBeenCalledWith( + { seriesId: `${EXEMPLAR_SERIES_ID}:1` }, + [0, 0], + ); + expect(api.points.value).toEqual([ + { + marker: expect.objectContaining({ id: "a" }), + xPx: 34, + yPx: 170, + clamped: false, + placement: "value", + }, + ]); + expect(api.yExtent.value).toEqual([0, 10]); + }); + + it("probes each grid twice per paint however many markers it holds", () => { + const many = Array.from({ length: 50 }, (_unused, i) => marker(`m${i}`)); + const { api, chart } = setup(many, found, undefined, placer); + api.onFinished(); + expect(chart.convertToPixel).toHaveBeenCalledTimes(2); + expect(api.points.value).toHaveLength(50); + }); + + it("lists only the markers drawn on a cell", () => { + const { api } = setup([marker("a"), marker("off")], found, undefined, placer); + api.onFinished(); + expect(api.points.value.map((p) => p.marker.id)).toEqual(["a"]); + }); + + it("opens the card from a stacked grid's marker series", () => { + const m = marker("a"); + const { api } = setup([m], found, undefined, placer); + api.onMouseOver({ ...event(m), seriesId: `${EXEMPLAR_SERIES_ID}:1` }); + expect(api.activeMarker.value?.id).toBe("a"); + }); + + it("opens and closes the card without a setOption, which would repaint every cell", async () => { + const m = marker("a"); + const { api, chart } = setup([m], found, undefined, placer); + api.onMouseOver({ ...event(m), seriesId: `${EXEMPLAR_SERIES_ID}:1` }); + expect(chart.dispatchAction).toHaveBeenCalledWith({ type: "hideTip" }); + api.onMouseOut({ ...event(m), seriesId: `${EXEMPLAR_SERIES_ID}:1` }); + await vi.advanceTimersByTimeAsync(EXEMPLAR_CARD_CLOSE_MS * 2); + expect(api.activeMarker.value).toBeNull(); + expect(chart.setOption).not.toHaveBeenCalled(); + }); + }); }); diff --git a/web/src/composables/dashboard/usePanelExemplarInteraction.ts b/web/src/composables/dashboard/usePanelExemplarInteraction.ts index 32bbb47bac..2e6d09261f 100644 --- a/web/src/composables/dashboard/usePanelExemplarInteraction.ts +++ b/web/src/composables/dashboard/usePanelExemplarInteraction.ts @@ -17,6 +17,7 @@ import { onScopeDispose, ref, shallowRef, watch, type Ref } from "vue"; import { EXEMPLAR_SERIES_ID, exemplarY, + isExemplarSeriesId, type ExemplarPlaceY, } from "@/utils/dashboard/exemplars/applyExemplarSeries"; import type { useExemplarTraceLookup } from "@/composables/dashboard/useExemplarTraceLookup"; @@ -46,6 +47,10 @@ interface ExemplarEventParams { event?: { event?: { pointerType?: string; type?: string } }; } +export type ExemplarGridPlacer = ( + marker: ExemplarMarker, +) => { seriesId: string; value: number[] } | null; + interface InteractionArgs { chartRendererRef: Ref<{ chart?: EChartsLike | null } | null>; markers: Ref; @@ -55,13 +60,15 @@ interface InteractionArgs { toChartX: (tsMs: number) => number; navigate: (marker: ExemplarMarker, verdict: TraceVerdict) => void; placeY?: ExemplarPlaceY; + /** Set while markers sit on category cells instead of the hidden time and value axes. */ + gridPlacer?: Ref; } const sameExtent = (a: [number, number], b: number[] | undefined): boolean => !!b && a[0] === b[0] && a[1] === b[1]; const isExemplarEvent = (params: ExemplarEventParams | undefined): boolean => - params?.seriesId === EXEMPLAR_SERIES_ID; + isExemplarSeriesId(params?.seriesId); const isTouch = (params: ExemplarEventParams): boolean => { const event = params?.event?.event; @@ -70,8 +77,17 @@ const isTouch = (params: ExemplarEventParams): boolean => { /** Owns marker extents, pixel positions, the hover card and the click-to-trace flow. */ export function usePanelExemplarInteraction(args: InteractionArgs) { - const { chartRendererRef, markers, seriesExtent, windowMs, lookup, toChartX, navigate, placeY } = - args; + const { + chartRendererRef, + markers, + seriesExtent, + windowMs, + lookup, + toChartX, + navigate, + placeY, + gridPlacer, + } = args; const yExtent = ref<[number, number]>([...seriesExtent.value]); const xExtent = ref<[number, number]>([...windowMs.value]); @@ -95,20 +111,60 @@ export function usePanelExemplarInteraction(args: InteractionArgs) { const chart = (): EChartsLike | null | undefined => chartRendererRef.value?.chart; + const pixelOf = ( + c: EChartsLike | null | undefined, + seriesId: string, + value: number[], + ): number[] | null => { + let pixel: number[] | undefined; + try { + pixel = c?.convertToPixel?.({ seriesId }, value); + } catch { + pixel = undefined; + } + return pixel && Number.isFinite(pixel[0]) && Number.isFinite(pixel[1]) ? pixel : null; + }; + + const cellPointOf = ( + marker: ExemplarMarker, + cell: { seriesId: string; value: number[] }, + c: EChartsLike | null | undefined, + pixelAt: (seriesId: string, value: number[]) => number[] | null = (seriesId, value) => + pixelOf(c, seriesId, value), + ): ExemplarPoint | null => { + const pixel = pixelAt(cell.seriesId, cell.value); + return pixel && { marker, xPx: pixel[0], yPx: pixel[1], clamped: false, placement: "value" }; + }; + + // Category cells lie on an affine grid, so two probes per grid place every marker on it. + const cellPixels = (c: EChartsLike | null | undefined) => { + const probes = new Map(); + return (seriesId: string, value: number[]): number[] | null => { + if (!probes.has(seriesId)) { + const origin = pixelOf(c, seriesId, [0, 0]); + const unit = pixelOf(c, seriesId, [1, 1]); + probes.set(seriesId, origin && unit ? [origin, unit] : null); + } + const probe = probes.get(seriesId); + if (!probe) return null; + const [[x0, y0], [x1, y1]] = probe; + return [x0 + value[0] * (x1 - x0), y0 + value[1] * (y1 - y0)]; + }; + }; + // Null while the painted option has no exemplar series yet, so no marker is published at 0,0. const pointOf = ( marker: ExemplarMarker, c: EChartsLike | null | undefined, ): ExemplarPoint | null => { - const { y, clamped, placement } = exemplarY(marker, yExtent.value, placeY); - let pixel: number[] | undefined; - try { - pixel = c?.convertToPixel?.({ seriesId: EXEMPLAR_SERIES_ID }, [toChartX(marker.tsMs), y]); - } catch { - pixel = undefined; + const placer = gridPlacer?.value; + if (placer) { + const cell = placer(marker); + return cell ? cellPointOf(marker, cell, c) : null; } - if (!pixel || !Number.isFinite(pixel[0]) || !Number.isFinite(pixel[1])) return null; - return { marker, xPx: pixel[0], yPx: pixel[1], clamped, placement }; + const { y, clamped, placement } = exemplarY(marker, yExtent.value, placeY); + const pixel = pixelOf(c, EXEMPLAR_SERIES_ID, [toChartX(marker.tsMs), y]); + return pixel && { marker, xPx: pixel[0], yPx: pixel[1], clamped, placement }; }; const samePoints = (next: ExemplarPoint[]): boolean => @@ -124,9 +180,28 @@ export function usePanelExemplarInteraction(args: InteractionArgs) { ); }); + // Publishes only once every drawn marker has a pixel, so a half-painted chart lists none. + const publishPoints = (next: (ExemplarPoint | null)[]) => { + if (next.some((p) => p === null)) return; + const resolved = next as ExemplarPoint[]; + if (!samePoints(resolved)) points.value = resolved; + }; + /** Pins the hidden axes to the primary axes' rendered extents; updates only on change to avoid a render loop. */ const onFinished = () => { const c = chart(); + const placer = gridPlacer?.value; + if (placer) { + // Each marker is placed once per paint; markers on no drawn cell are not listed. + const next: (ExemplarPoint | null)[] = []; + const pixelAt = cellPixels(c); + for (const marker of markers.value) { + const cell = placer(marker); + if (cell) next.push(cellPointOf(marker, cell, c, pixelAt)); + } + publishPoints(next); + return; + } const model = c?.getModel?.(); const x = model?.getComponent("xAxis", 0)?.axis?.scale?.getExtent(); const y = model?.getComponent("yAxis", 0)?.axis?.scale?.getExtent(); @@ -141,10 +216,7 @@ export function usePanelExemplarInteraction(args: InteractionArgs) { } // A pinned extent repaints the markers, so pixels read now would be stale; the next paint publishes them. if (moved && markers.value.length) return; - const next = markers.value.map((m) => pointOf(m, c)); - if (next.some((p) => p === null)) return; - const resolved = next as ExemplarPoint[]; - if (!samePoints(resolved)) points.value = resolved; + publishPoints(markers.value.map((m) => pointOf(m, c))); }; const clearTimers = () => { @@ -157,6 +229,8 @@ export function usePanelExemplarInteraction(args: InteractionArgs) { const suppressAxisTooltip = (on: boolean) => { const c = chart(); if (on) c?.dispatchAction?.({ type: "hideTip" }); + // A heatmap's item tooltip never shows for a marker, and a setOption there repaints every cell on each marker crossed. + if (gridPlacer?.value) return; c?.setOption?.({ tooltip: { triggerOn: on ? "none" : "mousemove|click" } }); }; diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index 041ee24a29..1ca5ea5032 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -6840,6 +6840,50 @@ "chartAlign": "محاذاة المخطط", "decimals": "الأعداد العشرية", "decimalsMustBeBetween": "يجب أن تكون الأعداد العشرية بين 0 و100", + "heatmapColorMin": "الحد الأدنى لمقياس اللون", + "heatmapColorMax": "الحد الأقصى لمقياس اللون", + "heatmapColorMinTooltipTitle": "حدد القيمة التي يمثلها أدنى لون.", + "heatmapColorMinTooltipDescription": "تأخذ الخلايا الأقل من هذه القيمة أدنى لون. اتركه فارغًا لاستخدام نطاق البيانات. وإذا تجاوز الطرف الآخر، يُستخدم النطاق التلقائي.", + "heatmapColorMaxTooltipTitle": "حدد القيمة التي يمثلها أعلى لون.", + "heatmapColorMaxTooltipDescription": "تأخذ الخلايا الأعلى من هذه القيمة أعلى لون. اتركه فارغًا لاستخدام نطاق البيانات. وإذا تجاوز الطرف الآخر، يُستخدم النطاق التلقائي.", + "heatmapColorRangeInvalid": "يجب أن يكون الحد الأدنى أقل من الحد الأقصى؛ ويُستخدم النطاق التلقائي بدلًا من ذلك", + "heatmapPalette": "لوحة ألوان الخريطة الحرارية", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "أحمر-أصفر-أزرق", + "heatmapPaletteReverse": "عكس لوحة الألوان", + "heatmapColorScale": "مقياس اللون", + "heatmapColorScaleLinear": "خطي", + "heatmapColorScaleSqrt": "الجذر التربيعي", + "heatmapColorScaleLog": "لوغاريتمي", + "heatmapHistogramMode": "حزم المخطط التكراري من Prometheus", + "heatmapHistogramModeTooltip": "تعامل مع كل سلسلة كحزمة تراكمية le في مخطط تكراري كلاسيكي. تُرتَّب الحزم حسب le وتُفكّ تراكميتها، مع شبكة واحدة لكل مجموعة تسميات.", + "heatmapYSort": "ترتيب المحور Y", + "heatmapYSortTooltip": "يحافظ ترتيب البيانات على الصفوف كما يعيدها الاستعلام. التصاعدي يضع أصغر قيمة في الأسفل، والتنازلي يضعها في الأعلى. وتُرتَّب الأرقام رقميًا.", + "heatmapYSortData": "ترتيب البيانات", + "heatmapYSortAsc": "تصاعدي", + "heatmapYSortDesc": "تنازلي", + "heatmapYBucket": "حزم المحور Y", + "heatmapYBucketTooltip": "جمّع حقل y الرقمي في نطاقات ضمن SQL المُولَّد. يُوسم كل صف بنطاقه، ويضع الوضع اللوغاريتمي كل قيمة تساوي 0 أو أقل في صف واحد.", + "heatmapYBucketNone": "لا شيء", + "heatmapYBucketSizeMode": "حجم ثابت", + "heatmapYBucketLogMode": "لوغاريتمي", + "heatmapYBucketSize": "حجم الحزمة", + "heatmapYBucketBase": "أساس اللوغاريتم", + "heatmapYBucketSizeInvalid": "أدخل حجم حزمة أكبر من 0؛ لن يُنفَّذ أي استعلام حتى ذلك الحين", + "heatmapYBucketTooMany": "ينتج عن هذا {count} حزمة، وهو أكثر من {max} التي يمكن للخريطة الحرارية رسمها؛ لن يُنفَّذ أي استعلام حتى تستخدم حجمًا أكبر أو نطاق y أضيق", + "heatmapYMin": "الحد الأدنى لـ Y", + "heatmapYMax": "الحد الأقصى لـ Y", + "heatmapYMinTooltip": "استبعد القيم الأقل من هذا. إنه يفلتر الاستعلام، لذا يقلل أيضًا البيانات المقروءة.", + "heatmapYMaxTooltip": "استبعد القيم الأعلى من هذا. إنه يفلتر الاستعلام، لذا يقلل أيضًا البيانات المقروءة.", + "heatmapYRangeInvalid": "يجب أن يكون الحد الأدنى أقل من الحد الأقصى؛ ويُستخدم النطاق الكامل بدلًا من ذلك", + "bucketUnit": "وحدة الحزمة", + "bucketUnitCustom": "وحدة حزمة مخصصة", "initialView": "العرض الأولي:", "query": "استعلام", "queryLimit": "حد الاستعلام", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "الاعتماد غير الصحيح: لا يمكن {child} الاعتماد على {parent}", "nestedArgPath": "{field} → أر {index}", "seriesLimitWarningCounts": "السلاسل المعروضة: {shown} من أصل {total}، للحفاظ على سرعة المخطط", + "heatmapRowsCapped": "عرض {shown} من {total} صفًا للبقاء ضمن حد خلايا الخريطة الحرارية", + "heatmapColumnsCapped": "عرض آخر {shownColumns} من {totalColumns} عمودًا و{shownRows} من {totalRows} صفًا للبقاء ضمن حد خلايا الخريطة الحرارية", + "heatmapDuplicateCells": "تم العثور على صفوف (x, y) مكررة: {count}؛ تعرض كل خلية متأثرة قيمة الصف الأول", + "heatmapEmptyCategory": "(فارغ)", + "heatmapHistogramsCapped": "عرض {shown} من {total} مخططًا تكراريًا الأكثر ازدحامًا للبقاء ضمن حد خلايا الخريطة الحرارية", + "heatmapHistogramColumnsCapped": "عرض آخر {shown} من {total} عمودًا للبقاء ضمن حد خلايا الخريطة الحرارية", + "heatmapHistogramsSeriesLimited": "عرض {shown} من {total} من المدرجات التكرارية للبقاء ضمن حد السلاسل", + "heatmapHistogramsHeightCapped": "عرض {shown} الأكثر ازدحامًا من {total} من المدرجات التكرارية التي تناسب ارتفاع اللوحة", + "heatmapColorRangeInverted": "يجب أن يكون الحد الأدنى لمقياس ألوان الخريطة الحرارية أقل من الحد الأقصى", + "heatmapYBucketSizeInvalid": "يجب أن يكون حجم حزمة المحور الصادي للخريطة الحرارية أكبر من 0", + "heatmapYBucketTooMany": "يمكن لحزم المحور الصادي للخريطة الحرارية تكوين {max} صف كحد أقصى؛ استخدم حجم حزمة أكبر أو نطاقًا صاديًا أضيق", + "heatmapYRangeInverted": "يجب أن يكون الحد الأدنى للنطاق الصادي أقل من الحد الأقصى للنطاق الصادي", "stateTimelineRowCap": "عرض {shown} من {total} صف.", "stateTimelineStateCap": "{shown} من {total} حالة لها لونها الخاص؛ والباقي يتشارك اللون الآخر.", "stateTimelineOther": "أخرى", diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index 3a408e1b7c..e090b1fa11 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -6840,6 +6840,50 @@ "chartAlign": "Diagramm ausrichten", "decimals": "Dezimalzahlen", "decimalsMustBeBetween": "Dezimalzahlen müssen zwischen 0 und 100 liegen", + "heatmapColorMin": "Farbskala-Minimum", + "heatmapColorMax": "Farbskala-Maximum", + "heatmapColorMinTooltipTitle": "Legt den Wert fest, für den die niedrigste Farbe steht.", + "heatmapColorMinTooltipDescription": "Zellen unterhalb dieses Werts erhalten die niedrigste Farbe. Leer lassen, um den Bereich der Daten zu verwenden. Wenn der Wert das andere Ende überschreitet, wird der automatische Bereich verwendet.", + "heatmapColorMaxTooltipTitle": "Legt den Wert fest, für den die höchste Farbe steht.", + "heatmapColorMaxTooltipDescription": "Zellen oberhalb dieses Werts erhalten die höchste Farbe. Leer lassen, um den Bereich der Daten zu verwenden. Wenn der Wert das andere Ende überschreitet, wird der automatische Bereich verwendet.", + "heatmapColorRangeInvalid": "Minimum muss kleiner als Maximum sein; andernfalls wird der automatische Bereich verwendet", + "heatmapPalette": "Heatmap-Palette", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Rot-gelb-blau", + "heatmapPaletteReverse": "Palette umkehren", + "heatmapColorScale": "Farbskala", + "heatmapColorScaleLinear": "Linear", + "heatmapColorScaleSqrt": "Quadratwurzel", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Prometheus-Histogramm-Buckets", + "heatmapHistogramModeTooltip": "Behandelt jede Serie als kumulativen le-Bucket eines klassischen Histogramms. Die Buckets werden nach le sortiert und de-akkumuliert; pro Label-Satz gibt es ein Raster.", + "heatmapYSort": "Y-Achsen-Reihenfolge", + "heatmapYSortTooltip": "Die Datenreihenfolge behält die Zeilen in der Reihenfolge bei, in der die Abfrage sie zurückgibt. Aufsteigend setzt den kleinsten Wert nach unten; absteigend setzt ihn nach oben. Zahlen werden numerisch sortiert.", + "heatmapYSortData": "Datenreihenfolge", + "heatmapYSortAsc": "Aufsteigend", + "heatmapYSortDesc": "Absteigend", + "heatmapYBucket": "Y-Achsen-Buckets", + "heatmapYBucketTooltip": "Gruppiert das numerische y-Feld im generierten SQL in Bereiche. Jede Zeile wird mit ihrem Bereich beschriftet, und der Log-Modus fasst jeden Wert kleiner oder gleich 0 in einer Zeile zusammen.", + "heatmapYBucketNone": "Keine", + "heatmapYBucketSizeMode": "Feste Größe", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Bucket-Größe", + "heatmapYBucketBase": "Log-Basis", + "heatmapYBucketSizeInvalid": "Geben Sie eine Bucket-Größe größer als 0 ein; bis dahin wird keine Abfrage ausgeführt", + "heatmapYBucketTooMany": "Dies ergibt {count} Buckets, mehr als die {max}, die eine Heatmap zeichnen kann; es wird keine Abfrage ausgeführt, bis Sie eine größere Größe oder einen schmaleren y-Bereich verwenden", + "heatmapYMin": "Y-Minimum", + "heatmapYMax": "Y-Maximum", + "heatmapYMinTooltip": "Lässt Werte darunter aus. Es filtert die Abfrage und reduziert dadurch auch die gelesenen Daten.", + "heatmapYMaxTooltip": "Lässt Werte darüber aus. Es filtert die Abfrage und reduziert dadurch auch die gelesenen Daten.", + "heatmapYRangeInvalid": "Minimum muss kleiner als Maximum sein; andernfalls wird der volle Bereich verwendet", + "bucketUnit": "Bucket-Einheit", + "bucketUnitCustom": "Benutzerdefinierte Bucket-Einheit", "initialView": "Erste Ansicht:", "query": "Abfrage", "queryLimit": "Abfragelimit", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Ungültige Abhängigkeit: {child} kann nicht von {parent} abhängen", "nestedArgPath": "{field} → Argument {index}", "seriesLimitWarningCounts": "Es werden {shown} von {total} Serien angezeigt, um das Diagramm schnell zu halten", + "heatmapRowsCapped": "Zeigt {shown} von {total} Zeilen an, um das Zellenlimit der Heatmap einzuhalten", + "heatmapColumnsCapped": "Zeigt die letzten {shownColumns} von {totalColumns} Spalten und {shownRows} von {totalRows} Zeilen an, um das Zellenlimit der Heatmap einzuhalten", + "heatmapDuplicateCells": "Doppelte (x, y)-Zeilen gefunden: {count}; jede betroffene Zelle zeigt den Wert der ersten Zeile", + "heatmapEmptyCategory": "(leer)", + "heatmapHistogramsCapped": "Zeigt die {shown} Histogramme mit dem meisten Datenaufkommen von {total} an, um das Zellenlimit der Heatmap einzuhalten", + "heatmapHistogramColumnsCapped": "Zeigt die letzten {shown} von {total} Spalten an, um das Zellenlimit der Heatmap einzuhalten", + "heatmapHistogramsSeriesLimited": "Es werden {shown} von {total} Histogrammen angezeigt, um das Serienlimit einzuhalten", + "heatmapHistogramsHeightCapped": "Angezeigt werden die {shown} aktivsten von {total} Histogrammen, die in die Panelhöhe passen", + "heatmapColorRangeInverted": "Das Minimum der Heatmap-Farbskala muss kleiner als das Maximum sein", + "heatmapYBucketSizeInvalid": "Die Bucket-Größe der Heatmap-Y-Achse muss größer als 0 sein", + "heatmapYBucketTooMany": "Die Y-Achsen-Buckets der Heatmap können höchstens {max} Zeilen erzeugen; verwenden Sie eine größere Bucket-Größe oder einen engeren Y-Bereich", + "heatmapYRangeInverted": "Das Y-Minimum der Heatmap muss kleiner als das Y-Maximum sein", "stateTimelineRowCap": "{shown} von {total} Zeilen werden angezeigt.", "stateTimelineStateCap": "{shown} von {total} Status haben eine eigene Farbe; die übrigen teilen sich die andere Farbe.", "stateTimelineOther": "Sonstige", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 313302db0a..09e95d269c 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -6840,6 +6840,50 @@ "chartAlign": "Chart Align", "decimals": "Decimals", "decimalsMustBeBetween": "Decimals must be between 0 and 100", + "heatmapColorMin": "Color scale min", + "heatmapColorMax": "Color scale max", + "heatmapColorMinTooltipTitle": "Set the value the lowest color stands for.", + "heatmapColorMinTooltipDescription": "Cells below this value take the lowest color. Leave empty to use the data's range. If it would pass the other end, the automatic range is used.", + "heatmapColorMaxTooltipTitle": "Set the value the highest color stands for.", + "heatmapColorMaxTooltipDescription": "Cells above this value take the highest color. Leave empty to use the data's range. If it would pass the other end, the automatic range is used.", + "heatmapColorRangeInvalid": "Min must be less than max; the automatic range is used instead", + "heatmapPalette": "Heatmap palette", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Red-yellow-blue", + "heatmapPaletteReverse": "Reverse palette", + "heatmapColorScale": "Color scale", + "heatmapColorScaleLinear": "Linear", + "heatmapColorScaleSqrt": "Square root", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Prometheus histogram buckets", + "heatmapHistogramModeTooltip": "Treat each series as a cumulative le bucket of a classic histogram. Buckets are sorted by le and de-accumulated, with one grid per label set.", + "heatmapYSort": "Y-axis order", + "heatmapYSortTooltip": "Data order keeps rows as the query returns them. Ascending puts the smallest value at the bottom; descending puts it at the top. Numbers sort numerically.", + "heatmapYSortData": "Data order", + "heatmapYSortAsc": "Ascending", + "heatmapYSortDesc": "Descending", + "heatmapYBucket": "Y-axis buckets", + "heatmapYBucketTooltip": "Group the numeric y field into ranges in the generated SQL. Each row is labelled with its range, and the log mode puts every value at or below 0 in one row.", + "heatmapYBucketNone": "None", + "heatmapYBucketSizeMode": "Fixed size", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Bucket size", + "heatmapYBucketBase": "Log base", + "heatmapYBucketSizeInvalid": "Enter a bucket size greater than 0; no query runs until then", + "heatmapYBucketTooMany": "This makes {count} buckets, more than the {max} a heatmap can draw; no query runs until you use a larger size or a narrower y range", + "heatmapYMin": "Y min", + "heatmapYMax": "Y max", + "heatmapYMinTooltip": "Leave out values below this. It filters the query, so it also reduces the data read.", + "heatmapYMaxTooltip": "Leave out values above this. It filters the query, so it also reduces the data read.", + "heatmapYRangeInvalid": "Min must be less than max; the full range is used instead", + "bucketUnit": "Bucket unit", + "bucketUnitCustom": "Custom bucket unit", "initialView": "Initial View:", "query": "Query", "queryLimit": "Query Limit", @@ -7873,6 +7917,18 @@ "invalidVariableDependency": "Invalid dependency: {child} cannot depend on {parent}", "nestedArgPath": "{field} → Arg {index}", "seriesLimitWarningCounts": "Showing {shown} of {total} series to keep the chart fast", + "heatmapRowsCapped": "Showing {shown} of {total} rows to stay within the heatmap cell limit", + "heatmapColumnsCapped": "Showing the last {shownColumns} of {totalColumns} columns and {shownRows} of {totalRows} rows to stay within the heatmap cell limit", + "heatmapDuplicateCells": "Duplicate (x, y) rows found: {count}; each affected cell shows the first row's value", + "heatmapEmptyCategory": "(empty)", + "heatmapHistogramsCapped": "Showing the {shown} busiest of {total} histograms to stay within the heatmap cell limit", + "heatmapHistogramColumnsCapped": "Showing the last {shown} of {total} columns to stay within the heatmap cell limit", + "heatmapHistogramsSeriesLimited": "Showing {shown} of {total} histograms to stay within the series limit", + "heatmapHistogramsHeightCapped": "Showing the {shown} busiest of {total} histograms that fit the panel height", + "heatmapColorRangeInverted": "Heatmap color scale min must be less than max", + "heatmapYBucketSizeInvalid": "Heatmap y-axis bucket size must be greater than 0", + "heatmapYBucketTooMany": "Heatmap y-axis buckets can make at most {max} rows; use a larger bucket size or a narrower y range", + "heatmapYRangeInverted": "Heatmap Y min must be less than Y max", "stateTimelineRowCap": "Showing {shown} of {total} rows.", "stateTimelineStateCap": "{shown} of {total} states have their own color; the rest share the other color.", "stateTimelineOther": "Other", diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index f43fd65b2e..29a5732958 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -6840,6 +6840,50 @@ "chartAlign": "Alineación del gráfico", "decimals": "Decimales", "decimalsMustBeBetween": "Los decimales deben estar entre 0 y 100", + "heatmapColorMin": "Mín. de la escala de color", + "heatmapColorMax": "Máx. de la escala de color", + "heatmapColorMinTooltipTitle": "Establece el valor que representa el color más bajo.", + "heatmapColorMinTooltipDescription": "Las celdas por debajo de este valor toman el color más bajo. Déjalo vacío para usar el rango de los datos. Si sobrepasara el otro extremo, se usa el rango automático.", + "heatmapColorMaxTooltipTitle": "Establece el valor que representa el color más alto.", + "heatmapColorMaxTooltipDescription": "Las celdas por encima de este valor toman el color más alto. Déjalo vacío para usar el rango de los datos. Si sobrepasara el otro extremo, se usa el rango automático.", + "heatmapColorRangeInvalid": "El mínimo debe ser menor que el máximo; en su lugar se usa el rango automático", + "heatmapPalette": "Paleta del mapa de calor", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Rojo-amarillo-azul", + "heatmapPaletteReverse": "Invertir paleta", + "heatmapColorScale": "Escala de color", + "heatmapColorScaleLinear": "Lineal", + "heatmapColorScaleSqrt": "Raíz cuadrada", + "heatmapColorScaleLog": "Logarítmica", + "heatmapHistogramMode": "Intervalos del histograma de Prometheus", + "heatmapHistogramModeTooltip": "Trata cada serie como un intervalo le acumulativo de un histograma clásico. Los intervalos se ordenan por le y se desacumulan, con una cuadrícula por conjunto de etiquetas.", + "heatmapYSort": "Orden del eje Y", + "heatmapYSortTooltip": "El orden de los datos mantiene las filas tal como las devuelve la consulta. El orden ascendente coloca el valor más pequeño abajo; el descendente lo coloca arriba. Los números se ordenan numéricamente.", + "heatmapYSortData": "Orden de los datos", + "heatmapYSortAsc": "Ascendente", + "heatmapYSortDesc": "Descendente", + "heatmapYBucket": "Intervalos del eje Y", + "heatmapYBucketTooltip": "Agrupa el campo numérico y en rangos en el SQL generado. Cada fila se etiqueta con su rango, y el modo logarítmico coloca todos los valores iguales o inferiores a 0 en una sola fila.", + "heatmapYBucketNone": "Ninguno", + "heatmapYBucketSizeMode": "Tamaño fijo", + "heatmapYBucketLogMode": "Logarítmico", + "heatmapYBucketSize": "Tamaño del intervalo", + "heatmapYBucketBase": "Base del logaritmo", + "heatmapYBucketSizeInvalid": "Introduce un tamaño de intervalo mayor que 0; no se ejecutará ninguna consulta hasta entonces", + "heatmapYBucketTooMany": "Esto crea {count} intervalos, más de los {max} que puede dibujar un mapa de calor; no se ejecutará ninguna consulta hasta que uses un tamaño mayor o un rango de y más estrecho", + "heatmapYMin": "Y mín.", + "heatmapYMax": "Y máx.", + "heatmapYMinTooltip": "Excluye los valores por debajo de este valor. Filtra la consulta, por lo que también reduce los datos leídos.", + "heatmapYMaxTooltip": "Excluye los valores por encima de este valor. Filtra la consulta, por lo que también reduce los datos leídos.", + "heatmapYRangeInvalid": "El mínimo debe ser menor que el máximo; en su lugar se usa el rango completo", + "bucketUnit": "Unidad del intervalo", + "bucketUnitCustom": "Unidad de intervalo personalizada", "initialView": "Vista inicial:", "query": "Consulta", "queryLimit": "Límite de consultas", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Dependencia no válida: {child} no puede depender de {parent}", "nestedArgPath": "{field} → Argumento {index}", "seriesLimitWarningCounts": "Mostrando {shown} de {total} series para mantener el gráfico rápido", + "heatmapRowsCapped": "Mostrando {shown} de {total} filas para no superar el límite de celdas del mapa de calor", + "heatmapColumnsCapped": "Mostrando las últimas {shownColumns} de {totalColumns} columnas y {shownRows} de {totalRows} filas para no superar el límite de celdas del mapa de calor", + "heatmapDuplicateCells": "Se encontraron filas (x, y) duplicadas: {count}; cada celda afectada muestra el valor de la primera fila", + "heatmapEmptyCategory": "(vacío)", + "heatmapHistogramsCapped": "Mostrando los {shown} histogramas con más actividad de {total} para no superar el límite de celdas del mapa de calor", + "heatmapHistogramColumnsCapped": "Mostrando las últimas {shown} de {total} columnas para no superar el límite de celdas del mapa de calor", + "heatmapHistogramsSeriesLimited": "Mostrando {shown} de {total} histogramas para no superar el límite de series", + "heatmapHistogramsHeightCapped": "Mostrando los {shown} histogramas con más actividad de {total} que caben en la altura del panel", + "heatmapColorRangeInverted": "El mínimo de la escala de color del mapa de calor debe ser menor que el máximo", + "heatmapYBucketSizeInvalid": "El tamaño del intervalo del eje Y del mapa de calor debe ser mayor que 0", + "heatmapYBucketTooMany": "Los intervalos del eje Y del mapa de calor pueden generar como máximo {max} filas; usa un tamaño de intervalo mayor o un rango Y más estrecho", + "heatmapYRangeInverted": "El mínimo de Y del mapa de calor debe ser menor que el máximo de Y", "stateTimelineRowCap": "Mostrando {shown} de {total} filas.", "stateTimelineStateCap": "{shown} de {total} estados tienen su propio color; el resto comparte el otro color.", "stateTimelineOther": "Otros", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index 1f67c0df65..a84a979c47 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -6840,6 +6840,50 @@ "chartAlign": "Alignement du graphique", "decimals": "Décimales", "decimalsMustBeBetween": "Les décimales doivent être comprises entre 0 et 100", + "heatmapColorMin": "Min. de l'échelle de couleurs", + "heatmapColorMax": "Max. de l'échelle de couleurs", + "heatmapColorMinTooltipTitle": "Définissez la valeur représentée par la couleur la plus basse.", + "heatmapColorMinTooltipDescription": "Les cellules inférieures à cette valeur prennent la couleur la plus basse. Laissez vide pour utiliser la plage des données. Si elle dépasse l'autre extrémité, la plage automatique est utilisée.", + "heatmapColorMaxTooltipTitle": "Définissez la valeur représentée par la couleur la plus haute.", + "heatmapColorMaxTooltipDescription": "Les cellules supérieures à cette valeur prennent la couleur la plus haute. Laissez vide pour utiliser la plage des données. Si elle dépasse l'autre extrémité, la plage automatique est utilisée.", + "heatmapColorRangeInvalid": "Le minimum doit être inférieur au maximum ; la plage automatique est utilisée à la place", + "heatmapPalette": "Palette de la carte thermique", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Bleus", + "heatmapPaletteGreens": "Verts", + "heatmapPaletteReds": "Rouges", + "heatmapPaletteRdYlBu": "Rouge-jaune-bleu", + "heatmapPaletteReverse": "Inverser la palette", + "heatmapColorScale": "Échelle de couleurs", + "heatmapColorScaleLinear": "Linéaire", + "heatmapColorScaleSqrt": "Racine carrée", + "heatmapColorScaleLog": "Logarithmique", + "heatmapHistogramMode": "Compartiments d'histogramme Prometheus", + "heatmapHistogramModeTooltip": "Traitez chaque série comme un compartiment cumulatif « le » d'un histogramme classique. Les compartiments sont triés par « le » et désaccumulés, avec une grille par ensemble d'étiquettes.", + "heatmapYSort": "Ordre de l'axe Y", + "heatmapYSortTooltip": "L'ordre des données conserve les lignes dans l'ordre renvoyé par la requête. L'ordre croissant place la plus petite valeur en bas ; l'ordre décroissant la place en haut. Les nombres sont triés numériquement.", + "heatmapYSortData": "Ordre des données", + "heatmapYSortAsc": "Croissant", + "heatmapYSortDesc": "Décroissant", + "heatmapYBucket": "Compartiments de l'axe Y", + "heatmapYBucketTooltip": "Regroupez le champ y numérique en plages dans le SQL généré. Chaque ligne est étiquetée par sa plage, et le mode logarithmique place toutes les valeurs inférieures ou égales à 0 dans une même ligne.", + "heatmapYBucketNone": "Aucun", + "heatmapYBucketSizeMode": "Taille fixe", + "heatmapYBucketLogMode": "Logarithmique", + "heatmapYBucketSize": "Taille du compartiment", + "heatmapYBucketBase": "Base du logarithme", + "heatmapYBucketSizeInvalid": "Saisissez une taille de compartiment supérieure à 0 ; aucune requête ne s'exécute tant que ce n'est pas fait", + "heatmapYBucketTooMany": "Cela crée {count} compartiments, soit plus que les {max} qu'une carte thermique peut dessiner ; aucune requête ne s'exécute tant que vous n'utilisez pas une taille plus grande ou une plage y plus étroite", + "heatmapYMin": "Min. Y", + "heatmapYMax": "Max. Y", + "heatmapYMinTooltip": "Excluez les valeurs inférieures à celle-ci. Cela filtre la requête et réduit donc aussi les données lues.", + "heatmapYMaxTooltip": "Excluez les valeurs supérieures à celle-ci. Cela filtre la requête et réduit donc aussi les données lues.", + "heatmapYRangeInvalid": "Le minimum doit être inférieur au maximum ; la plage complète est utilisée à la place", + "bucketUnit": "Unité de compartiment", + "bucketUnitCustom": "Unité de compartiment personnalisée", "initialView": "Vue initiale :", "query": "Requête", "queryLimit": "Limite de requêtes", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Dépendance invalide : {child} ne peut pas dépendre de {parent}", "nestedArgPath": "{field} → Arg {index}", "seriesLimitWarningCounts": "Affichage de {shown} sur {total} séries pour que le graphique reste rapide", + "heatmapRowsCapped": "Affichage de {shown} lignes sur {total} pour rester dans la limite de cellules de la carte thermique", + "heatmapColumnsCapped": "Affichage des {shownColumns} dernières colonnes sur {totalColumns} et des {shownRows} dernières lignes sur {totalRows} pour rester dans la limite de cellules de la carte thermique", + "heatmapDuplicateCells": "Lignes (x, y) en double trouvées : {count} ; chaque cellule concernée affiche la valeur de la première ligne", + "heatmapEmptyCategory": "(vide)", + "heatmapHistogramsCapped": "Affichage des {shown} histogrammes les plus actifs sur {total} pour rester dans la limite de cellules de la carte thermique", + "heatmapHistogramColumnsCapped": "Affichage des {shown} dernières colonnes sur {total} pour rester dans la limite de cellules de la carte thermique", + "heatmapHistogramsSeriesLimited": "Affichage de {shown} histogrammes sur {total} pour respecter la limite de séries", + "heatmapHistogramsHeightCapped": "Affichage des {shown} histogrammes les plus actifs sur {total} qui tiennent dans la hauteur du panneau", + "heatmapColorRangeInverted": "Le minimum de l'échelle de couleurs de la heatmap doit être inférieur au maximum", + "heatmapYBucketSizeInvalid": "La taille des intervalles de l'axe y de la heatmap doit être supérieure à 0", + "heatmapYBucketTooMany": "Les intervalles de l'axe y de la heatmap peuvent générer au plus {max} lignes ; utilisez une taille d'intervalle plus grande ou une plage y plus étroite", + "heatmapYRangeInverted": "Le minimum Y de la heatmap doit être inférieur au maximum Y", "stateTimelineRowCap": "{shown} lignes affichées sur {total}.", "stateTimelineStateCap": "{shown} états sur {total} ont leur propre couleur ; les autres partagent l'autre couleur.", "stateTimelineOther": "Autre", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 081c25af59..1b63ef4edf 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -6840,6 +6840,50 @@ "chartAlign": "Allinea grafico", "decimals": "Decimali", "decimalsMustBeBetween": "I decimali devono essere compresi tra 0 e 100", + "heatmapColorMin": "Min scala colori", + "heatmapColorMax": "Max scala colori", + "heatmapColorMinTooltipTitle": "Imposta il valore a cui corrisponde il colore più basso.", + "heatmapColorMinTooltipDescription": "Le celle sotto questo valore assumono il colore più basso. Lascia vuoto per usare l'intervallo dei dati. Se supera l'altro estremo, viene usato l'intervallo automatico.", + "heatmapColorMaxTooltipTitle": "Imposta il valore a cui corrisponde il colore più alto.", + "heatmapColorMaxTooltipDescription": "Le celle sopra questo valore assumono il colore più alto. Lascia vuoto per usare l'intervallo dei dati. Se supera l'altro estremo, viene usato l'intervallo automatico.", + "heatmapColorRangeInvalid": "Il minimo deve essere inferiore al massimo; viene usato l'intervallo automatico", + "heatmapPalette": "Palette heatmap", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Rosso-giallo-blu", + "heatmapPaletteReverse": "Inverti palette", + "heatmapColorScale": "Scala colori", + "heatmapColorScaleLinear": "Lineare", + "heatmapColorScaleSqrt": "Radice quadrata", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Bucket degli istogrammi Prometheus", + "heatmapHistogramModeTooltip": "Tratta ogni serie come un bucket cumulativo le di un istogramma classico. I bucket vengono ordinati per le e de-accumulati, con una griglia per ogni insieme di etichette.", + "heatmapYSort": "Ordine asse Y", + "heatmapYSortTooltip": "L'ordine dei dati mantiene le righe come le restituisce la query. Crescente mette il valore più piccolo in basso; decrescente lo mette in alto. I numeri vengono ordinati numericamente.", + "heatmapYSortData": "Ordine dei dati", + "heatmapYSortAsc": "Crescente", + "heatmapYSortDesc": "Decrescente", + "heatmapYBucket": "Bucket asse Y", + "heatmapYBucketTooltip": "Raggruppa il campo y numerico in intervalli nell'SQL generato. Ogni riga è etichettata con il suo intervallo e la modalità log mette ogni valore pari o inferiore a 0 in un'unica riga.", + "heatmapYBucketNone": "Nessuno", + "heatmapYBucketSizeMode": "Dimensione fissa", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Dimensione bucket", + "heatmapYBucketBase": "Base del log", + "heatmapYBucketSizeInvalid": "Inserisci una dimensione bucket maggiore di 0; fino ad allora nessuna query viene eseguita", + "heatmapYBucketTooMany": "Questo crea {count} bucket, più dei {max} che una heatmap può disegnare; nessuna query viene eseguita finché non usi una dimensione maggiore o un intervallo y più ristretto", + "heatmapYMin": "Y min", + "heatmapYMax": "Y max", + "heatmapYMinTooltip": "Esclude i valori inferiori a questo. Filtra la query, quindi riduce anche i dati letti.", + "heatmapYMaxTooltip": "Esclude i valori superiori a questo. Filtra la query, quindi riduce anche i dati letti.", + "heatmapYRangeInvalid": "Il minimo deve essere inferiore al massimo; viene usato l'intero intervallo", + "bucketUnit": "Unità bucket", + "bucketUnitCustom": "Unità bucket personalizzata", "initialView": "Vista iniziale:", "query": "Query", "queryLimit": "Limite di query", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Dipendenza non valida: {child} non può dipendere da {parent}", "nestedArgPath": "{field} → Argomento {index}", "seriesLimitWarningCounts": "Visualizzazione di {shown} serie su {total} per mantenere il grafico veloce", + "heatmapRowsCapped": "Visualizzazione di {shown} righe su {total} per restare entro il limite di celle della heatmap", + "heatmapColumnsCapped": "Visualizzazione delle ultime {shownColumns} colonne su {totalColumns} e delle ultime {shownRows} righe su {totalRows} per restare entro il limite di celle della heatmap", + "heatmapDuplicateCells": "Trovate righe (x, y) duplicate: {count}; ogni cella interessata mostra il valore della prima riga", + "heatmapEmptyCategory": "(vuoto)", + "heatmapHistogramsCapped": "Visualizzazione dei {shown} istogrammi più attivi su {total} per restare entro il limite di celle della heatmap", + "heatmapHistogramColumnsCapped": "Visualizzazione delle ultime {shown} colonne su {total} per restare entro il limite di celle della heatmap", + "heatmapHistogramsSeriesLimited": "Visualizzazione di {shown} istogrammi su {total} per rispettare il limite di serie", + "heatmapHistogramsHeightCapped": "Visualizzazione dei {shown} istogrammi più attivi su {total} che rientrano nell'altezza del pannello", + "heatmapColorRangeInverted": "Il minimo della scala colori della heatmap deve essere inferiore al massimo", + "heatmapYBucketSizeInvalid": "La dimensione del bucket dell'asse y della heatmap deve essere maggiore di 0", + "heatmapYBucketTooMany": "I bucket dell'asse y della heatmap possono creare al massimo {max} righe; usa una dimensione del bucket maggiore o un intervallo y più ristretto", + "heatmapYRangeInverted": "Il minimo Y della heatmap deve essere inferiore al massimo Y", "stateTimelineRowCap": "Visualizzazione di {shown} righe su {total}.", "stateTimelineStateCap": "{shown} stati su {total} hanno un colore proprio; gli altri condividono l'altro colore.", "stateTimelineOther": "Altro", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index 1d98171ad8..a8ca9f6b2f 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -6840,6 +6840,50 @@ "chartAlign": "グラフの配置", "decimals": "小数", "decimalsMustBeBetween": "小数点数は 0 から 100 の間でなければなりません", + "heatmapColorMin": "カラースケールの最小値", + "heatmapColorMax": "カラースケールの最大値", + "heatmapColorMinTooltipTitle": "最小色が表す値を設定します。", + "heatmapColorMinTooltipDescription": "この値未満のセルは最小色になります。空欄にするとデータの範囲を使用します。反対側の端を超える場合は、自動範囲が使用されます。", + "heatmapColorMaxTooltipTitle": "最大色が表す値を設定します。", + "heatmapColorMaxTooltipDescription": "この値を超えるセルは最大色になります。空欄にするとデータの範囲を使用します。反対側の端を超える場合は、自動範囲が使用されます。", + "heatmapColorRangeInvalid": "最小値は最大値より小さい必要があります。代わりに自動範囲が使用されます", + "heatmapPalette": "ヒートマップのパレット", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "赤-黄-青", + "heatmapPaletteReverse": "パレットを反転", + "heatmapColorScale": "カラースケール", + "heatmapColorScaleLinear": "線形", + "heatmapColorScaleSqrt": "平方根", + "heatmapColorScaleLog": "対数", + "heatmapHistogramMode": "Prometheus ヒストグラムバケット", + "heatmapHistogramModeTooltip": "各シリーズをクラシックヒストグラムの累積 le バケットとして扱います。バケットは le で並べ替えられ、非累積化されます。ラベルセットごとに 1 つのグリッドになります。", + "heatmapYSort": "Y 軸の順序", + "heatmapYSortTooltip": "データ順では、クエリが返した順に行を保持します。昇順では最小値が下、降順では最小値が上になります。数値は数値順に並べ替えられます。", + "heatmapYSortData": "データ順", + "heatmapYSortAsc": "昇順", + "heatmapYSortDesc": "降順", + "heatmapYBucket": "Y 軸のバケット", + "heatmapYBucketTooltip": "生成される SQL で数値の y フィールドを範囲にグループ化します。各行にはその範囲のラベルが付き、対数モードでは 0 以下のすべての値が 1 行にまとめられます。", + "heatmapYBucketNone": "なし", + "heatmapYBucketSizeMode": "固定サイズ", + "heatmapYBucketLogMode": "対数", + "heatmapYBucketSize": "バケットサイズ", + "heatmapYBucketBase": "対数の底", + "heatmapYBucketSizeInvalid": "0 より大きいバケットサイズを入力してください。入力するまでクエリは実行されません", + "heatmapYBucketTooMany": "これにより {count} 個のバケットが作成され、ヒートマップが描画できる {max} 個を超えます。より大きなサイズまたはより狭い y 範囲を使用するまで、クエリは実行されません", + "heatmapYMin": "Y 最小値", + "heatmapYMax": "Y 最大値", + "heatmapYMinTooltip": "この値未満の値は除外します。クエリをフィルターするため、読み取るデータも減ります。", + "heatmapYMaxTooltip": "この値より大きい値は除外します。クエリをフィルターするため、読み取るデータも減ります。", + "heatmapYRangeInvalid": "最小値は最大値より小さい必要があります。代わりに全範囲が使用されます", + "bucketUnit": "バケット単位", + "bucketUnitCustom": "カスタムバケット単位", "initialView": "初期ビュー:", "query": "クエリ", "queryLimit": "クエリ制限", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "無効な依存関係: {child} は {parent} に依存できません。", "nestedArgPath": "{field} → 引数 {index}", "seriesLimitWarningCounts": "チャートを高速に保つため、{total} シリーズ中 {shown} を表示しています", + "heatmapRowsCapped": "ヒートマップのセル上限内に収めるため、{total} 行中 {shown} 行を表示しています", + "heatmapColumnsCapped": "ヒートマップのセル上限内に収めるため、{totalColumns} 列中最後の {shownColumns} 列と、{totalRows} 行中 {shownRows} 行を表示しています", + "heatmapDuplicateCells": "重複する (x, y) 行が見つかりました: {count}。影響を受ける各セルには最初の行の値が表示されます", + "heatmapEmptyCategory": "(空)", + "heatmapHistogramsCapped": "ヒートマップのセル上限内に収めるため、{total} 個のヒストグラムのうち最もデータ量の多い {shown} 個を表示しています", + "heatmapHistogramColumnsCapped": "ヒートマップのセル上限内に収めるため、{total} 列中最後の {shown} 列を表示しています", + "heatmapHistogramsSeriesLimited": "シリーズ上限内に収めるため、{total} 件中 {shown} 件のヒストグラムを表示しています", + "heatmapHistogramsHeightCapped": "パネルの高さに収まる {total} 件のヒストグラムのうち、最もビジーな {shown} 件を表示しています", + "heatmapColorRangeInverted": "ヒートマップのカラースケールの最小値は最大値より小さくする必要があります", + "heatmapYBucketSizeInvalid": "ヒートマップの Y 軸のバケットサイズは 0 より大きくする必要があります", + "heatmapYBucketTooMany": "ヒートマップの Y 軸バケットは最大 {max} 行までです。バケットサイズを大きくするか、Y 範囲を狭めてください", + "heatmapYRangeInverted": "ヒートマップの Y 最小値は Y 最大値より小さくする必要があります", "stateTimelineRowCap": "{total} 行中 {shown} 行を表示しています。", "stateTimelineStateCap": "{total} 個の状態のうち {shown} 個に個別の色が割り当てられ、残りは共通の色になります。", "stateTimelineOther": "その他", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index 6ad1ad45ce..3bf35e3bbd 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -6840,6 +6840,50 @@ "chartAlign": "차트 정렬", "decimals": "소수", "decimalsMustBeBetween": "소수는 0에서 100 사이여야 합니다.", + "heatmapColorMin": "색상 스케일 최솟값", + "heatmapColorMax": "색상 스케일 최댓값", + "heatmapColorMinTooltipTitle": "가장 낮은 색상이 나타내는 값을 설정합니다.", + "heatmapColorMinTooltipDescription": "이 값 미만의 셀은 가장 낮은 색상으로 표시됩니다. 비워 두면 데이터 범위를 사용합니다. 반대쪽 끝을 넘어가면 자동 범위가 사용됩니다.", + "heatmapColorMaxTooltipTitle": "가장 높은 색상이 나타내는 값을 설정합니다.", + "heatmapColorMaxTooltipDescription": "이 값을 초과하는 셀은 가장 높은 색상으로 표시됩니다. 비워 두면 데이터 범위를 사용합니다. 반대쪽 끝을 넘어가면 자동 범위가 사용됩니다.", + "heatmapColorRangeInvalid": "최솟값은 최댓값보다 작아야 합니다. 대신 자동 범위가 사용됩니다", + "heatmapPalette": "히트맵 팔레트", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "빨강-노랑-파랑", + "heatmapPaletteReverse": "팔레트 반전", + "heatmapColorScale": "색상 스케일", + "heatmapColorScaleLinear": "선형", + "heatmapColorScaleSqrt": "제곱근", + "heatmapColorScaleLog": "로그", + "heatmapHistogramMode": "Prometheus 히스토그램 버킷", + "heatmapHistogramModeTooltip": "각 시리즈를 클래식 히스토그램의 누적 le 버킷으로 처리합니다. 버킷은 le 기준으로 정렬되고 누적이 해제되며, 레이블 집합마다 하나의 그리드가 생성됩니다.", + "heatmapYSort": "Y축 순서", + "heatmapYSortTooltip": "데이터 순서는 쿼리가 반환하는 순서대로 행을 유지합니다. 오름차순은 가장 작은 값을 아래에, 내림차순은 위에 배치합니다. 숫자는 숫자 기준으로 정렬됩니다.", + "heatmapYSortData": "데이터 순서", + "heatmapYSortAsc": "오름차순", + "heatmapYSortDesc": "내림차순", + "heatmapYBucket": "Y축 버킷", + "heatmapYBucketTooltip": "생성된 SQL에서 숫자 y 필드를 범위로 그룹화합니다. 각 행에는 해당 범위가 레이블로 지정되며, 로그 모드에서는 0 이하의 모든 값이 하나의 행에 배치됩니다.", + "heatmapYBucketNone": "없음", + "heatmapYBucketSizeMode": "고정 크기", + "heatmapYBucketLogMode": "로그", + "heatmapYBucketSize": "버킷 크기", + "heatmapYBucketBase": "로그 밑", + "heatmapYBucketSizeInvalid": "0보다 큰 버킷 크기를 입력하세요. 입력할 때까지 쿼리가 실행되지 않습니다", + "heatmapYBucketTooMany": "이 설정은 버킷을 {count}개 만들어 히트맵이 그릴 수 있는 최대 {max}개를 초과합니다. 더 큰 크기나 더 좁은 y 범위를 사용할 때까지 쿼리가 실행되지 않습니다", + "heatmapYMin": "Y 최솟값", + "heatmapYMax": "Y 최댓값", + "heatmapYMinTooltip": "이 값 미만의 값은 제외합니다. 쿼리를 필터링하므로 읽는 데이터도 줄어듭니다.", + "heatmapYMaxTooltip": "이 값을 초과하는 값은 제외합니다. 쿼리를 필터링하므로 읽는 데이터도 줄어듭니다.", + "heatmapYRangeInvalid": "최솟값은 최댓값보다 작아야 합니다. 대신 전체 범위가 사용됩니다", + "bucketUnit": "버킷 단위", + "bucketUnitCustom": "사용자 지정 버킷 단위", "initialView": "초기 보기:", "query": "쿼리", "queryLimit": "쿼리 제한", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "잘못된 종속성: {child}은(는) {parent}에 종속될 수 없습니다.", "nestedArgPath": "{field} → 인자 {index}", "seriesLimitWarningCounts": "차트 속도를 유지하기 위해 {total}개 시리즈 중 {shown}개를 표시합니다", + "heatmapRowsCapped": "히트맵 셀 제한 내에서 유지하기 위해 {total}개 행 중 {shown}개를 표시합니다", + "heatmapColumnsCapped": "히트맵 셀 제한 내에서 유지하기 위해 {totalColumns}개 열 중 마지막 {shownColumns}개와 {totalRows}개 행 중 {shownRows}개를 표시합니다", + "heatmapDuplicateCells": "중복된 (x, y) 행 발견: {count}개. 영향을 받는 각 셀에는 첫 번째 행의 값이 표시됩니다", + "heatmapEmptyCategory": "(비어 있음)", + "heatmapHistogramsCapped": "히트맵 셀 제한 내에서 유지하기 위해 {total}개 히스토그램 중 가장 사용량이 많은 {shown}개를 표시합니다", + "heatmapHistogramColumnsCapped": "히트맵 셀 제한 내에서 유지하기 위해 {total}개 열 중 마지막 {shown}개를 표시합니다", + "heatmapHistogramsSeriesLimited": "시리즈 제한을 유지하기 위해 히스토그램 {total}개 중 {shown}개를 표시합니다", + "heatmapHistogramsHeightCapped": "패널 높이에 맞는 히스토그램 {total}개 중 데이터가 가장 많은 {shown}개를 표시합니다", + "heatmapColorRangeInverted": "히트맵 색상 범위의 최솟값은 최댓값보다 작아야 합니다", + "heatmapYBucketSizeInvalid": "히트맵 y축 버킷 크기는 0보다 커야 합니다", + "heatmapYBucketTooMany": "히트맵 y축 버킷은 최대 {max}개 행만 생성할 수 있습니다. 더 큰 버킷 크기나 더 좁은 y 범위를 사용하세요", + "heatmapYRangeInverted": "히트맵 Y 최솟값은 Y 최댓값보다 작아야 합니다", "stateTimelineRowCap": "{total}개 행 중 {shown}개 표시 중.", "stateTimelineStateCap": "{total}개 상태 중 {shown}개에 고유 색상이 지정되며, 나머지는 다른 색상을 공유합니다.", "stateTimelineOther": "기타", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index 8c7f8d416e..f98764c766 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -6840,6 +6840,50 @@ "chartAlign": "Grafiek uitlijnen", "decimals": "Decimalen", "decimalsMustBeBetween": "Decimalen moeten tussen 0 en 100 liggen", + "heatmapColorMin": "Kleurenschaal min", + "heatmapColorMax": "Kleurenschaal max", + "heatmapColorMinTooltipTitle": "Stel de waarde in die de laagste kleur vertegenwoordigt.", + "heatmapColorMinTooltipDescription": "Cellen onder deze waarde krijgen de laagste kleur. Laat leeg om het bereik van de gegevens te gebruiken. Als de waarde het andere uiteinde zou passeren, wordt het automatische bereik gebruikt.", + "heatmapColorMaxTooltipTitle": "Stel de waarde in die de hoogste kleur vertegenwoordigt.", + "heatmapColorMaxTooltipDescription": "Cellen boven deze waarde krijgen de hoogste kleur. Laat leeg om het bereik van de gegevens te gebruiken. Als de waarde het andere uiteinde zou passeren, wordt het automatische bereik gebruikt.", + "heatmapColorRangeInvalid": "Min moet kleiner zijn dan max; in plaats daarvan wordt het automatische bereik gebruikt.", + "heatmapPalette": "Heatmap-palet", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Rood-geel-blauw", + "heatmapPaletteReverse": "Palet omkeren", + "heatmapColorScale": "Kleurenschaal", + "heatmapColorScaleLinear": "Lineair", + "heatmapColorScaleSqrt": "Vierkantswortel", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Prometheus-histogrambuckets", + "heatmapHistogramModeTooltip": "Behandel elke reeks als een cumulatieve le-bucket van een klassiek histogram. Buckets worden op le gesorteerd en van cumulatief naar niet-cumulatief omgezet, met één raster per labelset.", + "heatmapYSort": "Volgorde van de Y-as", + "heatmapYSortTooltip": "Gegevensvolgorde houdt rijen in de volgorde waarin de query ze retourneert. Oplopend zet de kleinste waarde onderaan; aflopend zet deze bovenaan. Getallen worden numeriek gesorteerd.", + "heatmapYSortData": "Gegevensvolgorde", + "heatmapYSortAsc": "Oplopend", + "heatmapYSortDesc": "Aflopend", + "heatmapYBucket": "Buckets op de Y-as", + "heatmapYBucketTooltip": "Groepeer het numerieke y-veld in bereiken in de gegenereerde SQL. Elke rij wordt gelabeld met het bijbehorende bereik, en de logmodus plaatst elke waarde op of onder 0 in één rij.", + "heatmapYBucketNone": "Geen", + "heatmapYBucketSizeMode": "Vaste grootte", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Bucketgrootte", + "heatmapYBucketBase": "Log-grondtal", + "heatmapYBucketSizeInvalid": "Voer een bucketgrootte groter dan 0 in; tot die tijd wordt er geen query uitgevoerd.", + "heatmapYBucketTooMany": "Dit maakt {count} buckets, meer dan de {max} die een heatmap kan tekenen; er wordt geen query uitgevoerd totdat je een grotere bucketgrootte of een nauwer y-bereik gebruikt.", + "heatmapYMin": "Y-min", + "heatmapYMax": "Y-max", + "heatmapYMinTooltip": "Laat waarden hieronder weg. Het filtert de query en vermindert zo ook de hoeveelheid gelezen gegevens.", + "heatmapYMaxTooltip": "Laat waarden hierboven weg. Het filtert de query en vermindert zo ook de hoeveelheid gelezen gegevens.", + "heatmapYRangeInvalid": "Min moet kleiner zijn dan max; in plaats daarvan wordt het volledige bereik gebruikt.", + "bucketUnit": "Bucket-eenheid", + "bucketUnitCustom": "Aangepaste bucket-eenheid", "initialView": "Eerste weergave:", "query": "Zoekopdracht", "queryLimit": "Query-limiet", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Ongeldige afhankelijkheid: {child} kan niet afhankelijk zijn van {parent}", "nestedArgPath": "{field} → Argument {index}", "seriesLimitWarningCounts": "Toont {shown} van {total} reeksen om de grafiek snel te houden", + "heatmapRowsCapped": "{shown} van {total} rijen weergegeven om binnen de heatmap-cellimiet te blijven.", + "heatmapColumnsCapped": "De laatste {shownColumns} van {totalColumns} kolommen en {shownRows} van {totalRows} rijen worden getoond om binnen de heatmap-cellimiet te blijven.", + "heatmapDuplicateCells": "Dubbele (x, y)-rijen gevonden: {count}; elke getroffen cel toont de waarde van de eerste rij.", + "heatmapEmptyCategory": "(leeg)", + "heatmapHistogramsCapped": "De {shown} drukste van {total} histogrammen worden getoond om binnen de heatmap-cellimiet te blijven.", + "heatmapHistogramColumnsCapped": "De laatste {shown} van {total} kolommen worden getoond om binnen de heatmap-cellimiet te blijven.", + "heatmapHistogramsSeriesLimited": "Toont {shown} van {total} histogrammen om binnen de reekslimiet te blijven", + "heatmapHistogramsHeightCapped": "Toont de {shown} drukste van {total} histogrammen die in de paneelhoogte passen", + "heatmapColorRangeInverted": "Het minimum van de heatmap-kleurenschaal moet kleiner zijn dan het maximum", + "heatmapYBucketSizeInvalid": "De bucketgrootte van de y-as van de heatmap moet groter zijn dan 0", + "heatmapYBucketTooMany": "Buckets op de y-as van de heatmap kunnen maximaal {max} rijen vormen; gebruik een grotere bucketgrootte of een nauwer y-bereik", + "heatmapYRangeInverted": "Het Y-minimum van de heatmap moet kleiner zijn dan het Y-maximum", "stateTimelineRowCap": "Toont {shown} van {total} rijen.", "stateTimelineStateCap": "{shown} van {total} statussen hebben een eigen kleur; de rest deelt de andere kleur.", "stateTimelineOther": "Overig", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index 9fb80ab292..be21f41629 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -6840,6 +6840,50 @@ "chartAlign": "Wyrównanie wykresu", "decimals": "Miejsca dziesiętne", "decimalsMustBeBetween": "Miejsca dziesiętne muszą być między 0 a 100", + "heatmapColorMin": "Min. skali kolorów", + "heatmapColorMax": "Maks. skali kolorów", + "heatmapColorMinTooltipTitle": "Ustaw wartość, którą reprezentuje najniższy kolor.", + "heatmapColorMinTooltipDescription": "Komórki poniżej tej wartości przyjmują najniższy kolor. Pozostaw puste, aby użyć zakresu danych. Jeśli wartość wykraczałaby poza drugi koniec, zostanie użyty zakres automatyczny.", + "heatmapColorMaxTooltipTitle": "Ustaw wartość, którą reprezentuje najwyższy kolor.", + "heatmapColorMaxTooltipDescription": "Komórki powyżej tej wartości przyjmują najwyższy kolor. Pozostaw puste, aby użyć zakresu danych. Jeśli wartość wykraczałaby poza drugi koniec, zostanie użyty zakres automatyczny.", + "heatmapColorRangeInvalid": "Minimum musi być mniejsze niż maksimum; zamiast tego używany jest zakres automatyczny", + "heatmapPalette": "Paleta mapy cieplnej", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Czerwono-żółto-niebieska", + "heatmapPaletteReverse": "Odwróć paletę", + "heatmapColorScale": "Skala kolorów", + "heatmapColorScaleLinear": "Liniowa", + "heatmapColorScaleSqrt": "Pierwiastek kwadratowy", + "heatmapColorScaleLog": "Logarytmiczna", + "heatmapHistogramMode": "Przedziały histogramu Prometheus", + "heatmapHistogramModeTooltip": "Traktuj każdą serię jako skumulowany przedział le klasycznego histogramu. Przedziały są sortowane według le i przeliczane z wartości skumulowanych, z jedną siatką na zestaw etykiet.", + "heatmapYSort": "Kolejność osi Y", + "heatmapYSortTooltip": "Kolejność danych zachowuje wiersze w takiej kolejności, w jakiej zwraca je zapytanie. Rosnąco umieszcza najmniejszą wartość na dole; malejąco — na górze. Liczby są sortowane numerycznie.", + "heatmapYSortData": "Kolejność danych", + "heatmapYSortAsc": "Rosnąco", + "heatmapYSortDesc": "Malejąco", + "heatmapYBucket": "Przedziały osi Y", + "heatmapYBucketTooltip": "Grupuj numeryczne pole y w zakresy w generowanym SQL. Każdy wiersz jest oznaczony swoim zakresem, a tryb logarytmiczny umieszcza wszystkie wartości równe 0 lub mniejsze w jednym wierszu.", + "heatmapYBucketNone": "Brak", + "heatmapYBucketSizeMode": "Stały rozmiar", + "heatmapYBucketLogMode": "Logarytmiczny", + "heatmapYBucketSize": "Rozmiar przedziału", + "heatmapYBucketBase": "Podstawa logarytmu", + "heatmapYBucketSizeInvalid": "Wprowadź rozmiar przedziału większy niż 0; do tego czasu żadne zapytanie nie zostanie uruchomione", + "heatmapYBucketTooMany": "To tworzy {count} przedziałów, więcej niż {max}, które może narysować mapa cieplna; zapytanie nie zostanie uruchomione, dopóki nie użyjesz większego rozmiaru lub węższego zakresu y", + "heatmapYMin": "Min. Y", + "heatmapYMax": "Maks. Y", + "heatmapYMinTooltip": "Pomiń wartości poniżej tej. Filtruje zapytanie, więc zmniejsza też ilość odczytywanych danych.", + "heatmapYMaxTooltip": "Pomiń wartości powyżej tej. Filtruje zapytanie, więc zmniejsza też ilość odczytywanych danych.", + "heatmapYRangeInvalid": "Minimum musi być mniejsze niż maksimum; zamiast tego używany jest pełny zakres", + "bucketUnit": "Jednostka przedziału", + "bucketUnitCustom": "Niestandardowa jednostka przedziału", "initialView": "Widok początkowy:", "query": "Zapytanie", "queryLimit": "Limit zapytania", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Nieprawidłowa zależność: {child} nie może zależeć od {parent}", "nestedArgPath": "{field} → Argument {index}", "seriesLimitWarningCounts": "Wyświetlane serie: {shown} z {total}, aby wykres działał szybko", + "heatmapRowsCapped": "Wyświetlono {shown} z {total} wierszy, aby nie przekroczyć limitu komórek mapy cieplnej", + "heatmapColumnsCapped": "Wyświetlono ostatnie {shownColumns} z {totalColumns} kolumn i {shownRows} z {totalRows} wierszy, aby nie przekroczyć limitu komórek mapy cieplnej", + "heatmapDuplicateCells": "Znaleziono zduplikowane wiersze (x, y): {count}; w każdej takiej komórce wyświetlana jest wartość pierwszego wiersza", + "heatmapEmptyCategory": "(puste)", + "heatmapHistogramsCapped": "Wyświetlono {shown} z {total} najbardziej aktywnych histogramów, aby nie przekroczyć limitu komórek mapy cieplnej", + "heatmapHistogramColumnsCapped": "Wyświetlono ostatnie {shown} z {total} kolumn, aby nie przekroczyć limitu komórek mapy cieplnej", + "heatmapHistogramsSeriesLimited": "Wyświetlanie {shown} z {total} histogramów, aby nie przekroczyć limitu serii", + "heatmapHistogramsHeightCapped": "Wyświetlanie {shown} najbardziej aktywnych z {total} histogramów, które mieszczą się w wysokości panelu", + "heatmapColorRangeInverted": "Minimalna wartość skali kolorów mapy cieplnej musi być mniejsza niż maksymalna", + "heatmapYBucketSizeInvalid": "Rozmiar kubełka osi Y mapy cieplnej musi być większy niż 0", + "heatmapYBucketTooMany": "Kubełki osi Y mapy cieplnej mogą utworzyć co najwyżej {max} wierszy; użyj większego rozmiaru kubełka lub węższego zakresu Y", + "heatmapYRangeInverted": "Minimalna wartość Y mapy cieplnej musi być mniejsza niż maksymalna wartość Y", "stateTimelineRowCap": "Wyświetlanie {shown} z {total} wierszy.", "stateTimelineStateCap": "{shown} z {total} stanów ma własny kolor; pozostałe używają drugiego koloru.", "stateTimelineOther": "Inne", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index dc47262fa6..c405b33353 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -6840,6 +6840,50 @@ "chartAlign": "Alinhamento do gráfico", "decimals": "Casas decimais", "decimalsMustBeBetween": "As casas decimais devem estar entre 0 e 100", + "heatmapColorMin": "Mín. da escala de cores", + "heatmapColorMax": "Máx. da escala de cores", + "heatmapColorMinTooltipTitle": "Defina o valor representado pela cor mais baixa.", + "heatmapColorMinTooltipDescription": "As células abaixo deste valor assumem a cor mais baixa. Deixe em branco para utilizar o intervalo dos dados. Se ultrapassar o outro extremo, é utilizado o intervalo automático.", + "heatmapColorMaxTooltipTitle": "Defina o valor representado pela cor mais alta.", + "heatmapColorMaxTooltipDescription": "As células acima deste valor assumem a cor mais alta. Deixe em branco para utilizar o intervalo dos dados. Se ultrapassar o outro extremo, é utilizado o intervalo automático.", + "heatmapColorRangeInvalid": "O mínimo tem de ser inferior ao máximo; caso contrário, é utilizado o intervalo automático", + "heatmapPalette": "Paleta do mapa de calor", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Azuis", + "heatmapPaletteGreens": "Verdes", + "heatmapPaletteReds": "Vermelhos", + "heatmapPaletteRdYlBu": "Vermelho-amarelo-azul", + "heatmapPaletteReverse": "Inverter paleta", + "heatmapColorScale": "Escala de cores", + "heatmapColorScaleLinear": "Linear", + "heatmapColorScaleSqrt": "Raiz quadrada", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Buckets de histograma do Prometheus", + "heatmapHistogramModeTooltip": "Trate cada série como um bucket le cumulativo de um histograma clássico. Os buckets são ordenados por le e desacumulados, com uma grelha por conjunto de etiquetas.", + "heatmapYSort": "Ordem do eixo Y", + "heatmapYSortTooltip": "A ordem dos dados mantém as linhas tal como a consulta as devolve. Ascendente coloca o valor mais pequeno em baixo; descendente coloca-o em cima. Os números são ordenados numericamente.", + "heatmapYSortData": "Ordem dos dados", + "heatmapYSortAsc": "Ascendente", + "heatmapYSortDesc": "Descendente", + "heatmapYBucket": "Buckets do eixo Y", + "heatmapYBucketTooltip": "Agrupa o campo y numérico em intervalos no SQL gerado. Cada linha é etiquetada com o respetivo intervalo e o modo log coloca todos os valores iguais ou inferiores a 0 numa única linha.", + "heatmapYBucketNone": "Nenhum", + "heatmapYBucketSizeMode": "Tamanho fixo", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Tamanho do bucket", + "heatmapYBucketBase": "Base do log", + "heatmapYBucketSizeInvalid": "Introduza um tamanho de bucket superior a 0; até lá, não é executada nenhuma consulta", + "heatmapYBucketTooMany": "Isto cria {count} buckets, mais do que os {max} que um mapa de calor consegue desenhar; não é executada nenhuma consulta até utilizar um tamanho maior ou um intervalo de y mais estreito", + "heatmapYMin": "Y mín.", + "heatmapYMax": "Y máx.", + "heatmapYMinTooltip": "Exclui os valores abaixo deste limite. Filtra a consulta, pelo que também reduz os dados lidos.", + "heatmapYMaxTooltip": "Exclui os valores acima deste limite. Filtra a consulta, pelo que também reduz os dados lidos.", + "heatmapYRangeInvalid": "O mínimo tem de ser inferior ao máximo; caso contrário, é utilizado o intervalo completo", + "bucketUnit": "Unidade do bucket", + "bucketUnitCustom": "Unidade de bucket personalizada", "initialView": "Vista inicial:", "query": "Consulta", "queryLimit": "Limite de consulta", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Dependência inválida: {child} não pode depender de {parent}", "nestedArgPath": "{field} → Argumento {index}", "seriesLimitWarningCounts": "A mostrar {shown} de {total} séries para manter o gráfico rápido", + "heatmapRowsCapped": "A mostrar {shown} de {total} linhas para não exceder o limite de células do mapa de calor", + "heatmapColumnsCapped": "A mostrar as últimas {shownColumns} de {totalColumns} colunas e {shownRows} de {totalRows} linhas para não exceder o limite de células do mapa de calor", + "heatmapDuplicateCells": "Encontradas {count} linhas (x, y) duplicadas; cada célula afetada mostra o valor da primeira linha", + "heatmapEmptyCategory": "(vazio)", + "heatmapHistogramsCapped": "A mostrar os {shown} histogramas com mais atividade de {total} para não exceder o limite de células do mapa de calor", + "heatmapHistogramColumnsCapped": "A mostrar as últimas {shown} de {total} colunas para não exceder o limite de células do mapa de calor", + "heatmapHistogramsSeriesLimited": "A mostrar {shown} de {total} histogramas para respeitar o limite de séries", + "heatmapHistogramsHeightCapped": "A mostrar os {shown} histogramas com mais atividade de {total} que cabem na altura do painel", + "heatmapColorRangeInverted": "O valor mínimo da escala de cores do mapa de calor tem de ser inferior ao máximo", + "heatmapYBucketSizeInvalid": "O tamanho do intervalo do eixo y do mapa de calor tem de ser superior a 0", + "heatmapYBucketTooMany": "Os intervalos do eixo y do mapa de calor podem gerar no máximo {max} linhas; utilize um intervalo maior ou um intervalo y mais estreito", + "heatmapYRangeInverted": "O valor mínimo de Y do mapa de calor tem de ser inferior ao máximo de Y", "stateTimelineRowCap": "A mostrar {shown} de {total} linhas.", "stateTimelineStateCap": "{shown} de {total} estados têm cor própria; os restantes partilham a outra cor.", "stateTimelineOther": "Outro", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index 684cb4ab8b..761c8214f7 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -6840,6 +6840,50 @@ "chartAlign": "Выравнивание диаграммы", "decimals": "Десятичные знаки", "decimalsMustBeBetween": "Десятичные знаки должны быть от 0 до 100", + "heatmapColorMin": "Минимум цветовой шкалы", + "heatmapColorMax": "Максимум цветовой шкалы", + "heatmapColorMinTooltipTitle": "Укажите значение, соответствующее минимальному цвету.", + "heatmapColorMinTooltipDescription": "Ячейки ниже этого значения получают минимальный цвет. Оставьте поле пустым, чтобы использовать диапазон данных. Если значение выйдет за другой предел, будет использован автоматический диапазон.", + "heatmapColorMaxTooltipTitle": "Укажите значение, соответствующее максимальному цвету.", + "heatmapColorMaxTooltipDescription": "Ячейки выше этого значения получают максимальный цвет. Оставьте поле пустым, чтобы использовать диапазон данных. Если значение выйдет за другой предел, будет использован автоматический диапазон.", + "heatmapColorRangeInvalid": "Минимум должен быть меньше максимума; вместо этого используется автоматический диапазон", + "heatmapPalette": "Палитра тепловой карты", + "heatmapPaletteSpectral": "Спектральная", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Синие", + "heatmapPaletteGreens": "Зелёные", + "heatmapPaletteReds": "Красные", + "heatmapPaletteRdYlBu": "Красно-жёлто-синий", + "heatmapPaletteReverse": "Обратная палитра", + "heatmapColorScale": "Цветовая шкала", + "heatmapColorScaleLinear": "Линейная", + "heatmapColorScaleSqrt": "Квадратный корень", + "heatmapColorScaleLog": "Логарифмическая", + "heatmapHistogramMode": "Бакеты гистограммы Prometheus", + "heatmapHistogramModeTooltip": "Рассматривать каждый ряд как накопительный бакет le классической гистограммы. Бакеты сортируются по le и преобразуются из накопительных значений в приращения; для каждого набора меток строится отдельная сетка.", + "heatmapYSort": "Порядок оси Y", + "heatmapYSortTooltip": "Порядок данных сохраняет строки в том порядке, в котором их возвращает запрос. При сортировке по возрастанию наименьшее значение оказывается внизу, по убыванию — вверху. Числа сортируются численно.", + "heatmapYSortData": "Порядок данных", + "heatmapYSortAsc": "По возрастанию", + "heatmapYSortDesc": "По убыванию", + "heatmapYBucket": "Бакеты оси Y", + "heatmapYBucketTooltip": "Группирует числовое поле y в диапазоны в сгенерированном SQL. Каждая строка подписывается своим диапазоном, а в логарифмическом режиме все значения не больше 0 помещаются в одну строку.", + "heatmapYBucketNone": "Нет", + "heatmapYBucketSizeMode": "Фиксированный размер", + "heatmapYBucketLogMode": "Логарифмический", + "heatmapYBucketSize": "Размер бакета", + "heatmapYBucketBase": "Основание логарифма", + "heatmapYBucketSizeInvalid": "Введите размер бакета больше 0; до этого запрос выполняться не будет", + "heatmapYBucketTooMany": "Количество бакетов: {count}, что превышает предел тепловой карты в {max}; запрос не будет выполняться, пока вы не увеличите размер или не сузите диапазон y", + "heatmapYMin": "Минимум Y", + "heatmapYMax": "Максимум Y", + "heatmapYMinTooltip": "Не учитывать значения ниже указанного. Это фильтрует запрос и также сокращает объём читаемых данных.", + "heatmapYMaxTooltip": "Не учитывать значения выше указанного. Это фильтрует запрос и также сокращает объём читаемых данных.", + "heatmapYRangeInvalid": "Минимум должен быть меньше максимума; вместо этого используется полный диапазон", + "bucketUnit": "Единица бакета", + "bucketUnitCustom": "Пользовательская единица бакета", "initialView": "Начальный вид:", "query": "Запрос", "queryLimit": "Лимит запроса", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Недопустимая зависимость: {child} не может зависеть от {parent}", "nestedArgPath": "{field} → Аргумент {index}", "seriesLimitWarningCounts": "Показано серий: {shown} из {total}, чтобы диаграмма работала быстро", + "heatmapRowsCapped": "Показано {shown} из {total} строк, чтобы уложиться в лимит ячеек тепловой карты", + "heatmapColumnsCapped": "Показаны последние {shownColumns} из {totalColumns} столбцов и {shownRows} из {totalRows} строк, чтобы уложиться в лимит ячеек тепловой карты", + "heatmapDuplicateCells": "Найдены дубликаты строк (x, y): {count}; в каждой затронутой ячейке отображается значение первой строки", + "heatmapEmptyCategory": "(пусто)", + "heatmapHistogramsCapped": "Показано {shown} из {total} наиболее нагруженных гистограмм, чтобы уложиться в лимит ячеек тепловой карты", + "heatmapHistogramColumnsCapped": "Показаны последние {shown} из {total} столбцов, чтобы уложиться в лимит ячеек тепловой карты", + "heatmapHistogramsSeriesLimited": "Показано {shown} из {total} гистограмм в пределах лимита серий", + "heatmapHistogramsHeightCapped": "Показано {shown} самых активных из {total} гистограмм, помещающихся по высоте панели", + "heatmapColorRangeInverted": "Минимум цветовой шкалы тепловой карты должен быть меньше максимума", + "heatmapYBucketSizeInvalid": "Размер интервала по оси Y тепловой карты должен быть больше 0", + "heatmapYBucketTooMany": "Интервалы по оси Y тепловой карты могут создавать не более {max} строк; используйте больший размер интервала или более узкий диапазон Y", + "heatmapYRangeInverted": "Минимум Y тепловой карты должен быть меньше максимума Y", "stateTimelineRowCap": "Показано {shown} из {total} строк.", "stateTimelineStateCap": "У {shown} из {total} состояний есть собственный цвет; остальные отображаются другим общим цветом.", "stateTimelineOther": "Другое", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index 5e32e8bff8..eb5e540f75 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -6840,6 +6840,50 @@ "chartAlign": "Grafik Hizalama", "decimals": "Ondalık sayılar", "decimalsMustBeBetween": "Ondalık sayılar 0 ile 100 arasında olmalıdır", + "heatmapColorMin": "Renk ölçeği min", + "heatmapColorMax": "Renk ölçeği maks", + "heatmapColorMinTooltipTitle": "En düşük rengin temsil ettiği değeri ayarlayın.", + "heatmapColorMinTooltipDescription": "Bu değerin altındaki hücreler en düşük rengi alır. Verinin aralığını kullanmak için boş bırakın. Diğer ucu aşarsa otomatik aralık kullanılır.", + "heatmapColorMaxTooltipTitle": "En yüksek rengin temsil ettiği değeri ayarlayın.", + "heatmapColorMaxTooltipDescription": "Bu değerin üzerindeki hücreler en yüksek rengi alır. Verinin aralığını kullanmak için boş bırakın. Diğer ucu aşarsa otomatik aralık kullanılır.", + "heatmapColorRangeInvalid": "Min, maks değerinden küçük olmalıdır; bunun yerine otomatik aralık kullanılır", + "heatmapPalette": "Isı haritası paleti", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Kırmızı-sarı-mavi", + "heatmapPaletteReverse": "Paleti ters çevir", + "heatmapColorScale": "Renk ölçeği", + "heatmapColorScaleLinear": "Doğrusal", + "heatmapColorScaleSqrt": "Karekök", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Prometheus histogram kovaları", + "heatmapHistogramModeTooltip": "Her seriyi klasik bir histogramın kümülatif le kovası olarak ele alın. Kovalar le'ye göre sıralanır ve kümülatif toplamdan arındırılır; her etiket kümesi için bir ızgara oluşturulur.", + "heatmapYSort": "Y ekseni sırası", + "heatmapYSortTooltip": "Veri sırası, satırları sorgunun döndürdüğü şekilde korur. Artan, en küçük değeri alta koyar; azalan, onu en üste koyar. Sayılar sayısal olarak sıralanır.", + "heatmapYSortData": "Veri sırası", + "heatmapYSortAsc": "Artan", + "heatmapYSortDesc": "Azalan", + "heatmapYBucket": "Y ekseni kovaları", + "heatmapYBucketTooltip": "Oluşturulan SQL'de sayısal y alanını aralıklara gruplandırın. Her satır kendi aralığıyla etiketlenir ve log modu 0 veya altındaki tüm değerleri tek satıra koyar.", + "heatmapYBucketNone": "Yok", + "heatmapYBucketSizeMode": "Sabit boyut", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Kova boyutu", + "heatmapYBucketBase": "Log tabanı", + "heatmapYBucketSizeInvalid": "0'dan büyük bir kova boyutu girin; bu girilene kadar sorgu çalışmaz", + "heatmapYBucketTooMany": "Bu, bir ısı haritasının çizebileceği {max} sınırından fazla, {count} kova oluşturur; daha büyük bir boyut veya daha dar bir y aralığı kullanana kadar sorgu çalışmaz", + "heatmapYMin": "Y min", + "heatmapYMax": "Y maks", + "heatmapYMinTooltip": "Bunun altındaki değerleri hariç tutun. Sorguyu filtrelediği için okunan veri miktarını da azaltır.", + "heatmapYMaxTooltip": "Bunun üzerindeki değerleri hariç tutun. Sorguyu filtrelediği için okunan veri miktarını da azaltır.", + "heatmapYRangeInvalid": "Min, maks değerinden küçük olmalıdır; bunun yerine tam aralık kullanılır", + "bucketUnit": "Kova birimi", + "bucketUnitCustom": "Özel kova birimi", "initialView": "İlk Görünüm:", "query": "Sorgu", "queryLimit": "Sorgu Sınırı", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Geçersiz bağımlılık: {child}, {parent} öğesine bağımlı olamaz", "nestedArgPath": "{field} → Arg {index}", "seriesLimitWarningCounts": "Grafiği hızlı tutmak için {total} serinin {shown} tanesi gösteriliyor", + "heatmapRowsCapped": "Isı haritası hücre sınırında kalmak için {total} satırın {shown} tanesi gösteriliyor", + "heatmapColumnsCapped": "Isı haritası hücre sınırında kalmak için {totalColumns} sütunun son {shownColumns} tanesi ve {totalRows} satırın {shownRows} tanesi gösteriliyor", + "heatmapDuplicateCells": "Yinelenen (x, y) satırları bulundu: {count}; etkilenen her hücre ilk satırın değerini gösterir", + "heatmapEmptyCategory": "(boş)", + "heatmapHistogramsCapped": "Isı haritası hücre sınırında kalmak için {total} histogramdan en yoğun {shown} tanesi gösteriliyor", + "heatmapHistogramColumnsCapped": "Isı haritası hücre sınırında kalmak için {total} sütunun son {shown} tanesi gösteriliyor", + "heatmapHistogramsSeriesLimited": "Seri sınırında kalmak için {total} histogramdan {shown} tanesi gösteriliyor", + "heatmapHistogramsHeightCapped": "Panel yüksekliğine sığan {total} histogramdan en yoğun {shown} tanesi gösteriliyor", + "heatmapColorRangeInverted": "Isı haritası renk ölçeği min değeri maks değerinden küçük olmalıdır", + "heatmapYBucketSizeInvalid": "Isı haritası y ekseni kova boyutu 0'dan büyük olmalıdır", + "heatmapYBucketTooMany": "Isı haritası y ekseni kovaları en fazla {max} satır oluşturabilir; daha büyük bir kova boyutu veya daha dar bir y aralığı kullanın", + "heatmapYRangeInverted": "Isı haritası Y min değeri Y maks değerinden küçük olmalıdır", "stateTimelineRowCap": "{total} satırdan {shown} tanesi gösteriliyor.", "stateTimelineStateCap": "{total} durumdan {shown} tanesinin kendi rengi var; geri kalanı diğer rengi paylaşır.", "stateTimelineOther": "Diğer", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index 659c2d8b99..e78bbbb00c 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -6840,6 +6840,50 @@ "chartAlign": "Căn chỉnh biểu đồ", "decimals": "Số thập phân", "decimalsMustBeBetween": "Số thập phân phải từ 0 đến 100", + "heatmapColorMin": "Giá trị tối thiểu thang màu", + "heatmapColorMax": "Giá trị tối đa thang màu", + "heatmapColorMinTooltipTitle": "Đặt giá trị tương ứng với màu thấp nhất.", + "heatmapColorMinTooltipDescription": "Các ô có giá trị dưới mức này sẽ nhận màu thấp nhất. Để trống để dùng phạm vi của dữ liệu. Nếu vượt quá đầu kia, phạm vi tự động sẽ được dùng.", + "heatmapColorMaxTooltipTitle": "Đặt giá trị tương ứng với màu cao nhất.", + "heatmapColorMaxTooltipDescription": "Các ô có giá trị trên mức này sẽ nhận màu cao nhất. Để trống để dùng phạm vi của dữ liệu. Nếu vượt quá đầu kia, phạm vi tự động sẽ được dùng.", + "heatmapColorRangeInvalid": "Tối thiểu phải nhỏ hơn tối đa; thay vào đó, phạm vi tự động sẽ được dùng", + "heatmapPalette": "Bảng màu heatmap", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "Đỏ-vàng-xanh lam", + "heatmapPaletteReverse": "Đảo ngược bảng màu", + "heatmapColorScale": "Thang màu", + "heatmapColorScaleLinear": "Tuyến tính", + "heatmapColorScaleSqrt": "Căn bậc hai", + "heatmapColorScaleLog": "Log", + "heatmapHistogramMode": "Các bucket histogram Prometheus", + "heatmapHistogramModeTooltip": "Coi mỗi chuỗi là một bucket le tích lũy của histogram cổ điển. Các bucket được sắp xếp theo le và khử tích lũy, với một lưới cho mỗi tập nhãn.", + "heatmapYSort": "Thứ tự trục Y", + "heatmapYSortTooltip": "Thứ tự dữ liệu giữ nguyên các hàng như truy vấn trả về. Tăng dần đặt giá trị nhỏ nhất ở dưới; giảm dần đặt giá trị nhỏ nhất ở trên. Số được sắp xếp theo giá trị số.", + "heatmapYSortData": "Thứ tự dữ liệu", + "heatmapYSortAsc": "Tăng dần", + "heatmapYSortDesc": "Giảm dần", + "heatmapYBucket": "Các bucket trục Y", + "heatmapYBucketTooltip": "Nhóm trường y dạng số thành các khoảng trong SQL được tạo. Mỗi hàng được gắn nhãn theo khoảng của nó, và chế độ log đặt mọi giá trị bằng hoặc nhỏ hơn 0 vào một hàng.", + "heatmapYBucketNone": "Không", + "heatmapYBucketSizeMode": "Kích thước cố định", + "heatmapYBucketLogMode": "Log", + "heatmapYBucketSize": "Kích thước bucket", + "heatmapYBucketBase": "Cơ số log", + "heatmapYBucketSizeInvalid": "Nhập kích thước bucket lớn hơn 0; cho đến khi đó sẽ không có truy vấn nào chạy", + "heatmapYBucketTooMany": "Thao tác này tạo ra {count} bucket, nhiều hơn mức {max} mà heatmap có thể vẽ; không có truy vấn nào chạy cho đến khi bạn dùng kích thước lớn hơn hoặc phạm vi y hẹp hơn", + "heatmapYMin": "Y tối thiểu", + "heatmapYMax": "Y tối đa", + "heatmapYMinTooltip": "Bỏ qua các giá trị dưới mức này. Nó lọc truy vấn, nên cũng giảm lượng dữ liệu được đọc.", + "heatmapYMaxTooltip": "Bỏ qua các giá trị trên mức này. Nó lọc truy vấn, nên cũng giảm lượng dữ liệu được đọc.", + "heatmapYRangeInvalid": "Tối thiểu phải nhỏ hơn tối đa; thay vào đó, toàn bộ phạm vi sẽ được dùng", + "bucketUnit": "Đơn vị bucket", + "bucketUnitCustom": "Đơn vị bucket tùy chỉnh", "initialView": "Chế độ xem ban đầu:", "query": "Truy vấn", "queryLimit": "Giới hạn truy vấn", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "Phụ thuộc không hợp lệ: {child} không thể phụ thuộc vào {parent}", "nestedArgPath": "{field} → Đối số {index}", "seriesLimitWarningCounts": "Đang hiển thị {shown} trong {total} series để giữ biểu đồ nhanh", + "heatmapRowsCapped": "Đang hiển thị {shown} trong {total} hàng để nằm trong giới hạn số ô của heatmap", + "heatmapColumnsCapped": "Đang hiển thị {shownColumns} cột cuối trong {totalColumns} cột và {shownRows} hàng trong {totalRows} hàng để nằm trong giới hạn số ô của heatmap", + "heatmapDuplicateCells": "Đã tìm thấy các hàng (x, y) trùng lặp: {count}; mỗi ô bị ảnh hưởng hiển thị giá trị của hàng đầu tiên", + "heatmapEmptyCategory": "(trống)", + "heatmapHistogramsCapped": "Đang hiển thị {shown} histogram nhiều dữ liệu nhất trong {total} histogram để nằm trong giới hạn số ô của heatmap", + "heatmapHistogramColumnsCapped": "Đang hiển thị {shown} cột cuối trong {total} cột để nằm trong giới hạn số ô của heatmap", + "heatmapHistogramsSeriesLimited": "Hiển thị {shown} trong {total} biểu đồ tần suất để không vượt giới hạn chuỗi", + "heatmapHistogramsHeightCapped": "Hiển thị {shown} biểu đồ tần suất có nhiều dữ liệu nhất trong số {total} biểu đồ vừa với chiều cao panel", + "heatmapColorRangeInverted": "Giá trị tối thiểu của thang màu heatmap phải nhỏ hơn giá trị tối đa", + "heatmapYBucketSizeInvalid": "Kích thước bucket trục y của heatmap phải lớn hơn 0", + "heatmapYBucketTooMany": "Các bucket trục y của heatmap có thể tạo tối đa {max} hàng; hãy dùng kích thước bucket lớn hơn hoặc phạm vi y hẹp hơn", + "heatmapYRangeInverted": "Giá trị Y nhỏ nhất của heatmap phải nhỏ hơn giá trị Y lớn nhất", "stateTimelineRowCap": "Đang hiển thị {shown} trên {total} hàng.", "stateTimelineStateCap": "{shown} trên {total} trạng thái có màu riêng; số còn lại dùng chung màu khác.", "stateTimelineOther": "Khác", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index eb890b5088..e805c37ecf 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -6840,6 +6840,50 @@ "chartAlign": "图表对齐", "decimals": "小数", "decimalsMustBeBetween": "小数必须介于 0 和 100 之间", + "heatmapColorMin": "色阶最小值", + "heatmapColorMax": "色阶最大值", + "heatmapColorMinTooltipTitle": "设置最低颜色代表的值。", + "heatmapColorMinTooltipDescription": "低于此值的单元格使用最低颜色。留空则使用数据范围。如果该值越过另一端,则使用自动范围。", + "heatmapColorMaxTooltipTitle": "设置最高颜色代表的值。", + "heatmapColorMaxTooltipDescription": "高于此值的单元格使用最高颜色。留空则使用数据范围。如果该值越过另一端,则使用自动范围。", + "heatmapColorRangeInvalid": "最小值必须小于最大值;否则将使用自动范围", + "heatmapPalette": "热力图调色板", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "蓝色系", + "heatmapPaletteGreens": "绿色系", + "heatmapPaletteReds": "红色系", + "heatmapPaletteRdYlBu": "红-黄-蓝", + "heatmapPaletteReverse": "反转调色板", + "heatmapColorScale": "色阶", + "heatmapColorScaleLinear": "线性", + "heatmapColorScaleSqrt": "平方根", + "heatmapColorScaleLog": "对数", + "heatmapHistogramMode": "Prometheus 直方图桶", + "heatmapHistogramModeTooltip": "将每个序列视为经典直方图的累积 le 桶。按 le 排序并进行去累积处理,每个标签集对应一个网格。", + "heatmapYSort": "Y 轴顺序", + "heatmapYSortTooltip": "数据顺序会保持查询返回的行顺序。升序将最小值放在底部;降序将其放在顶部。数字按数值排序。", + "heatmapYSortData": "数据顺序", + "heatmapYSortAsc": "升序", + "heatmapYSortDesc": "降序", + "heatmapYBucket": "Y 轴分桶", + "heatmapYBucketTooltip": "在生成的 SQL 中将数值 y 字段按范围分组。每一行以其范围作为标签,对数模式会将所有小于等于 0 的值放入同一行。", + "heatmapYBucketNone": "无", + "heatmapYBucketSizeMode": "固定大小", + "heatmapYBucketLogMode": "对数", + "heatmapYBucketSize": "分桶大小", + "heatmapYBucketBase": "对数底数", + "heatmapYBucketSizeInvalid": "请输入大于 0 的分桶大小;在此之前不会运行查询", + "heatmapYBucketTooMany": "这会生成 {count} 个桶,超过热力图可绘制的 {max} 个;在您使用更大的分桶大小或更窄的 y 范围之前,不会运行查询", + "heatmapYMin": "Y 最小值", + "heatmapYMax": "Y 最大值", + "heatmapYMinTooltip": "排除低于此值的值。它会过滤查询,因此也会减少数据读取量。", + "heatmapYMaxTooltip": "排除高于此值的值。它会过滤查询,因此也会减少数据读取量。", + "heatmapYRangeInvalid": "最小值必须小于最大值;否则将使用完整范围", + "bucketUnit": "分桶单位", + "bucketUnitCustom": "自定义分桶单位", "initialView": "初始视图:", "query": "查询", "queryLimit": "查询限制", @@ -7875,6 +7919,18 @@ "invalidVariableDependency": "无效的依赖关系:{child} 不能依赖 {parent}", "nestedArgPath": "{field} → 参数 {index}", "seriesLimitWarningCounts": "为保持图表流畅,正在显示 {total} 个序列中的 {shown} 个", + "heatmapRowsCapped": "为不超过热力图单元格限制,显示 {total} 行中的 {shown} 行", + "heatmapColumnsCapped": "为不超过热力图单元格限制,显示 {totalColumns} 列中的最后 {shownColumns} 列,以及 {totalRows} 行中的 {shownRows} 行", + "heatmapDuplicateCells": "发现重复的 (x, y) 行:{count};每个受影响的单元格显示第一行的值", + "heatmapEmptyCategory": "(空)", + "heatmapHistogramsCapped": "为不超过热力图单元格限制,显示 {total} 个直方图中最繁忙的 {shown} 个", + "heatmapHistogramColumnsCapped": "为不超过热力图单元格限制,显示 {total} 列中的最后 {shown} 列", + "heatmapHistogramsSeriesLimited": "显示 {shown}/{total} 个直方图,以保证在序列上限内", + "heatmapHistogramsHeightCapped": "显示 {total} 个直方图中最繁忙且能放入面板高度的 {shown} 个", + "heatmapColorRangeInverted": "热力图颜色刻度的最小值必须小于最大值", + "heatmapYBucketSizeInvalid": "热力图 y 轴分桶大小必须大于 0", + "heatmapYBucketTooMany": "热力图 y 轴分桶最多可生成 {max} 行;请使用更大的分桶大小或更窄的 y 范围", + "heatmapYRangeInverted": "热力图 Y 最小值必须小于 Y 最大值", "stateTimelineRowCap": "显示 {shown} 行,共 {total} 行。", "stateTimelineStateCap": "{total} 个状态中有 {shown} 个拥有独立颜色;其余共用另一种颜色。", "stateTimelineOther": "其他", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index f62059a374..11c53c88c3 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -6835,6 +6835,50 @@ "chartAlign": "圖表對齊", "decimals": "小數位數", "decimalsMustBeBetween": "小數位數必須介於 0 到 100 之間", + "heatmapColorMin": "色階最小值", + "heatmapColorMax": "色階最大值", + "heatmapColorMinTooltipTitle": "設定最低顏色代表的值。", + "heatmapColorMinTooltipDescription": "低於此值的儲存格會採用最低顏色。留空則使用資料範圍。若會超過另一端,將改用自動範圍。", + "heatmapColorMaxTooltipTitle": "設定最高顏色代表的值。", + "heatmapColorMaxTooltipDescription": "高於此值的儲存格會採用最高顏色。留空則使用資料範圍。若會超過另一端,將改用自動範圍。", + "heatmapColorRangeInvalid": "最小值必須小於最大值;將改用自動範圍", + "heatmapPalette": "熱圖調色盤", + "heatmapPaletteSpectral": "Spectral", + "heatmapPaletteViridis": "Viridis", + "heatmapPaletteMagma": "Magma", + "heatmapPaletteInferno": "Inferno", + "heatmapPaletteBlues": "Blues", + "heatmapPaletteGreens": "Greens", + "heatmapPaletteReds": "Reds", + "heatmapPaletteRdYlBu": "紅-黃-藍", + "heatmapPaletteReverse": "反轉調色盤", + "heatmapColorScale": "色階", + "heatmapColorScaleLinear": "線性", + "heatmapColorScaleSqrt": "平方根", + "heatmapColorScaleLog": "對數", + "heatmapHistogramMode": "Prometheus 直方圖桶", + "heatmapHistogramModeTooltip": "將每個序列視為傳統直方圖的累積 le 桶。桶會依 le 排序並去除累積,每個標籤集各一張網格。", + "heatmapYSort": "Y 軸順序", + "heatmapYSortTooltip": "資料順序會保留查詢傳回的列順序。遞增會將最小值放在底部;遞減則將其放在頂部。數字會依數值排序。", + "heatmapYSortData": "資料順序", + "heatmapYSortAsc": "遞增", + "heatmapYSortDesc": "遞減", + "heatmapYBucket": "Y 軸分桶", + "heatmapYBucketTooltip": "在產生的 SQL 中將數值 y 欄位分組為範圍。每一列會以其範圍標示,而對數模式會將所有小於等於 0 的值放在同一列。", + "heatmapYBucketNone": "無", + "heatmapYBucketSizeMode": "固定大小", + "heatmapYBucketLogMode": "對數", + "heatmapYBucketSize": "桶大小", + "heatmapYBucketBase": "對數底數", + "heatmapYBucketSizeInvalid": "請輸入大於 0 的桶大小;在此之前不會執行查詢", + "heatmapYBucketTooMany": "這會產生 {count} 個桶,超過熱圖可繪製的 {max} 個;在您使用較大的桶大小或更窄的 y 範圍之前,不會執行查詢", + "heatmapYMin": "Y 最小值", + "heatmapYMax": "Y 最大值", + "heatmapYMinTooltip": "略過低於此值的值。它會篩選查詢,因此也會減少讀取的資料量。", + "heatmapYMaxTooltip": "略過高於此值的值。它會篩選查詢,因此也會減少讀取的資料量。", + "heatmapYRangeInvalid": "最小值必須小於最大值;將改用完整範圍", + "bucketUnit": "桶單位", + "bucketUnitCustom": "自訂桶單位", "initialView": "初始檢視:", "query": "查詢", "queryLimit": "查詢限制", @@ -7870,6 +7914,18 @@ "invalidVariableDependency": "無效的相依性:{child} 不能依賴於 {parent}", "nestedArgPath": "{field} → 參數 {index}", "seriesLimitWarningCounts": "為保持圖表流暢,顯示 {total} 個數列中的 {shown} 個", + "heatmapRowsCapped": "顯示 {shown} 列(共 {total} 列),以維持在熱圖儲存格限制內", + "heatmapColumnsCapped": "顯示最後 {shownColumns} 欄(共 {totalColumns} 欄)及 {shownRows} 列(共 {totalRows} 列),以維持在熱圖儲存格限制內", + "heatmapDuplicateCells": "找到重複的 (x, y) 列:{count};每個受影響的儲存格會顯示第一列的值", + "heatmapEmptyCategory": "(空)", + "heatmapHistogramsCapped": "顯示最繁忙的 {shown} 個直方圖(共 {total} 個),以維持在熱圖儲存格限制內", + "heatmapHistogramColumnsCapped": "顯示最後 {shown} 欄(共 {total} 欄),以維持在熱圖儲存格限制內", + "heatmapHistogramsSeriesLimited": "顯示 {total} 個直方圖中的 {shown} 個,以維持在序列上限內", + "heatmapHistogramsHeightCapped": "顯示符合面板高度的 {total} 個直方圖中最活躍的 {shown} 個", + "heatmapColorRangeInverted": "熱圖色階最小值必須小於最大值", + "heatmapYBucketSizeInvalid": "熱圖 Y 軸的分桶大小必須大於 0", + "heatmapYBucketTooMany": "熱圖 Y 軸分桶最多可產生 {max} 列;請使用更大的分桶大小或更窄的 Y 範圍", + "heatmapYRangeInverted": "熱圖 Y 最小值必須小於 Y 最大值", "stateTimelineRowCap": "顯示 {shown} 列,共 {total} 列。", "stateTimelineStateCap": "{total} 種狀態中有 {shown} 種擁有專屬顏色;其餘狀態共用另一種顏色。", "stateTimelineOther": "其他", diff --git a/web/src/utils/dashboard/configUtils.spec.ts b/web/src/utils/dashboard/configUtils.spec.ts index 59d5ec0d2e..ac95c11cc5 100644 --- a/web/src/utils/dashboard/configUtils.spec.ts +++ b/web/src/utils/dashboard/configUtils.spec.ts @@ -28,6 +28,7 @@ import { shouldShowAreaLineStyleConfig, shouldShowTopResultsConfig, shouldShowAxisConfig, + shouldShowAxisBorderConfig, shouldShowCartesianAxisConfig, shouldShowAxisLabelConfig, shouldShowLineThickness, @@ -1241,6 +1242,14 @@ describe("configUtils", () => { }); }); + describe("shouldShowAxisBorderConfig", () => { + it("offers the axis border toggle where axis config shows, except on heatmaps", () => { + expect(shouldShowAxisBorderConfig({ data: { type: "line" } })).toBe(true); + expect(shouldShowAxisBorderConfig({ data: { type: "heatmap" } })).toBe(false); + expect(shouldShowAxisBorderConfig({ data: { type: "table" } })).toBe(false); + }); + }); + describe("shouldShowAxisConfig", () => { it("should return true for line chart", () => { expect(shouldShowAxisConfig({ data: { type: "line" } })).toBe(true); diff --git a/web/src/utils/dashboard/configUtils.ts b/web/src/utils/dashboard/configUtils.ts index 690a96c7d8..ee6d2eaaa9 100644 --- a/web/src/utils/dashboard/configUtils.ts +++ b/web/src/utils/dashboard/configUtils.ts @@ -222,6 +222,10 @@ export const shouldShowAxisConfig = (dashboardPanelData: any): boolean => { ); }; +// The editor stores axis_border_show false by default, so honouring it on heatmaps would strip the axis lines of every saved one. +export const shouldShowAxisBorderConfig = (dashboardPanelData: any): boolean => + shouldShowAxisConfig(dashboardPanelData) && dashboardPanelData.data.type !== "heatmap"; + /** * 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 diff --git a/web/src/utils/dashboard/convertPromQLData.spec.ts b/web/src/utils/dashboard/convertPromQLData.spec.ts index 41a4ffaa57..ca5b6efbff 100644 --- a/web/src/utils/dashboard/convertPromQLData.spec.ts +++ b/web/src/utils/dashboard/convertPromQLData.spec.ts @@ -4848,4 +4848,43 @@ describe("Convert PromQL Data Utils", () => { expect(result.extras.limitNumberOfSeriesWarningMessage).toBeUndefined(); }); }); + + describe("heatmap time axis", () => { + it("hands the server step to the heatmap so steps without samples still get a column", async () => { + const panelSchema = { id: "panel1", type: "heatmap", config: {}, queries: [{ config: {} }] }; + const searchQueryData = [ + { + result: [ + { + metric: { job: "api" }, + values: [ + [60, "1"], + [180, "3"], + ], + }, + ], + }, + ]; + + const result: any = await convertPromQLData( + panelSchema, + searchQueryData, + mockStore, + mockChartPanelRef, + mockHoveredSeriesState, + [], + { queries: [{ startTime: 0, endTime: 240_000_000 }] }, + [[{ step: 60_000_000 }]], + ); + + expect(result.options.xAxis[0].data).toHaveLength(5); + expect(result.options.series[0].data.map((cell: any) => cell[2])).toEqual([ + "-", + 1, + "-", + 3, + "-", + ]); + }); + }); }); diff --git a/web/src/utils/dashboard/convertPromQLData.ts b/web/src/utils/dashboard/convertPromQLData.ts index d4d8da3dd4..20c5d10b9f 100644 --- a/web/src/utils/dashboard/convertPromQLData.ts +++ b/web/src/utils/dashboard/convertPromQLData.ts @@ -282,6 +282,7 @@ export const convertPromQLData = async ( metadata, parentQueryIndex, nameSuffixes, + stepSeconds, }); if (result.options) applySeriesOverrides(result.options, panelSchema, store.state.theme); diff --git a/web/src/utils/dashboard/convertSQLData.spec.ts b/web/src/utils/dashboard/convertSQLData.spec.ts index 3a0a5719d2..d2c34439c3 100644 --- a/web/src/utils/dashboard/convertSQLData.spec.ts +++ b/web/src/utils/dashboard/convertSQLData.spec.ts @@ -15,6 +15,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { convertMultiSQLData, convertSQLData } from "@/utils/dashboard/convertSQLData"; +import { calculateGridPositions } from "@/utils/dashboard/calculateGridForSubPlot"; // Mock external dependencies vi.mock("date-fns-tz", () => ({ @@ -11041,3 +11042,306 @@ describe("convertSQLData", () => { }); }); }); + +describe("multi-query heatmap pinned colour range", () => { + const store = { state: { theme: "light", zoConfig: { max_dashboard_series: 100 } } }; + const query = () => ({ + customQuery: true, + fields: { + x: [{ alias: "x" }], + y: [{ alias: "y" }], + z: [{ alias: "z" }], + breakdown: [], + }, + }); + const searchData = [ + [ + { x: "a", y: "r", z: 5 }, + { x: "b", y: "r", z: 20 }, + ], + [ + { x: "a", y: "r", z: 40 }, + { x: "b", y: "r", z: 80 }, + ], + ]; + + const render = (config: Record) => { + vi.mocked(calculateGridPositions).mockReturnValueOnce({ + gridArray: [ + { left: "0%", top: "0%", width: "50%", height: "100%" }, + { left: "50%", top: "0%", width: "50%", height: "100%" }, + ], + gridWidth: 400, + gridHeight: 400, + gridNoOfRow: 1, + gridNoOfCol: 2, + } as any); + return convertMultiSQLData( + { type: "heatmap", config: { decimals: 2, ...config }, queries: [query(), query()] }, + searchData, + store, + { value: { offsetWidth: 800, offsetHeight: 400 } }, + {}, + { value: [[{}], [{}]] }, + { queries: [{}, {}] }, + {}, + [], + ); + }; + + it("keeps the shared automatic range when nothing is pinned", async () => { + const { visualMap } = (await render({})).options; + expect([visualMap.min, visualMap.max]).toEqual([0, 80]); + }); + + it("replaces the shared range with both pins", async () => { + const { visualMap } = (await render({ heatmap_color_min: 10, heatmap_color_max: 30 })).options; + expect([visualMap.min, visualMap.max]).toEqual([10, 30]); + }); + + it("pins only the max over the shared automatic min", async () => { + const { visualMap } = (await render({ heatmap_color_max: 30 })).options; + expect([visualMap.min, visualMap.max]).toEqual([0, 30]); + }); + + it("keeps a min pin that one query alone would reject", async () => { + // 50 is above query 0's own max of 20 but below the shared max of 80. + const { visualMap } = (await render({ heatmap_color_min: 50 })).options; + expect([visualMap.min, visualMap.max]).toEqual([50, 80]); + }); + + it("ignores an inverted pair and uses the shared automatic range", async () => { + const { visualMap } = (await render({ heatmap_color_min: 30, heatmap_color_max: 10 })).options; + expect([visualMap.min, visualMap.max]).toEqual([0, 80]); + }); + + it("keeps real values in every query's cells", async () => { + const { series } = (await render({ heatmap_color_max: 30 })).options; + expect(series[1].data.map((cell: any) => cell[2])).toEqual([40, 80]); + }); + + it("colours every tile from one palette and scale over the shared range", async () => { + const { visualMap } = (await render({ heatmap_palette: "viridis", heatmap_color_scale: "log" })) + .options; + // The log floor is the smallest positive cell of any query: query 0's 5. + expect([visualMap.min, visualMap.max]).toEqual([5, 80]); + expect(visualMap.inRange.color[0]).toBe("#440154"); + expect(visualMap.inRange.color.at(-1)).toBe("#fde725"); + }); +}); + +describe("multi-query heatmap tiles", () => { + const store = { state: { theme: "light", zoConfig: { max_dashboard_series: 100 } } }; + const query = () => ({ + customQuery: true, + fields: { + x: [{ alias: "x" }], + y: [{ alias: "y" }], + z: [{ alias: "z" }], + breakdown: [], + }, + }); + + const render = (searchData: any[]) => { + vi.mocked(calculateGridPositions).mockReturnValueOnce({ + gridArray: [ + { left: "0%", top: "0%", width: "50%", height: "100%" }, + { left: "50%", top: "0%", width: "50%", height: "100%" }, + ], + gridWidth: 400, + gridHeight: 400, + gridNoOfRow: 1, + gridNoOfCol: 2, + } as any); + return convertMultiSQLData( + { type: "heatmap", config: { decimals: 2 }, queries: [query(), query()] }, + searchData, + store, + { value: { offsetWidth: 800, offsetHeight: 400 } }, + {}, + { value: [[{}], [{}]] }, + { queries: [{}, {}] }, + {}, + [], + ); + }; + + it("colours all-negative tiles over their negative range", async () => { + const { visualMap } = ( + await render([ + [ + { x: "a", y: "r", z: -5 }, + { x: "b", y: "r", z: -20 }, + ], + [ + { x: "a", y: "r", z: -40 }, + { x: "b", y: "r", z: -2 }, + ], + ]) + ).options; + expect([visualMap.min, visualMap.max]).toEqual([-40, -2]); + }); + + it("hands drilldowns each tile's raw row values by its y axis", async () => { + const result: any = await render([[{ x: "a", y: "", z: 1 }], [{ x: "a", y: "r2", z: 1 }]]); + expect(result.extras.heatmapRowValues).toEqual([[""], ["r2"]]); + }); + + it("surfaces a later tile's duplicate-row warning on the panel", async () => { + const result: any = await render([ + [{ x: "a", y: "r", z: 1 }], + [ + { x: "a", y: "r", z: 1 }, + { x: "a", y: "r", z: 2 }, + ], + ]); + expect(result.extras.limitNumberOfSeriesWarningMessage).toBe( + "Duplicate (x, y) rows found: 1; each affected cell shows the first row's value", + ); + }); +}); + +describe("heatmap drag-to-zoom and cursor sync", () => { + const store = { + state: { + theme: "light", + timezone: "UTC", + zoConfig: { max_dashboard_series: 100, timestamp_column: "_timestamp" }, + }, + }; + const builderQuery = () => ({ + customQuery: false, + fields: { + x: [{ alias: "x", functionName: "histogram", args: [{ value: { field: "_timestamp" } }] }], + y: [{ alias: "y" }], + z: [{ alias: "z" }], + breakdown: [], + }, + }); + const customQuery = () => ({ ...builderQuery(), customQuery: true }); + const rows = [ + { x: "2024-01-15T10:00:00", y: "r", z: 1 }, + { x: "2024-01-15T10:01:00", y: "r", z: 2 }, + ]; + const range = { + startTime: Date.parse("2024-01-15T10:00:00Z") * 1000, + endTime: Date.parse("2024-01-15T10:02:00Z") * 1000, + }; + const render = (query: any, resultMetaData: any = [{ histogram_interval: 60 }]) => + convertSQLData( + { type: "heatmap", config: {}, queries: [query] }, + [rows], + store, + { value: { offsetWidth: 800, offsetHeight: 400 } }, + { value: null }, + resultMetaData, + { queries: [range] }, + {}, + [], + ); + + it("zooms and syncs a builder heatmap whose x is histogram(_timestamp)", async () => { + const result: any = await render(builderQuery()); + expect(result.options.toolbox.show).toBe(true); + expect(result.extras.isTimeSeries).toBe(true); + // The bucket starting at the range end is drawn too, as the filled x axis has always done. + expect(result.extras.heatmapTimeAxis.columns).toHaveLength(3); + }); + + it("keeps a heatmap with a categorical x without zoom or sync", async () => { + const result: any = await render(customQuery()); + expect(result.options.toolbox.show).toBe(false); + expect(result.extras.isTimeSeries).toBe(false); + expect(result.extras.heatmapTimeAxis).toBeUndefined(); + }); + + it("keeps a builder histogram heatmap without a known interval without zoom", async () => { + const result: any = await render(builderQuery(), [{}]); + expect(result.options.toolbox.show).toBe(false); + expect(result.extras.heatmapTimeAxis).toBeUndefined(); + }); + + it("drops zoom and sync from heatmap tiles whose columns differ", async () => { + vi.mocked(calculateGridPositions).mockReturnValueOnce({ + gridArray: [ + { left: "0%", top: "0%", width: "50%", height: "100%" }, + { left: "50%", top: "0%", width: "50%", height: "100%" }, + ], + gridWidth: 400, + gridHeight: 400, + gridNoOfRow: 1, + gridNoOfCol: 2, + } as any); + const result: any = await convertMultiSQLData( + { type: "heatmap", config: {}, queries: [builderQuery(), builderQuery()] }, + [rows, rows], + store, + { value: { offsetWidth: 800, offsetHeight: 400 } }, + { value: null }, + { value: [[{ histogram_interval: 60 }], [{ histogram_interval: 30 }]] }, + { queries: [range, range] }, + {}, + [], + ); + expect(result.extras.heatmapTimeAxis).toBeUndefined(); + expect(result.extras.isTimeSeries).toBe(false); + expect(result.options.toolbox.show).toBe(false); + }); +}); + +describe("multi-query heatmap tiles share one cell budget", () => { + const store = { state: { theme: "light", zoConfig: { max_dashboard_series: 100 } } }; + const query = () => ({ + customQuery: true, + fields: { x: [{ alias: "x" }], y: [{ alias: "y" }], z: [{ alias: "z" }], breakdown: [] }, + }); + const grid = () => + vi.mocked(calculateGridPositions).mockReturnValueOnce({ + gridArray: [ + { left: "0%", top: "0%", width: "50%", height: "100%" }, + { left: "50%", top: "0%", width: "50%", height: "100%" }, + ], + gridWidth: 400, + gridHeight: 400, + gridNoOfRow: 1, + gridNoOfCol: 2, + } as any); + const render = (searchData: any[]) => { + grid(); + return convertMultiSQLData( + { type: "heatmap", config: { decimals: 2 }, queries: [query(), query()] }, + searchData, + store, + { value: { offsetWidth: 800, offsetHeight: 400 } }, + {}, + { value: [[{}], [{}]] }, + { queries: [{}, {}] }, + {}, + [], + ); + }; + + it("cuts each tile to half the budget, so the panel stays within it", async () => { + const tile = Array.from({ length: 300 }, (_u, row) => + Array.from({ length: 100 }, (_v, col) => ({ x: `c${col}`, y: `r${row}`, z: 1 })), + ).flat(); + const result: any = await render([tile, tile]); + const cells = result.options.series.map((series: any) => series.data.length); + expect(cells.every((count: number) => count <= 25_000)).toBe(true); + expect(cells.reduce((sum: number, count: number) => sum + count, 0)).toBeLessThanOrEqual( + 50_000, + ); + }); + + it("labels a hovered cell from its own tile's y axis when an earlier tile is empty", async () => { + const result: any = await render([[], [{ x: "a", y: "row-b", z: 5 }]]); + expect(result.options.series).toHaveLength(1); + const text = result.options.tooltip.formatter({ + seriesIndex: 0, + value: [0, 0, 5], + name: "a", + marker: "", + }); + expect(text).toContain("row-b"); + }); +}); diff --git a/web/src/utils/dashboard/convertSQLData.ts b/web/src/utils/dashboard/convertSQLData.ts index ae4e628bc6..7bee6ce717 100644 --- a/web/src/utils/dashboard/convertSQLData.ts +++ b/web/src/utils/dashboard/convertSQLData.ts @@ -30,6 +30,13 @@ import { formatUnitValue, getUnitValue } from "./convertDataIntoUnitValue"; import { escapeHtml } from "@/utils/html"; import { applyGaugeSeriesThresholds, applyLineAreaThresholds } from "./thresholds"; import { applySeriesOverrides } from "./seriesOverrides"; +import { + HEATMAP_MAX_CELLS, + addHeatmapWarning, + heatmapColorRange, + heatmapColorScale, +} from "./heatmapDefaults"; +import { sharedHeatmapTimeAxis } from "./heatmapTimeAxis"; /** * Re-applies trellis layout on merged multi-query data. @@ -242,6 +249,10 @@ export const convertMultiSQLData = async ( ...panelSchema, queries: [panelSchema.queries[panelQueryIndex]], ...(isMultiQuery ? { config: { ...panelSchema.config, trellis: undefined } } : {}), + // Heatmap tiles draw side by side in one panel, so they split its cell budget. + ...(isMultiQuery && panelSchema.type === "heatmap" + ? { heatmapCellBudget: Math.floor(HEATMAP_MAX_CELLS / searchQueryData.length) } + : {}), }; options.push( @@ -661,6 +672,30 @@ export const convertMultiSQLData = async ( } }); + // Each tile reports its own cell-limit and duplicate warnings; only options[0] reaches the panel. + options.slice(1).forEach((opt: any) => { + const message = opt?.extras?.limitNumberOfSeriesWarningMessage; + if (message) { + options[0].extras = options[0].extras ?? {}; + addHeatmapWarning(options[0].extras, message); + } + }); + + options[0].extras = options[0].extras ?? {}; + options[0].extras.heatmapRowValues = options.map( + (opt: any) => opt?.extras?.heatmapRowValues?.[0] ?? null, + ); + + // One zoom box or hovered time maps through a single column list, so tiles must share it. + if ( + options[0].extras?.heatmapTimeAxis && + !sharedHeatmapTimeAxis(options.map((opt: any) => opt?.extras?.heatmapTimeAxis)) + ) { + delete options[0].extras.heatmapTimeAxis; + options[0].extras.isTimeSeries = false; + if (options[0].options.toolbox) options[0].options.toolbox.show = false; + } + options[0].options.grid = allGrids; options[0].options.xAxis = allXAxes; options[0].options.yAxis = allYAxes; @@ -674,8 +709,9 @@ export const convertMultiSQLData = async ( trigger: "item", formatter: (params: any) => { try { - const yLabel = - allYAxes[params.seriesIndex]?.data?.[params?.value?.[1]] ?? params?.seriesName ?? ""; + // An empty tile keeps its axis but loses its series, so series and axis indices can differ. + const yAxis = allYAxes[allSeries[params.seriesIndex]?.yAxisIndex]; + const yLabel = yAxis?.data?.[params?.value?.[1]] ?? params?.seriesName ?? ""; const rawVal = params?.value?.[2]; const formatted = formatUnitValue( @@ -694,8 +730,17 @@ export const convertMultiSQLData = async ( }; if (options[0].options.visualMap) { - options[0].options.visualMap.min = isFinite(globalMin) ? globalMin : 0; - options[0].options.visualMap.max = isFinite(globalMax) ? globalMax : 0; + // Re-resolved over the shared range: a pin one query rejected may still hold for the panel. + const { color, ...sharedRange } = heatmapColorScale( + panelSchema?.config, + heatmapColorRange(panelSchema?.config, { + min: isFinite(globalMin) ? globalMin : 0, + max: isFinite(globalMax) ? globalMax : 0, + }), + allSeries.map((series: any) => series.data ?? []), + ); + Object.assign(options[0].options.visualMap, sharedRange); + options[0].options.visualMap.inRange = { color }; options[0].options.visualMap.bottom = VISUAL_MAP_BOTTOM; } diff --git a/web/src/utils/dashboard/dashboardAutoQueryBuilder.spec.ts b/web/src/utils/dashboard/dashboardAutoQueryBuilder.spec.ts index c49058e63f..8a5dfebe95 100644 --- a/web/src/utils/dashboard/dashboardAutoQueryBuilder.spec.ts +++ b/web/src/utils/dashboard/dashboardAutoQueryBuilder.spec.ts @@ -695,6 +695,151 @@ describe("dashboardAutoQueryBuilder", () => { expect(result).toContain("GROUP BY x_alias, y_alias"); }); + describe("heatmap numeric y bucketing", () => { + const yFieldOf = (field: string, extra: Record = {}) => ({ + alias: "y_axis_1", + isDerived: false, + functionName: null, + args: [{ type: "field", value: { field } }], + ...extra, + }); + const heatmapQuery = (config: Record, yField: any = yFieldOf("age")) => { + mockQueryData.fields.x = [ + { + alias: "x_axis_1", + isDerived: false, + functionName: "histogram", + args: [{ type: "field", value: { field: "_timestamp" } }], + }, + ]; + mockQueryData.fields.y = [yField]; + mockQueryData.fields.z = [ + { + alias: "z_axis_1", + isDerived: false, + functionName: "count", + args: [{ type: "field", value: { field: "_timestamp" } }], + }, + ]; + mockDashboardPanelData.data.config = config; + return buildSQLChartQuery({ + queryData: mockQueryData, + chartType: "heatmap", + dashboardPanelData: mockDashboardPanelData, + }); + }; + + it("is unchanged when bucketing is unset", () => { + expect(heatmapQuery({})).toBe( + 'SELECT histogram(_timestamp) as "x_axis_1", age as "y_axis_1", count(_timestamp) as "z_axis_1" FROM "logs" GROUP BY x_axis_1, y_axis_1', + ); + }); + + it("groups by the lower bound of a fixed-size bucket", () => { + expect(heatmapQuery({ heatmap_y_bucket: { mode: "size", size: 10 } })).toBe( + 'SELECT histogram(_timestamp) as "x_axis_1", floor(CAST(age AS DOUBLE) / 10) * 10 as "y_axis_1", count(_timestamp) as "z_axis_1" FROM "logs" GROUP BY x_axis_1, y_axis_1', + ); + }); + + it("groups by the power of the log base, with <= 0 in one bucket", () => { + const sql = heatmapQuery({ heatmap_y_bucket: { mode: "log", base: 2 } }); + expect(sql).toContain( + 'CASE WHEN age > 0 THEN pow(2, floor(log2(CAST(age AS DOUBLE)))) WHEN age <= 0 THEN 0 END as "y_axis_1"', + ); + expect(sql).toContain("GROUP BY x_axis_1, y_axis_1"); + }); + + it("references the y column through the stream alias when joins exist", () => { + mockQueryData.joins = [ + { stream: "other", streamAlias: "b", joinType: "inner", conditions: [] }, + ]; + const sql = heatmapQuery({ + heatmap_y_bucket: { mode: "size", size: 5 }, + heatmap_y_min: 1, + }); + expect(sql).toContain('floor(CAST(logs.age AS DOUBLE) / 5) * 5 as "y_axis_1"'); + expect(sql).toContain("WHERE logs.age >= 1"); + }); + + it("adds the y range to the WHERE clause, keeping the user's OR group intact", () => { + mockQueryData.fields.filter.conditions = [ + { + type: "condition", + column: { field: "status" }, + operator: "=", + value: "a", + logicalOperator: "AND", + }, + { + type: "condition", + column: { field: "status" }, + operator: "=", + value: "b", + logicalOperator: "OR", + }, + ]; + const sql = heatmapQuery({ + heatmap_y_bucket: { mode: "size", size: 10 }, + heatmap_y_min: -5, + heatmap_y_max: 500, + }); + expect(sql).toContain( + "WHERE (status = 'a' OR status = 'b') AND age >= -5 AND age <= 500 GROUP BY", + ); + }); + + it("adds a WHERE for the y range when there are no filters", () => { + expect(heatmapQuery({ heatmap_y_bucket: { mode: "log" }, heatmap_y_max: 1000 })).toContain( + 'FROM "logs" WHERE age <= 1000 GROUP BY', + ); + }); + + it("ignores an inverted y range", () => { + const sql = heatmapQuery({ + heatmap_y_bucket: { mode: "size", size: 10 }, + heatmap_y_min: 50, + heatmap_y_max: 10, + }); + expect(sql).not.toContain("WHERE"); + expect(sql).toContain("floor(CAST(age AS DOUBLE) / 10) * 10"); + }); + + it.each([0, -1, null, "abc"])("generates no query for bucket size %s", (size) => { + expect(heatmapQuery({ heatmap_y_bucket: { mode: "size", size } })).toBe(""); + }); + + it("generates no query when the known range exceeds the cell budget", () => { + expect( + heatmapQuery({ + heatmap_y_bucket: { mode: "size", size: 0.001 }, + heatmap_y_min: 0, + heatmap_y_max: 1000, + }), + ).toBe(""); + }); + + it("leaves a non-numeric or aggregated y as it is", () => { + const config = { heatmap_y_bucket: { mode: "size", size: 10 }, heatmap_y_min: 1 }; + expect(heatmapQuery(config, yFieldOf("status"))).toContain('status as "y_axis_1"'); + expect(heatmapQuery(config, yFieldOf("status"))).not.toContain("WHERE"); + expect(heatmapQuery(config, yFieldOf("age", { functionName: "max" }))).toContain( + 'max(age) as "y_axis_1"', + ); + }); + + it("does not bucket other chart types", () => { + mockQueryData.fields.x = [yFieldOf("status", { alias: "x_axis_1" })]; + mockQueryData.fields.y = [yFieldOf("age")]; + mockDashboardPanelData.data.config = { heatmap_y_bucket: { mode: "size", size: 10 } }; + const sql = buildSQLChartQuery({ + queryData: mockQueryData, + chartType: "bar", + dashboardPanelData: mockDashboardPanelData, + }); + expect(sql).toContain('age as "y_axis_1"'); + }); + }); + it("should build query with breakdown fields", () => { mockQueryData.fields.x = [ { diff --git a/web/src/utils/dashboard/dashboardAutoQueryBuilder.ts b/web/src/utils/dashboard/dashboardAutoQueryBuilder.ts index 0a4af8506a..b16e222a55 100644 --- a/web/src/utils/dashboard/dashboardAutoQueryBuilder.ts +++ b/web/src/utils/dashboard/dashboardAutoQueryBuilder.ts @@ -1,6 +1,13 @@ import { splitQuotedString, escapeSingleQuotes } from "@/utils/zincutils"; import functionValidation from "@/components/dashboards/addPanel/dynamicFunction/functionValidation.json"; import { DEFAULT_CAST_TARGET_TYPE, isCastTargetType } from "@/utils/dashboard/castSuggestion"; +import { + heatmapYBucketError, + heatmapYBucketing, + heatmapYBucketSql, + heatmapYRangeSql, + isNumericYField, +} from "@/utils/dashboard/heatmapYAxis"; export function buildSQLQueryFromInput(fields: any, defaultStream: any): string { // Handle undefined or null fields @@ -358,6 +365,9 @@ export function buildSQLChartQuery(config: { const stream = queryData.fields.stream; const streamAlias = queryData?.joins?.length ? stream : ""; + const yBucket = chartType === "heatmap" ? heatmapYBucketPlan(config, streamAlias) : null; + if (yBucket === "reject") return ""; + // Merge all field lists const allFields = [ ...queryData.fields.x, @@ -373,7 +383,10 @@ export function buildSQLChartQuery(config: { const validFieldAliases = new Set(); for (const field of allFields) { - const expr = buildFieldExpression(field, streamAlias); + const expr = + yBucket && field === yBucket.field + ? `${yBucket.select} as "${field.alias}"` + : buildFieldExpression(field, streamAlias); if (expr) { selectExpressions.push(expr); validFieldAliases.add(field.alias); @@ -391,7 +404,7 @@ export function buildSQLChartQuery(config: { // WHERE clause const whereClause = buildWhereClause(queryData.fields.filter.conditions, dashboardPanelData); - query += whereClause; + query += yBucket?.where ? andWhere(whereClause, yBucket.where) : whereClause; // GROUP BY clause - chart-type-specific logic // Only include fields that have valid expressions (are in validFieldAliases) @@ -489,6 +502,32 @@ export function buildSQLChartQuery(config: { return query; } +/** The bucketed y SELECT expression and range condition for a heatmap whose y is a numeric column, "reject" when the settings must not run, or null when bucketing is off. */ +function heatmapYBucketPlan( + config: { queryData: any; dashboardPanelData: any }, + streamAlias: string, +): { field: any; select: string; where: string } | "reject" | null { + const field = config.queryData.fields.y?.[0]; + const panelConfig = config.dashboardPanelData?.data?.config; + if (!isNumericYField(field, config.dashboardPanelData)) return null; + if (heatmapYBucketError(panelConfig)) return "reject"; + const bucketing = heatmapYBucketing(panelConfig); + if (!bucketing) return null; + const expr = buildSQLQueryFromInput(field, streamAlias); + if (!expr) return null; + return { + field, + select: heatmapYBucketSql(expr, bucketing.bucket), + where: heatmapYRangeSql(expr, bucketing.range), + }; +} + +/** `whereClause` (" WHERE ..." or "") with `condition` ANDed on, keeping the user's OR groups intact. */ +function andWhere(whereClause: string, condition: string): string { + const existing = whereClause.replace(/^ WHERE /, "").trim(); + return existing ? ` WHERE (${existing}) AND ${condition}` : ` WHERE ${condition}`; +} + export function addMissingArgs(fields: any): any { const { functionName, args } = fields; diff --git a/web/src/utils/dashboard/exemplars/applyExemplarSeries.ts b/web/src/utils/dashboard/exemplars/applyExemplarSeries.ts index e5a794ee7c..18a8890283 100644 --- a/web/src/utils/dashboard/exemplars/applyExemplarSeries.ts +++ b/web/src/utils/dashboard/exemplars/applyExemplarSeries.ts @@ -20,8 +20,8 @@ export const EXEMPLAR_SERIES_ID = "__exemplars__"; export const EXEMPLAR_SYMBOL_SIZE = 10; export const EXEMPLAR_HALO_WIDTH = 1.5; export const EXEMPLAR_EDGE_BLUR = 2; -const EXEMPLAR_Z = 20; -const EMPHASIS_SCALE = 1.4; +export const EXEMPLAR_Z = 20; +export const EXEMPLAR_EMPHASIS_SCALE = 1.4; export type ExemplarClamp = "top" | "bottom" | false; @@ -103,6 +103,18 @@ export function toChartTimeMs(tsMs: number, timezone: string | undefined): numbe return toZonedTime(tsMs, timezone).getTime(); } +/** The marker series of heatmap grid `grid`; grid 0 keeps the plain id, so single-grid panels match every existing check. */ +export function heatmapExemplarSeriesId(grid: number): string { + return grid === 0 ? EXEMPLAR_SERIES_ID : `${EXEMPLAR_SERIES_ID}:${grid}`; +} + +/** True for any exemplar marker series, including a stacked heatmap's per-grid ones. */ +export function isExemplarSeriesId(id: unknown): boolean { + return ( + typeof id === "string" && (id === EXEMPLAR_SERIES_ID || id.startsWith(`${EXEMPLAR_SERIES_ID}:`)) + ); +} + const asArray = (axis: unknown): AnyOptions[] => { if (Array.isArray(axis)) return [...axis]; return axis ? [axis as AnyOptions] : []; @@ -201,7 +213,7 @@ export function applyExemplarSeries( yAxisIndex: yAxes.length, z: EXEMPLAR_Z, symbolSize: EXEMPLAR_SYMBOL_SIZE, - emphasis: { scale: EMPHASIS_SCALE }, + emphasis: { scale: EXEMPLAR_EMPHASIS_SCALE }, itemStyle: { borderColor: ctx.haloColor, borderWidth: EXEMPLAR_HALO_WIDTH, diff --git a/web/src/utils/dashboard/exemplars/exemplarEligibility.spec.ts b/web/src/utils/dashboard/exemplars/exemplarEligibility.spec.ts index 29421c960d..f4c53d46e2 100644 --- a/web/src/utils/dashboard/exemplars/exemplarEligibility.spec.ts +++ b/web/src/utils/dashboard/exemplars/exemplarEligibility.spec.ts @@ -23,30 +23,27 @@ const panel = (type: string, queryType: string, queryTypes: (string | undefined) }); describe("isExemplarEligible", () => { - it.each(["line", "area", "bar", "scatter"])("accepts a PromQL range %s panel", (type) => { - expect(isExemplarEligible(panel(type, "promql", ["range"]))).toBe(true); - }); + it.each(["line", "area", "bar", "scatter", "heatmap"])( + "accepts a PromQL range %s panel", + (type) => { + expect(isExemplarEligible(panel(type, "promql", ["range"]))).toBe(true); + }, + ); it("treats a query without query_type as a range query", () => { expect(isExemplarEligible(panel("line", "promql", [undefined]))).toBe(true); }); - it.each([ - "h-bar", - "stacked", - "area-stacked", - "h-stacked", - "table", - "metric", - "gauge", - "heatmap", - "pie", - ])("rejects chart type %s", (type) => { - expect(isExemplarEligible(panel(type, "promql", ["range"]))).toBe(false); - }); + it.each(["h-bar", "stacked", "area-stacked", "h-stacked", "table", "metric", "gauge", "pie"])( + "rejects chart type %s", + (type) => { + expect(isExemplarEligible(panel(type, "promql", ["range"]))).toBe(false); + }, + ); it.each(["sql", "builder", "custom"])("rejects a %s panel", (queryType) => { expect(isExemplarEligible(panel("line", queryType, ["range"]))).toBe(false); + expect(isExemplarEligible(panel("heatmap", queryType, ["range"]))).toBe(false); }); it("rejects a panel whose queries are all instant", () => { diff --git a/web/src/utils/dashboard/exemplars/exemplarEligibility.ts b/web/src/utils/dashboard/exemplars/exemplarEligibility.ts index f7c222ec12..0f1df3c2e2 100644 --- a/web/src/utils/dashboard/exemplars/exemplarEligibility.ts +++ b/web/src/utils/dashboard/exemplars/exemplarEligibility.ts @@ -14,7 +14,7 @@ // along with this program. If not, see . /** Chart types that draw time on the horizontal axis without stacking. */ -export const EXEMPLAR_CHART_TYPES = ["line", "area", "bar", "scatter"] as const; +export const EXEMPLAR_CHART_TYPES = ["line", "area", "bar", "scatter", "heatmap"] as const; export interface ExemplarPanelLike { type?: string; diff --git a/web/src/utils/dashboard/exemplars/heatmapExemplars.spec.ts b/web/src/utils/dashboard/exemplars/heatmapExemplars.spec.ts new file mode 100644 index 0000000000..4a63374b64 --- /dev/null +++ b/web/src/utils/dashboard/exemplars/heatmapExemplars.spec.ts @@ -0,0 +1,288 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it, vi } from "vitest"; +import type { ExemplarMarker } from "@/ts/interfaces/exemplars"; +import { promqlHeatmapTimeAxis } from "../heatmapTimeAxis"; +import { HeatmapConverter } from "../promql/convertPromQLHeatmapChart"; +import { processPromQLData } from "../promql/shared/dataProcessor"; +import { EXEMPLAR_SERIES_ID, isExemplarSeriesId, toChartTimeMs } from "./applyExemplarSeries"; +import { + applyHeatmapExemplarSeries, + cachedHeatmapExemplarPlacement, + heatmapExemplarPlacer, + placeHeatmapExemplars, + type HeatmapExemplarLayout, +} from "./heatmapExemplars"; + +const T0 = Date.UTC(2026, 0, 1, 10, 0, 0) / 1000; +// Columns end at T0, T0+60 and T0+120, each covering the minute before it. +const axis = promqlHeatmapTimeAxis( + [ + [T0, "a"], + [T0 + 60, "b"], + [T0 + 120, "c"], + ], + 60, + T0 - 60, + "UTC", +)!; +const toChartX = (ms: number) => toChartTimeMs(ms, "UTC"); + +const marker = (overrides: Partial = {}): ExemplarMarker => ({ + id: "m1", + queryIndexes: [0], + tsMs: (T0 + 30) * 1000, + value: 0.3, + labels: { trace_id: "t1" }, + seriesLabels: { __name__: "lat_bucket", pod: "a", le: "0.5" }, + traceId: "t1", + ...overrides, +}); + +describe("heatmapExemplarPlacer in generic mode", () => { + const layout: HeatmapExemplarLayout = { + mode: "series", + rows: [ + { queryIndex: 0, labels: { pod: "b" } }, + { queryIndex: 0, labels: { pod: "a" } }, + null, + { queryIndex: 1, labels: { pod: "a" } }, + ], + }; + const place = heatmapExemplarPlacer(layout, axis, toChartX); + + it("puts a marker in the column holding its time and the row of its series", () => { + expect(place(marker())).toEqual({ grid: 0, seriesId: EXEMPLAR_SERIES_ID, value: [1, 1] }); + expect(place(marker({ tsMs: (T0 + 120) * 1000 }))).toMatchObject({ value: [2, 1] }); + }); + + it("matches only rows of a query that fetched the marker", () => { + expect(place(marker({ queryIndexes: [1] }))).toMatchObject({ value: [1, 3] }); + }); + + it("matches a hidden-query panel's rows by the panel query index the converter records", async () => { + // Query A is hidden, so the data holds B and C, which the executor's metadata names as panel queries 1 and 2. + const result = (labels: Record) => ({ + result: [{ metric: labels, values: [[T0 + 60, "1"]] }], + }); + const panelSchema = { id: "p", type: "heatmap", config: {}, queries: [{}, {}, {}] }; + const store = { state: { timezone: "UTC" } }; + const processed = await processPromQLData( + [result({ pod: "a" }), result({ pod: "a" })] as any, + panelSchema, + store, + { parentQueryIndex: [1, 2] }, + ); + const extras: any = { legends: [] }; + new HeatmapConverter().convert(processed, panelSchema, store, extras, undefined, { + metadata: { queries: [{ startTime: T0 * 1e6, endTime: (T0 + 120) * 1e6 }] }, + stepSeconds: 60, + } as any); + const hidden = heatmapExemplarPlacer( + extras.heatmapExemplarLayout, + extras.heatmapTimeAxis, + toChartX, + ); + expect(hidden(marker({ queryIndexes: [1] }))).toMatchObject({ value: [1, 0] }); + expect(hidden(marker({ queryIndexes: [2] }))).toMatchObject({ value: [1, 1] }); + expect(hidden(marker({ queryIndexes: [0] }))).toBeNull(); + }); + + it("prefers the most specific row when several match", () => { + const nested = heatmapExemplarPlacer( + { + mode: "series", + rows: [ + { queryIndex: 0, labels: {} }, + { queryIndex: 0, labels: { pod: "a" } }, + ], + }, + axis, + toChartX, + ); + expect(nested(marker())).toMatchObject({ value: [1, 1] }); + }); + + it("skips markers outside the drawn columns or with no drawn row", () => { + expect(place(marker({ tsMs: (T0 - 90) * 1000 }))).toBeNull(); + expect(place(marker({ tsMs: (T0 + 121) * 1000 }))).toBeNull(); + expect(place(marker({ seriesLabels: { pod: "z" } }))).toBeNull(); + expect(place(marker({ queryIndexes: [2] }))).toBeNull(); + }); +}); + +describe("heatmapExemplarPlacer in histogram mode", () => { + const layout: HeatmapExemplarLayout = { + mode: "histogram", + groups: [ + { queryIndex: 0, labels: { pod: "b" }, bounds: [0.1, 0.5, 1, Infinity] }, + { queryIndex: 0, labels: { pod: "a" }, bounds: [0.1, 0.5, 1] }, + ], + }; + const place = heatmapExemplarPlacer(layout, axis, toChartX); + + it("puts a marker in the bucket whose (previous le, le] holds its value, in its own histogram", () => { + expect(place(marker({ value: 0.3 }))).toEqual({ + grid: 1, + seriesId: `${EXEMPLAR_SERIES_ID}:1`, + value: [1, 1], + }); + expect(place(marker({ value: 0.5 }))).toMatchObject({ value: [1, 1] }); + expect(place(marker({ value: 0.05 }))).toMatchObject({ value: [1, 0] }); + expect(place(marker({ value: 0.51 }))).toMatchObject({ value: [1, 2] }); + }); + + it("puts a value above every finite bound in the +Inf bucket", () => { + const podB = { __name__: "lat_bucket", pod: "b", le: "+Inf" }; + expect(place(marker({ value: 7, seriesLabels: podB }))).toEqual({ + grid: 0, + seriesId: EXEMPLAR_SERIES_ID, + value: [1, 3], + }); + }); + + it("skips a value above the top bucket, a non-finite value and an unmatched histogram", () => { + expect(place(marker({ value: 7 }))).toBeNull(); + expect(place(marker({ value: NaN }))).toBeNull(); + expect(place(marker({ seriesLabels: { pod: "c" } }))).toBeNull(); + }); +}); + +describe("applyHeatmapExemplarSeries", () => { + const layout: HeatmapExemplarLayout = { + mode: "histogram", + groups: [ + { queryIndex: 0, labels: { pod: "b" }, bounds: [1, Infinity] }, + { queryIndex: 0, labels: { pod: "a" }, bounds: [1, Infinity] }, + ], + }; + const place = heatmapExemplarPlacer(layout, axis, toChartX); + const style = { queryColors: ["red"], haloColor: "white", edgeColor: "black" }; + const options = { series: [{ type: "heatmap" }, { type: "heatmap" }], xAxis: [{}, {}] }; + + it("adds one marker series per grid with markers, on that grid's own axes", () => { + const out = applyHeatmapExemplarSeries( + options, + [ + marker({ id: "a", value: 0.5 }), + marker({ id: "b", value: 3, seriesLabels: { pod: "b" } }), + marker({ id: "skipped", tsMs: 0 }), + ], + place, + style, + )!; + const added = out.series.slice(2); + expect(added.map((s: any) => [s.id, s.xAxisIndex, s.yAxisIndex])).toEqual([ + [`${EXEMPLAR_SERIES_ID}:1`, 1, 1], + [EXEMPLAR_SERIES_ID, 0, 0], + ]); + expect(added[0].data).toEqual([ + expect.objectContaining({ + value: [1, 0], + symbol: "diamond", + itemStyle: { color: "red" }, + exemplar: expect.objectContaining({ id: "a", traceId: "t1", clamped: false }), + }), + ]); + expect(added[0].tooltip).toEqual({ show: false }); + expect(added.every((s: any) => isExemplarSeriesId(s.id))).toBe(true); + expect(options.series).toHaveLength(2); + }); + + it("limits the colour bar to the heatmap series, so dragging it never hides a marker", () => { + const withMap = { ...options, visualMap: { min: 0, max: 5 } }; + const out = applyHeatmapExemplarSeries(withMap, [marker({ value: 0.5 })], place, style)!; + expect(out.visualMap).toEqual({ min: 0, max: 5, seriesIndex: [0, 1] }); + const listed = applyHeatmapExemplarSeries( + { ...options, visualMap: [{ min: 0 }, { seriesIndex: 1 }] }, + [marker({ value: 0.5 })], + place, + style, + )!; + expect(listed.visualMap).toEqual([{ min: 0, seriesIndex: [0, 1] }, { seriesIndex: 1 }]); + expect(withMap.visualMap).toEqual({ min: 0, max: 5 }); + }); + + it("returns the options untouched when no marker lands on a cell", () => { + expect(applyHeatmapExemplarSeries(options, [marker({ tsMs: 0 })], place, style)).toBe(options); + expect(applyHeatmapExemplarSeries(options, [], place, style)).toBe(options); + }); +}); + +describe("placeHeatmapExemplars", () => { + const layout: HeatmapExemplarLayout = { + mode: "series", + rows: [{ queryIndex: 0, labels: { pod: "a" } }], + }; + + it("places each marker once however often the chart asks", () => { + const place = vi.fn(heatmapExemplarPlacer(layout, axis, toChartX)); + const m = marker(); + const placed = placeHeatmapExemplars([m], place); + for (let i = 0; i < 5; i++) expect(placed(m)).toMatchObject({ value: [1, 0] }); + expect(place).toHaveBeenCalledTimes(1); + }); + + it("keeps only the topmost, last-drawn marker of a cell, and no marker it was not given", () => { + const first = marker({ id: "first" }); + const last = marker({ id: "last", tsMs: (T0 + 50) * 1000 }); + const other = marker({ id: "other", tsMs: (T0 + 90) * 1000 }); + const placed = placeHeatmapExemplars( + [first, last, other, marker({ id: "off", tsMs: 0 })], + heatmapExemplarPlacer(layout, axis, toChartX), + ); + expect(placed(first)).toBeNull(); + expect(placed(last)).toMatchObject({ value: [1, 0] }); + expect(placed(other)).toMatchObject({ value: [2, 0] }); + expect(placed(marker({ id: "first" }))).toBeNull(); + const out = applyHeatmapExemplarSeries( + { series: [{ type: "heatmap" }] }, + [first, last, other], + placed, + { queryColors: ["red"], haloColor: "white" }, + )!; + expect(out.series[1].data.map((d: any) => d.exemplar.id)).toEqual(["last", "other"]); + }); +}); + +describe("cachedHeatmapExemplarPlacement", () => { + const layout: HeatmapExemplarLayout = { + mode: "series", + rows: [{ queryIndex: 0, labels: { pod: "a" } }], + }; + + it("reuses the placement until the layout, axis or markers change", () => { + const toX = vi.fn(toChartX); + const placement = cachedHeatmapExemplarPlacement(toX); + const markers = [marker()]; + const first = placement(layout, axis, markers); + expect(placement(layout, axis, markers)).toBe(first); + expect(toX).toHaveBeenCalledTimes(1); + expect(placement(layout, axis, [...markers])).not.toBe(first); + expect(placement({ ...layout }, axis, markers)).not.toBe(first); + expect(placement(layout, { ...axis }, markers)(markers[0])).toMatchObject({ value: [1, 0] }); + }); +}); + +describe("isExemplarSeriesId", () => { + it("recognises the plain and per-grid marker series and nothing else", () => { + expect(isExemplarSeriesId(EXEMPLAR_SERIES_ID)).toBe(true); + expect(isExemplarSeriesId(`${EXEMPLAR_SERIES_ID}:2`)).toBe(true); + expect(isExemplarSeriesId(`${EXEMPLAR_SERIES_ID}x`)).toBe(false); + expect(isExemplarSeriesId("series-0")).toBe(false); + expect(isExemplarSeriesId(undefined)).toBe(false); + }); +}); diff --git a/web/src/utils/dashboard/exemplars/heatmapExemplars.ts b/web/src/utils/dashboard/exemplars/heatmapExemplars.ts new file mode 100644 index 0000000000..f7cf13cfb6 --- /dev/null +++ b/web/src/utils/dashboard/exemplars/heatmapExemplars.ts @@ -0,0 +1,227 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { ExemplarMarker } from "@/ts/interfaces/exemplars"; +import { heatmapColumnAt, type HeatmapTimeAxis } from "../heatmapTimeAxis"; +import { + EXEMPLAR_EDGE_BLUR, + EXEMPLAR_EMPHASIS_SCALE, + EXEMPLAR_HALO_WIDTH, + EXEMPLAR_SYMBOL_SIZE, + EXEMPLAR_Z, + heatmapExemplarSeriesId, + type ExemplarDatum, +} from "./applyExemplarSeries"; + +/** A drawn heatmap row's source: its panel query index and its labels without `__name__`. */ +export interface HeatmapExemplarRow { + queryIndex: number; + labels: Record; +} + +/** A drawn histogram grid's source, with its buckets' `le` bounds ascending, one per row. */ +export interface HeatmapExemplarGroup extends HeatmapExemplarRow { + bounds: number[]; +} + +/** What each drawn row or grid of a PromQL heatmap was built from; null entries take no exemplars. */ +export type HeatmapExemplarLayout = + | { mode: "series"; rows: (HeatmapExemplarRow | null)[] } + | { mode: "histogram"; groups: (HeatmapExemplarGroup | null)[] }; + +export interface HeatmapExemplarPlace { + grid: number; + seriesId: string; + value: [number, number]; +} + +export type HeatmapExemplarPlacer = (marker: ExemplarMarker) => HeatmapExemplarPlace | null; + +export interface HeatmapExemplarStyle { + /** Colour per panel query index. */ + queryColors: string[]; + haloColor: string; + edgeColor?: string; +} + +/** Maps a marker to the grid, marker series id and `[column, row]` cell holding it, or null when its time or series is off the heatmap. */ +export function heatmapExemplarPlacer( + layout: HeatmapExemplarLayout, + axis: HeatmapTimeAxis, + toChartX: (tsMs: number) => number, +): HeatmapExemplarPlacer { + return (marker) => { + const column = heatmapColumnAt(axis, toChartX(marker.tsMs)); + if (column < 0) return null; + const cell = + layout.mode === "series" + ? seriesCell(marker, layout.rows) + : histogramCell(marker, layout.groups); + if (!cell) return null; + return { + grid: cell.grid, + seriesId: heatmapExemplarSeriesId(cell.grid), + value: [column, cell.row], + }; + }; +} + +/** `place` resolved once per marker, keeping only the last-drawn marker of each cell, the only one there that can be seen or hovered. */ +export function placeHeatmapExemplars( + markers: ExemplarMarker[], + place: HeatmapExemplarPlacer, +): HeatmapExemplarPlacer { + const byCell = new Map(); + for (const marker of markers) { + const at = place(marker); + if (at) byCell.set(`${at.grid}:${at.value[0]}:${at.value[1]}`, [marker, at]); + } + const placed = new Map(byCell.values()); + return (marker) => placed.get(marker) ?? null; +} + +/** `placeHeatmapExemplars` that reuses its last placement while the layout, axis and markers are the same objects. */ +export function cachedHeatmapExemplarPlacement( + toChartX: (tsMs: number) => number, +): ( + layout: HeatmapExemplarLayout, + axis: HeatmapTimeAxis, + markers: ExemplarMarker[], +) => HeatmapExemplarPlacer { + let last: { + layout: HeatmapExemplarLayout; + axis: HeatmapTimeAxis; + markers: ExemplarMarker[]; + place: HeatmapExemplarPlacer; + } | null = null; + return (layout, axis, markers) => { + if (last?.layout !== layout || last.axis !== axis || last.markers !== markers) { + const place = placeHeatmapExemplars(markers, heatmapExemplarPlacer(layout, axis, toChartX)); + last = { layout, axis, markers, place }; + } + return last.place; + }; +} + +/** Returns a copy of `options` with one marker series per heatmap grid that has markers; markers on no drawn cell are left out. */ +export function applyHeatmapExemplarSeries( + options: Record | null | undefined, + markers: ExemplarMarker[], + place: HeatmapExemplarPlacer, + style: HeatmapExemplarStyle, +): Record | null | undefined { + if (!options || !markers.length) return options; + const byGrid = new Map(); + for (const marker of markers) { + const at = place(marker); + if (!at) continue; + const data = byGrid.get(at.grid) ?? []; + data.push(heatmapExemplarDatum(marker, at.value, style)); + byGrid.set(at.grid, data); + } + if (!byGrid.size) return options; + const series = [...byGrid.entries()].map(([grid, data]) => ({ + id: heatmapExemplarSeriesId(grid), + type: "scatter", + xAxisIndex: grid, + yAxisIndex: grid, + z: EXEMPLAR_Z, + symbolSize: EXEMPLAR_SYMBOL_SIZE, + emphasis: { scale: EXEMPLAR_EMPHASIS_SCALE }, + itemStyle: { + borderColor: style.haloColor, + borderWidth: EXEMPLAR_HALO_WIDTH, + opacity: 1, + ...(style.edgeColor ? { shadowColor: style.edgeColor, shadowBlur: EXEMPLAR_EDGE_BLUR } : {}), + }, + tooltip: { show: false }, + animation: false, + data, + })); + return { + ...options, + ...(options.visualMap + ? { visualMap: pinVisualMapToHeatmaps(options.visualMap, options.series ?? []) } + : {}), + series: [...(options.series ?? []), ...series], + }; +} + +function heatmapExemplarDatum( + marker: ExemplarMarker, + value: [number, number], + style: HeatmapExemplarStyle, +): ExemplarDatum { + return { + value, + symbol: "diamond", + itemStyle: { color: style.queryColors[marker.queryIndexes[0]] }, + exemplar: { + id: marker.id, + queryIndexes: marker.queryIndexes, + value: marker.value, + tsMs: marker.tsMs, + clamped: false, + placement: "value", + traceId: marker.traceId, + spanId: marker.spanId, + labels: marker.labels, + }, + }; +} + +/** `visualMap` limited to the heatmap series; unpinned, it also maps each marker by its last value, the row index. */ +function pinVisualMapToHeatmaps(visualMap: any, series: any[]): any { + const seriesIndex = series.flatMap((entry, index) => (entry?.type === "heatmap" ? [index] : [])); + const pin = (map: any) => (map && map.seriesIndex === undefined ? { ...map, seriesIndex } : map); + return Array.isArray(visualMap) ? visualMap.map(pin) : pin(visualMap); +} + +/** The index of the most specific source whose query fetched the marker and whose labels its series carries, or -1. */ +function matchSource(marker: ExemplarMarker, sources: (HeatmapExemplarRow | null)[]): number { + let best = -1; + let bestSize = -1; + sources.forEach((source, index) => { + if (!source || !marker.queryIndexes.includes(source.queryIndex)) return; + const entries = Object.entries(source.labels); + if (entries.some(([name, value]) => marker.seriesLabels?.[name] !== value)) return; + if (entries.length > bestSize) { + best = index; + bestSize = entries.length; + } + }); + return best; +} + +function seriesCell( + marker: ExemplarMarker, + rows: (HeatmapExemplarRow | null)[], +): { grid: number; row: number } | null { + const row = matchSource(marker, rows); + return row < 0 ? null : { grid: 0, row }; +} + +/** The bucket whose (previous `le`, `le`] holds the marker's value, in the histogram its series belongs to. */ +function histogramCell( + marker: ExemplarMarker, + groups: (HeatmapExemplarGroup | null)[], +): { grid: number; row: number } | null { + if (!Number.isFinite(marker.value)) return null; + const grid = matchSource(marker, groups); + if (grid < 0) return null; + // Bounds ascend, so the first one at or above the value closes the bucket that holds it. + const row = groups[grid]!.bounds.findIndex((le) => marker.value <= le); + return row < 0 ? null : { grid, row }; +} diff --git a/web/src/utils/dashboard/heatmapDefaults.spec.ts b/web/src/utils/dashboard/heatmapDefaults.spec.ts index b7b89e3582..7f0a2ffb5b 100644 --- a/web/src/utils/dashboard/heatmapDefaults.spec.ts +++ b/web/src/utils/dashboard/heatmapDefaults.spec.ts @@ -14,28 +14,63 @@ // along with this program. If not, see . import { describe, it, expect } from "vitest"; +import * as echarts from "echarts"; import { computeStepSeconds } from "@/utils/metrics/metricDefaults"; import { HEATMAP_LABEL_CELL_LIMIT, + HEATMAP_MAX_CELLS, HEATMAP_MAX_COLUMNS, HEATMAP_SPLIT_AREA, - HEATMAP_VISUAL_MAP_COLORS, + addHeatmapWarning, + cutHeatmapToBudget, + cutHistogramsToBudget, + heatmapBudgetWarning, heatmapCellItemStyle, + heatmapColorRange, + heatmapColorScale, + histogramBudgetWarnings, heatmapLargeGridDefaults, heatmapValueLabel, + isHeatmapColorRangeInverted, + visualMapRangeOf, } from "./heatmapDefaults"; -import { SPECTRAL_HEATMAP_STOP_COUNT } from "./promql/shared/spectral"; +import { HEATMAP_PALETTES, HEATMAP_STOP_COUNT, samplePalette } from "./heatmapPalettes"; +import { HeatmapConverter } from "./promql/convertPromQLHeatmapChart"; import { chartColor } from "@/utils/chartTheme"; +describe("isHeatmapColorRangeInverted", () => { + it("is true only when both pins are set with min >= max", () => { + expect(isHeatmapColorRangeInverted({ heatmap_color_min: 5, heatmap_color_max: 5 })).toBe(true); + expect(isHeatmapColorRangeInverted({ heatmap_color_min: "9", heatmap_color_max: 2 })).toBe( + true, + ); + expect(isHeatmapColorRangeInverted({ heatmap_color_min: 1, heatmap_color_max: 2 })).toBe(false); + expect(isHeatmapColorRangeInverted({ heatmap_color_min: 5, heatmap_color_max: null })).toBe( + false, + ); + expect(isHeatmapColorRangeInverted(undefined)).toBe(false); + }); +}); + +describe("cutHeatmapToBudget contiguous", () => { + it("keeps the busiest unbroken run of rows instead of the busiest rows", () => { + const totals = [9, 1, 1, 5, 6, 1]; + expect(cutHeatmapToBudget(1, totals, 2)?.rows).toEqual([0, 4]); + expect(cutHeatmapToBudget(1, totals, 2, true)?.rows).toEqual([3, 4]); + expect(cutHeatmapToBudget(1, totals, 6, true)).toBeNull(); + }); +}); + describe("shared heatmap defaults", () => { it("runs the colour ramp cool-to-warm", () => { // ColorBrewer publishes Spectral warm-first. Used in that order, a // histogram's near-empty high-`le` rows came out as a solid dark-red slab — // the loudest thing on the chart — while the busy low buckets sank into // blue. The eye reads hot as "more", so low must be cool. - expect(HEATMAP_VISUAL_MAP_COLORS).toHaveLength(SPECTRAL_HEATMAP_STOP_COUNT); - expect(HEATMAP_VISUAL_MAP_COLORS[0]).toBe("#5e4fa2"); - expect(HEATMAP_VISUAL_MAP_COLORS.at(-1)).toBe("#9e0142"); + const stops = heatmapColorScale({}, { min: 0, max: 10 }, []).color; + expect(stops).toHaveLength(HEATMAP_STOP_COUNT); + expect(stops[0]).toBe("#5e4fa2"); + expect(stops.at(-1)).toBe("#9e0142"); }); it("strokes cells in the panel background so they read as discrete tiles", () => { @@ -127,3 +162,372 @@ describe("the column cap is what actually bounds the work", () => { expect(cells).toBeLessThan(2000); }); }); + +describe("heatmapColorRange", () => { + const auto = { min: 0, max: 100 }; + + it("returns the base range untouched when nothing is pinned", () => { + expect(heatmapColorRange({}, auto)).toEqual(auto); + expect(heatmapColorRange(undefined, auto)).toEqual(auto); + expect(heatmapColorRange({ heatmap_color_min: null, heatmap_color_max: "" }, auto)).toEqual( + auto, + ); + }); + + it("pins only the min and keeps the automatic max", () => { + expect(heatmapColorRange({ heatmap_color_min: 10 }, auto)).toEqual({ + min: 10, + max: 100, + }); + }); + + it("pins only the max and keeps the automatic min", () => { + expect(heatmapColorRange({ heatmap_color_max: 50 }, auto)).toEqual({ + min: 0, + max: 50, + }); + }); + + it("pins both ends", () => { + expect(heatmapColorRange({ heatmap_color_min: 10, heatmap_color_max: 50 }, auto)).toEqual({ + min: 10, + max: 50, + }); + }); + + it("accepts zero as a pinned bound", () => { + expect(heatmapColorRange({ heatmap_color_min: 0, heatmap_color_max: 0.5 }, auto)).toEqual({ + min: 0, + max: 0.5, + }); + }); + + it.each([ + [50, 10], + [20, 20], + ])("ignores both pins when min %s >= max %s", (min, max) => { + expect(heatmapColorRange({ heatmap_color_min: min, heatmap_color_max: max }, auto)).toEqual( + auto, + ); + }); + + it("ignores a single pin that crosses the other, automatic end", () => { + expect(heatmapColorRange({ heatmap_color_min: 100 }, auto)).toEqual(auto); + expect(heatmapColorRange({ heatmap_color_max: -1 }, auto)).toEqual(auto); + }); + + it("ignores a non-numeric pin", () => { + expect(heatmapColorRange({ heatmap_color_min: "abc", heatmap_color_max: 50 }, auto)).toEqual({ + min: 0, + max: 50, + }); + }); +}); + +describe("a pinned colour range rendered by ECharts", () => { + // The converter's own output, so a change to the ECharts out-of-range default fails here. + const cellFills = (config: Record) => { + const values = [5, 10, 15, 20, 500]; + const options: any = new HeatmapConverter().convert( + [ + { + timestamps: values.map((_v, i) => [i, `00:0${i}:00`]), + series: [ + { + name: "s", + values: [], + data: Object.fromEntries(values.map((v, i) => [i, String(v)])), + }, + ], + queryIndex: 0, + } as any, + ], + { type: "heatmap", config }, + { state: { theme: "light" } }, + { legends: [] }, + ); + const chart = echarts.init(null as any, null, { + renderer: "svg", + ssr: true, + width: 400, + height: 300, + }); + chart.setOption({ animation: false, ...options, tooltip: undefined }); + const data = (chart as any).getModel().getSeriesByIndex(0).getData(); + const fills = values.map((_v, i) => data.getItemVisual(i, "style").fill); + chart.dispose(); + return fills; + }; + + it("saturates cells beyond the pinned ends to the end colours", () => { + const [below, atMin, middle, atMax, above] = cellFills({ + heatmap_color_min: 10, + heatmap_color_max: 20, + }); + + expect(below).toMatch(/^(#|rgb)/); + expect(above).toMatch(/^(#|rgb)/); + expect(below).toBe(atMin); + expect(above).toBe(atMax); + expect(atMin).not.toBe(atMax); + expect(middle).not.toBe(atMin); + expect(middle).not.toBe(atMax); + }); +}); + +describe("cutHeatmapToBudget", () => { + it("is 50,000 cells by default", () => { + expect(HEATMAP_MAX_CELLS).toBe(50_000); + expect(cutHeatmapToBudget(500, Array(100).fill(1))).toBeNull(); + expect(cutHeatmapToBudget(500, Array(101).fill(1))).not.toBeNull(); + }); + + it("leaves a grid that fits untouched", () => { + expect(cutHeatmapToBudget(4, [1, 2, 3], 12)).toBeNull(); + expect(cutHeatmapToBudget(0, [1, 2, 3], 1)).toBeNull(); + }); + + it("keeps every column and the floor(budget / columns) largest rows, in their original order", () => { + expect(cutHeatmapToBudget(4, [5, 50, 1, 30, 40], 12)).toEqual({ + firstColumn: 0, + rows: [1, 3, 4], + }); + }); + + it("breaks equal totals by original order", () => { + expect(cutHeatmapToBudget(2, [7, 9, 7, 7], 4)).toEqual({ firstColumn: 0, rows: [0, 1] }); + }); + + it("keeps the last budget columns and the single largest row once columns alone exceed it", () => { + expect(cutHeatmapToBudget(10, [3, 8, 8], 4)).toEqual({ firstColumn: 6, rows: [1] }); + }); +}); + +describe("heatmap warnings", () => { + it("says how many rows a row cut kept", () => { + expect(heatmapBudgetWarning({ firstColumn: 0, rows: Array(400).fill(0) }, 125, 1250)).toBe( + "Showing 400 of 1,250 rows to stay within the heatmap cell limit", + ); + }); + + it("says how many columns and rows a column cut kept", () => { + expect(heatmapBudgetWarning({ firstColumn: 10_000, rows: [3] }, 60_000, 4)).toBe( + "Showing the last 50,000 of 60,000 columns and 1 of 4 rows to stay within the heatmap cell limit", + ); + }); + + it("appends to an existing panel warning instead of replacing it", () => { + const extras: any = {}; + addHeatmapWarning(extras, "First"); + addHeatmapWarning(extras, "Second"); + expect(extras.limitNumberOfSeriesWarningMessage).toBe("First. Second"); + expect(() => addHeatmapWarning(undefined, "x")).not.toThrow(); + }); +}); + +describe("visualMapRangeOf", () => { + it("floors at 0 for non-negative data and follows the data below 0", () => { + expect(visualMapRangeOf(3, 9)).toEqual({ min: 0, max: 9 }); + expect(visualMapRangeOf(-9, -3)).toEqual({ min: -9, max: -3 }); + expect(visualMapRangeOf(Infinity, -Infinity)).toEqual({ min: 0, max: 0 }); + }); +}); + +describe("heatmapColorScale", () => { + const range = { min: 0, max: 100 }; + const stopsOf = (config: Record, cellSets: any[][] = []) => + heatmapColorScale(config, range, cellSets).color; + + it("is the square-root-warped Spectral ramp over the given range when nothing is configured", () => { + const scale = heatmapColorScale({}, range, []); + expect(scale.min).toBe(0); + expect(scale.max).toBe(100); + expect([scale.color[1], scale.color[10], scale.color[20], scale.color[39]]).toEqual([ + "#50aaaf", + "#ffffbf", + "#fca95f", + "#a50944", + ]); + expect(stopsOf({ heatmap_color_scale: "sqrt" })).toEqual(scale.color); + expect(stopsOf({ heatmap_color_scale: "unknown" })).toEqual(scale.color); + }); + + it.each(Object.keys(HEATMAP_PALETTES))("runs %s from its first stop to its last", (id) => { + const stops = stopsOf({ heatmap_palette: id }); + expect(stops).toHaveLength(HEATMAP_STOP_COUNT); + expect(stops[0]).toBe(HEATMAP_PALETTES[id][0]); + expect(stops.at(-1)).toBe(HEATMAP_PALETTES[id].at(-1)); + }); + + it("offers the eight palettes, each a valid hex ramp", () => { + expect(Object.keys(HEATMAP_PALETTES)).toEqual([ + "spectral", + "viridis", + "magma", + "inferno", + "blues", + "greens", + "reds", + "rd-yl-bu", + ]); + for (const stops of Object.values(HEATMAP_PALETTES)) { + expect(stops.length).toBeGreaterThan(2); + for (const stop of stops) expect(stop).toMatch(/^#[0-9a-f]{6}$/); + } + }); + + it("falls back to Spectral for an unknown palette id", () => { + expect(stopsOf({ heatmap_palette: "nope" })).toEqual(stopsOf({})); + }); + + it("reverses the ramp before the scale warp, so the low end stays stretched", () => { + const stops = stopsOf({ heatmap_palette: "viridis", heatmap_palette_reverse: true }); + expect(stops[0]).toBe("#fde725"); + expect(stops.at(-1)).toBe("#440154"); + const reversed = [...HEATMAP_PALETTES.viridis].reverse(); + expect(stops[1]).toBe(samplePalette(reversed, Math.sqrt(1 / (HEATMAP_STOP_COUNT - 1)))); + }); + + it("samples a linear scale at the stop's own position", () => { + const stops = stopsOf({ heatmap_color_scale: "linear" }); + expect(stops[10]).toBe(samplePalette(HEATMAP_PALETTES.spectral, 10 / 40)); + expect(stops[10]).not.toBe(stopsOf({})[10]); + }); + + it("runs a log scale from the smallest positive cell to max", () => { + const cells = [ + [0, 0, 0], + [1, 0, 1], + [2, 0, "-"], + [3, 0, 100], + ]; + const scale = heatmapColorScale({ heatmap_color_scale: "log" }, range, [cells]); + expect(scale.min).toBe(1); + expect(scale.max).toBe(100); + expect(scale.color[0]).toBe("#5e4fa2"); + expect(scale.color.at(-1)).toBe("#9e0142"); + // Stop 20 sits at value 50.5 on the [1, 100] bar. + expect(scale.color[20]).toBe( + samplePalette(HEATMAP_PALETTES.spectral, Math.log(50.5) / Math.log(100)), + ); + }); + + it("puts every value at or below the log floor under the bar, so it takes the lowest colour", () => { + const scale = heatmapColorScale({ heatmap_color_scale: "log" }, { min: -40, max: 40 }, [ + [[0, 0, 4]], + ]); + expect(scale.min).toBe(4); + expect(scale.max).toBe(40); + }); + + it("uses a pinned min above the smallest cell as the log floor", () => { + const scale = heatmapColorScale({ heatmap_color_scale: "log" }, { min: 10, max: 1000 }, [ + [[0, 0, 1]], + ]); + expect(scale.min).toBe(10); + expect(scale.color[1]).toBe( + samplePalette(HEATMAP_PALETTES.spectral, Math.log(34.75 / 10) / Math.log(100)), + ); + }); + + it("falls back to linear when no cell is positive", () => { + const negative = { min: -5, max: 0 }; + expect(heatmapColorScale({ heatmap_color_scale: "log" }, negative, [[[0, 0, -3]]])).toEqual( + heatmapColorScale({ heatmap_color_scale: "linear" }, negative, []), + ); + }); +}); + +describe("colour scales rendered by ECharts", () => { + const cellFills = (config: Record, values: number[]) => { + const options: any = new HeatmapConverter().convert( + [ + { + timestamps: values.map((_v, i) => [i, `00:0${i}:00`]), + series: [ + { + name: "s", + values: [], + data: Object.fromEntries(values.map((v, i) => [i, String(v)])), + }, + ], + queryIndex: 0, + } as any, + ], + { type: "heatmap", config }, + { state: { theme: "light" } }, + { legends: [] }, + ); + const chart = echarts.init(null as any, null, { + renderer: "svg", + ssr: true, + width: 400, + height: 300, + }); + chart.setOption({ animation: false, ...options, tooltip: undefined }); + const data = (chart as any).getModel().getSeriesByIndex(0).getData(); + const fills = values.map((_v, i) => data.getItemVisual(i, "style").fill); + chart.dispose(); + return fills; + }; + + it("draws the same low cell differently under each scale", () => { + const values = [0, 40, 1000]; + const low = (scale: string) => cellFills({ heatmap_color_scale: scale }, values)[1]; + expect(new Set([low("linear"), low("sqrt"), low("log")]).size).toBe(3); + }); + + it("gives a zero cell the lowest colour on a log scale", () => { + const [negative, zero, smallest, middle] = cellFills( + { heatmap_color_scale: "log" }, + [-3, 0, 1, 50, 1000], + ); + expect(zero).toBe(smallest); + expect(negative).toBe(smallest); + expect(middle).not.toBe(smallest); + }); + + it("paints the chosen palette's ends", () => { + const [low, high] = cellFills({ heatmap_palette: "blues" }, [0, 10]); + expect([low, high]).toEqual(["rgba(247,251,255,1)", "rgba(8,48,107,1)"]); + }); +}); + +describe("cutHistogramsToBudget", () => { + it("leaves a stack that fits untouched", () => { + expect(cutHistogramsToBudget(10, [5, 5], [1, 2], 100)).toBeNull(); + }); + + it("drops the smallest histograms first and keeps the rest in their original order", () => { + expect(cutHistogramsToBudget(10, [4, 4, 4], [9, 1, 5], 80)).toEqual({ + groups: [0, 2], + firstColumn: 0, + }); + }); + + it("keeps every bucket of the one histogram left and cuts its oldest columns", () => { + expect(cutHistogramsToBudget(120, [500], [1], 50_000)).toEqual({ + groups: [0], + firstColumn: 20, + }); + }); + + it("drops histograms before cutting columns", () => { + expect(cutHistogramsToBudget(100, [300, 300], [1, 2], 20_000)).toEqual({ + groups: [1], + firstColumn: 34, + }); + }); + + it("keeps one column when even that exceeds the budget", () => { + expect(cutHistogramsToBudget(5, [200], [1], 100)).toEqual({ groups: [0], firstColumn: 4 }); + }); + + it("warns about dropped histograms and cut columns", () => { + expect(histogramBudgetWarnings({ groups: [1], firstColumn: 34 }, 100, 2)).toEqual([ + "Showing the 1 busiest of 2 histograms to stay within the heatmap cell limit", + "Showing the last 66 of 100 columns to stay within the heatmap cell limit", + ]); + expect(histogramBudgetWarnings({ groups: [0, 1], firstColumn: 0 }, 100, 2)).toEqual([]); + }); +}); diff --git a/web/src/utils/dashboard/heatmapDefaults.ts b/web/src/utils/dashboard/heatmapDefaults.ts index 4bfb66b08b..7ad41acf15 100644 --- a/web/src/utils/dashboard/heatmapDefaults.ts +++ b/web/src/utils/dashboard/heatmapDefaults.ts @@ -20,14 +20,21 @@ * Prometheus-histogram mode, and the SQL converter — and share these defaults so * a heatmap looks like a heatmap wherever it appears. * - * This is presentation only. It says nothing about how the data is prepared: + * It also holds the cell budget, colour range/scale and warning helpers, but never prepares data: * the classic-histogram `le`-sort and de-accumulation stay opt-in behind * `config.heatmap_mode === "prometheus_histogram"`, because applying them to an * arbitrary heatmap would corrupt it. */ -import { SPECTRAL_HEATMAP_STOPS } from "./promql/shared/spectral"; +import { + DEFAULT_HEATMAP_PALETTE, + HEATMAP_PALETTES, + HEATMAP_STOP_COUNT, + buildColorStops, +} from "./heatmapPalettes"; import { chartColor } from "@/utils/chartTheme"; +import { gt } from "@/types/i18n"; +import { addCommasToNumber } from "@/utils/formatters"; /** * Stroke every cell in the panel background colour. @@ -43,13 +50,10 @@ export function heatmapCellItemStyle(_store: any) { }; } -/** - * The Spectral ramp, cool-to-warm, with the colour-scale exponent already baked - * into the stop positions (see spectral.ts). ECharts spreads `inRange.color` - * linearly over [min, max], so the non-linear scale has to live in where each - * stop is sampled from the ramp rather than in ECharts' own config. - */ -export const HEATMAP_VISUAL_MAP_COLORS = SPECTRAL_HEATMAP_STOPS; +/** The colour scales `heatmap_color_scale` accepts; unset is square root. */ +export const HEATMAP_COLOR_SCALES = ["linear", "sqrt", "log"] as const; + +export const DEFAULT_HEATMAP_COLOR_SCALE = "sqrt"; /** * The cell borders delineate the grid; a split-area backdrop underneath them @@ -112,3 +116,219 @@ export function heatmapLargeGridDefaults(cellCount: number) { * on the panel or the query still wins — this is only the default.) */ export const HEATMAP_MAX_COLUMNS = 120; + +/** The most cells a heatmap grid holds: 120 columns by ~400 rows, past which rows shrink below a pixel on a full-size panel. */ +export const HEATMAP_MAX_CELLS = 50_000; + +/** The most rows a full-width heatmap keeps within the cell budget. */ +export const HEATMAP_MAX_ROWS = Math.floor(HEATMAP_MAX_CELLS / HEATMAP_MAX_COLUMNS); + +export interface HeatmapConfig { + heatmap_color_min?: unknown; + heatmap_color_max?: unknown; + heatmap_palette?: string | null; + heatmap_palette_reverse?: boolean | null; + heatmap_color_scale?: string | null; + heatmap_y_sort?: string | null; + heatmap_y_bucket?: { mode?: unknown; size?: unknown; base?: unknown } | null; + heatmap_y_min?: unknown; + heatmap_y_max?: unknown; +} + +export interface HeatmapGridCut { + firstColumn: number; + rows: number[]; +} + +export interface HistogramStackCut { + groups: number[]; + firstColumn: number; +} + +function pinnedColorBound(value: unknown): number | undefined { + if (value === null || value === undefined || value === "") return undefined; + const bound = Number(value); + return Number.isFinite(bound) ? bound : undefined; +} + +/** Whether both colour pins are set with min >= max, which the renderer ignores. */ +export function isHeatmapColorRangeInverted(config: HeatmapConfig | null | undefined): boolean { + const min = pinnedColorBound(config?.heatmap_color_min); + const max = pinnedColorBound(config?.heatmap_color_max); + return min !== undefined && max !== undefined && min >= max; +} + +/** The `visualMap` range with `heatmap_color_min` / `heatmap_color_max` applied over `baseRange`, ignoring pins that would leave min >= max. */ +export function heatmapColorRange( + config: HeatmapConfig | null | undefined, + baseRange: { min: number; max: number }, +): { min: number; max: number } { + const pinnedMin = pinnedColorBound(config?.heatmap_color_min); + const pinnedMax = pinnedColorBound(config?.heatmap_color_max); + if (pinnedMin === undefined && pinnedMax === undefined) return { ...baseRange }; + + const min = pinnedMin ?? baseRange.min; + const max = pinnedMax ?? baseRange.max; + if (min >= max) return { ...baseRange }; + return { min, max }; +} + +/** The `visualMap` range and `inRange.color` stops for `config`'s palette, reverse flag and colour scale over `range`; `cellSets` holds the `[x, y, value]` cells it colours. */ +export function heatmapColorScale( + config: HeatmapConfig | null | undefined, + range: { min: number; max: number }, + cellSets: any[][], +): { min: number; max: number; color: string[] } { + const palette = + HEATMAP_PALETTES[config?.heatmap_palette ?? DEFAULT_HEATMAP_PALETTE] ?? + HEATMAP_PALETTES[DEFAULT_HEATMAP_PALETTE]; + const ramp = config?.heatmap_palette_reverse ? [...palette].reverse() : palette; + const scale = config?.heatmap_color_scale ?? DEFAULT_HEATMAP_COLOR_SCALE; + + // Log runs over [max(min, smallest positive cell), max]; cells at or below that floor (so every value <= 0) fall under the bar and take its lowest colour, and with no positive span the scale is linear. + if (scale === "log") { + const floor = Math.max(range.min, smallestPositiveCell(cellSets)); + if (floor > 0 && range.max > floor) { + const logSpan = Math.log(range.max / floor); + const warp = (t: number) => Math.log((floor + t * (range.max - floor)) / floor) / logSpan; + return { min: floor, max: range.max, color: buildColorStops(ramp, HEATMAP_STOP_COUNT, warp) }; + } + } + // Square root is the variance-stabilising transform for Poisson counts, so a colour step means similar evidence along the ramp. + const warp = scale === "linear" || scale === "log" ? (t: number) => t : Math.sqrt; + return { ...range, color: buildColorStops(ramp, HEATMAP_STOP_COUNT, warp) }; +} + +/** The `visualMap` range for a set of cell values; the floor drops below 0 only for negative data, so all-negative grids are not one colour. */ +export function visualMapRangeOf(minValue: number, maxValue: number): { min: number; max: number } { + return { + min: Number.isFinite(minValue) ? Math.min(0, minValue) : 0, + max: Number.isFinite(maxValue) ? maxValue : 0, + }; +} + +/** The cut that fits a `columnCount` x `rowTotals.length` grid into `budget` cells, or null when it already fits; `contiguous` keeps one unbroken run of rows, for ordered axes such as y buckets. */ +export function cutHeatmapToBudget( + columnCount: number, + rowTotals: number[], + budget: number = HEATMAP_MAX_CELLS, + contiguous = false, +): HeatmapGridCut | null { + if (columnCount * rowTotals.length <= budget) return null; + const keep = columnCount <= budget ? Math.floor(budget / columnCount) : 1; + const rows = contiguous ? largestRowRun(rowTotals, keep) : largestRows(rowTotals, keep); + return { firstColumn: Math.max(0, columnCount - budget), rows }; +} + +export function heatmapBudgetWarning( + cut: HeatmapGridCut, + columnCount: number, + rowCount: number, +): string { + if (cut.firstColumn === 0) { + return gt("dashboard.utils.heatmapRowsCapped", { + shown: addCommasToNumber(cut.rows.length), + total: addCommasToNumber(rowCount), + }); + } + return gt("dashboard.utils.heatmapColumnsCapped", { + shownColumns: addCommasToNumber(columnCount - cut.firstColumn), + totalColumns: addCommasToNumber(columnCount), + shownRows: addCommasToNumber(cut.rows.length), + totalRows: addCommasToNumber(rowCount), + }); +} + +/** The histogram grids (indices, in input order) and first column a stack keeps within `budget`: smallest-total grids go first, then the oldest columns, never a bucket; null when it fits. */ +export function cutHistogramsToBudget( + columnCount: number, + groupRows: number[], + groupTotals: number[], + budget: number = HEATMAP_MAX_CELLS, +): HistogramStackCut | null { + const totalRows = groupRows.reduce((sum, rows) => sum + rows, 0); + if (columnCount * totalRows <= budget) return null; + + const groups: number[] = []; + let keptRows = 0; + const byTotal = groupTotals + .map((_total, index) => index) + .sort((a, b) => groupTotals[b] - groupTotals[a] || a - b); + for (const group of byTotal) { + if (groups.length > 0 && (keptRows + groupRows[group]) * columnCount > budget) break; + groups.push(group); + keptRows += groupRows[group]; + } + const keptColumns = Math.max(1, Math.floor(budget / Math.max(1, keptRows))); + return { + groups: groups.sort((a, b) => a - b), + firstColumn: Math.max(0, columnCount - keptColumns), + }; +} + +export function histogramBudgetWarnings( + cut: HistogramStackCut, + columnCount: number, + groupCount: number, +): string[] { + const warnings: string[] = []; + if (cut.groups.length < groupCount) { + warnings.push( + gt("dashboard.utils.heatmapHistogramsCapped", { + shown: addCommasToNumber(cut.groups.length), + total: addCommasToNumber(groupCount), + }), + ); + } + if (cut.firstColumn > 0) { + warnings.push( + gt("dashboard.utils.heatmapHistogramColumnsCapped", { + shown: addCommasToNumber(columnCount - cut.firstColumn), + total: addCommasToNumber(columnCount), + }), + ); + } + return warnings; +} + +/** Adds `message` to the panel's warning, which the header shows through the series-limit notice. */ +export function addHeatmapWarning(extras: any, message: string): void { + if (!extras) return; + const existing = extras.limitNumberOfSeriesWarningMessage; + extras.limitNumberOfSeriesWarningMessage = existing ? `${existing}. ${message}` : message; +} + +function smallestPositiveCell(cellSets: any[][]): number { + let smallest = Infinity; + for (const cells of cellSets) { + for (const cell of cells) { + const value = Number(cell?.[2]); + if (value > 0 && value < smallest) smallest = value; + } + } + return smallest; +} + +/** The `keep` adjacent rows with the largest summed total, earliest run on a tie. */ +function largestRowRun(rowTotals: number[], keep: number): number[] { + let sum = 0; + let bestSum = -Infinity; + let bestStart = 0; + for (let index = 0; index < rowTotals.length; index++) { + sum += rowTotals[index]; + if (index >= keep) sum -= rowTotals[index - keep]; + if (index >= keep - 1 && sum > bestSum) { + bestSum = sum; + bestStart = index - keep + 1; + } + } + return Array.from({ length: Math.min(keep, rowTotals.length) }, (_unused, i) => bestStart + i); +} + +function largestRows(rowTotals: number[], keep: number): number[] { + return rowTotals + .map((_total, index) => index) + .sort((a, b) => rowTotals[b] - rowTotals[a] || a - b) + .slice(0, keep) + .sort((a, b) => a - b); +} diff --git a/web/src/utils/dashboard/heatmapPalettes.ts b/web/src/utils/dashboard/heatmapPalettes.ts new file mode 100644 index 0000000000..66b72ecf64 --- /dev/null +++ b/web/src/utils/dashboard/heatmapPalettes.ts @@ -0,0 +1,190 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/** Heatmap colour ramps, each listed low to high, and the sampler that turns one into `visualMap` stops. */ + +// ColorBrewer ramps (Apache 2.0, colorbrewer2.org); diverging ones listed cool-first so hot reads as "more". +const SPECTRAL = [ + "#5e4fa2", + "#3288bd", + "#66c2a5", + "#abdda4", + "#e6f598", + "#ffffbf", + "#fee08b", + "#fdae61", + "#f46d43", + "#d53e4f", + "#9e0142", +]; + +const RD_YL_BU = [ + "#313695", + "#4575b4", + "#74add1", + "#abd9e9", + "#e0f3f8", + "#ffffbf", + "#fee090", + "#fdae61", + "#f46d43", + "#d73027", + "#a50026", +]; + +const BLUES = [ + "#f7fbff", + "#deebf7", + "#c6dbef", + "#9ecae1", + "#6baed6", + "#4292c6", + "#2171b5", + "#08519c", + "#08306b", +]; + +const GREENS = [ + "#f7fcf5", + "#e5f5e0", + "#c7e9c0", + "#a1d99b", + "#74c476", + "#41ab5d", + "#238b45", + "#006d2c", + "#00441b", +]; + +const REDS = [ + "#fff5f0", + "#fee0d2", + "#fcbba1", + "#fc9272", + "#fb6a4a", + "#ef3b2c", + "#cb181d", + "#a50f15", + "#67000d", +]; + +// Matplotlib perceptually uniform maps (CC0), sampled at tenths. +const VIRIDIS = [ + "#440154", + "#482475", + "#414487", + "#355f8d", + "#2a788e", + "#21918c", + "#22a884", + "#44bf70", + "#7ad151", + "#bddf26", + "#fde725", +]; + +const MAGMA = [ + "#000004", + "#140e36", + "#3b0f70", + "#641a80", + "#8c2981", + "#b73779", + "#de4968", + "#f7705c", + "#fe9f6d", + "#fecf92", + "#fcfdbf", +]; + +const INFERNO = [ + "#000004", + "#160b39", + "#420a68", + "#6a176e", + "#932667", + "#bc3754", + "#dd513a", + "#f37819", + "#fca50a", + "#f6d746", + "#fcffa4", +]; + +export const HEATMAP_PALETTES: Readonly> = { + spectral: SPECTRAL, + viridis: VIRIDIS, + magma: MAGMA, + inferno: INFERNO, + blues: BLUES, + greens: GREENS, + reds: REDS, + "rd-yl-bu": RD_YL_BU, +}; + +export const DEFAULT_HEATMAP_PALETTE = "spectral"; + +/** Enough stops for a smooth gradient without more than a colour bar can distinguish. */ +export const HEATMAP_STOP_COUNT = 41; + +/** The colour at position `t` (clamped to [0, 1]) along `stops`, interpolated in RGB. */ +export function samplePalette(stops: readonly string[], t: number): string { + if (!Number.isFinite(t)) return stops[0]; + + const scaled = Math.max(0, Math.min(1, t)) * (stops.length - 1); + const lowerIndex = Math.floor(scaled); + const upperIndex = Math.min(lowerIndex + 1, stops.length - 1); + const fraction = scaled - lowerIndex; + + const lower = hexToRgb(stops[lowerIndex]); + const upper = hexToRgb(stops[upperIndex]); + return rgbToHex([ + lower[0] + (upper[0] - lower[0]) * fraction, + lower[1] + (upper[1] - lower[1]) * fraction, + lower[2] + (upper[2] - lower[2]) * fraction, + ]); +} + +/** `count` stops for a `visualMap`, stop k sampled from `stops` at `warp(k / (count - 1))`; ECharts spreads stops linearly, so a colour scale lives in `warp`. */ +export function buildColorStops( + stops: readonly string[], + count: number, + warp: (t: number) => number, +): string[] { + const steps = Math.max(2, Math.floor(count)); + return Array.from({ length: steps }, (_unused, k) => samplePalette(stops, warp(k / (steps - 1)))); +} + +function hexToRgb(hex: string): [number, number, number] { + const value = hex.replace("#", ""); + return [ + parseInt(value.slice(0, 2), 16), + parseInt(value.slice(2, 4), 16), + parseInt(value.slice(4, 6), 16), + ]; +} + +function rgbToHex(rgb: [number, number, number]): string { + return ( + "#" + + rgb + .map((channel) => + Math.max(0, Math.min(255, Math.round(channel))) + .toString(16) + .padStart(2, "0"), + ) + .join("") + ); +} diff --git a/web/src/utils/dashboard/heatmapTimeAxis.spec.ts b/web/src/utils/dashboard/heatmapTimeAxis.spec.ts new file mode 100644 index 0000000000..63e4dd011d --- /dev/null +++ b/web/src/utils/dashboard/heatmapTimeAxis.spec.ts @@ -0,0 +1,279 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it, vi } from "vitest"; +import * as echarts from "echarts"; +import { + applyHeatmapTimePointer, + heatmapColumnAt, + heatmapHoverTime, + heatmapSyncPointer, + heatmapZoomRange, + heatmapZoomToolbox, + promqlHeatmapTimeAxis, + sqlHeatmapTimeAxis, +} from "./heatmapTimeAxis"; +import { toChartTimeMs } from "./exemplars/applyExemplarSeries"; + +const MIN = 60_000; +const T0 = Date.UTC(2026, 0, 1, 10, 0, 0); +const chart = (ms: number) => toChartTimeMs(ms, "UTC"); + +describe("sqlHeatmapTimeAxis", () => { + it("spans each bucket from its start to start + interval, clipped to the query range", () => { + const axis = sqlHeatmapTimeAxis( + [T0, T0 + MIN, T0 + 2 * MIN], + MIN, + [T0 + 30_000, T0 + 150_000], + "UTC", + ); + expect(axis?.endInclusive).toBe(false); + expect(axis?.columns.map(({ start, end }) => [start, end])).toEqual([ + [chart(T0 + 30_000), chart(T0 + MIN)], + [chart(T0 + MIN), chart(T0 + 2 * MIN)], + [chart(T0 + 2 * MIN), chart(T0 + 150_000)], + ]); + }); + + it("reports each column as the zoned Date a SQL time-series panel plots its bucket at", () => { + const axis = sqlHeatmapTimeAxis([T0], MIN, [T0, T0 + MIN], "UTC"); + expect(axis?.columns[0].x).toBeInstanceOf(Date); + expect((axis?.columns[0].x as Date).getTime()).toBe(chart(T0)); + }); + + it("has no time axis without columns or a known interval", () => { + expect(sqlHeatmapTimeAxis([], MIN, [T0, T0 + MIN], "UTC")).toBeNull(); + expect(sqlHeatmapTimeAxis([T0], NaN, [T0, T0 + MIN], "UTC")).toBeNull(); + }); +}); + +describe("promqlHeatmapTimeAxis", () => { + const sec = T0 / 1000; + const stamps: Array<[number, string]> = [ + [sec, "a"], + [sec + 60, "b"], + [sec + 120, "c"], + ]; + + it("covers the step before each evaluation instant, clipped to the query start", () => { + const axis = promqlHeatmapTimeAxis(stamps, 60, sec - 30, "UTC"); + expect(axis?.endInclusive).toBe(true); + expect(axis?.columns.map(({ start, end }) => [start, end])).toEqual([ + [chart(T0 - 30_000), chart(T0)], + [chart(T0), chart(T0 + MIN)], + [chart(T0 + MIN), chart(T0 + 2 * MIN)], + ]); + expect(axis?.columns.map((column) => column.x)).toEqual(["a", "b", "c"]); + }); + + it("falls back to the smallest gap between instants when the step is unknown", () => { + const axis = promqlHeatmapTimeAxis(stamps, NaN, NaN, "UTC"); + expect(axis?.columns[1].start).toBe(chart(T0)); + }); + + it("has no time axis without instants", () => { + expect(promqlHeatmapTimeAxis([], 60, sec, "UTC")).toBeNull(); + }); +}); + +describe("heatmapColumnAt", () => { + const sqlAxis = sqlHeatmapTimeAxis([T0, T0 + MIN], MIN, [T0, T0 + 2 * MIN], "UTC"); + const promAxis = promqlHeatmapTimeAxis( + [ + [T0 / 1000, "a"], + [T0 / 1000 + 60, "b"], + ], + 60, + T0 / 1000 - 60, + "UTC", + ); + + it("puts a SQL bucket's start in that bucket and its end in the next", () => { + expect(heatmapColumnAt(sqlAxis, chart(T0))).toBe(0); + expect(heatmapColumnAt(sqlAxis, chart(T0 + MIN - 1))).toBe(0); + expect(heatmapColumnAt(sqlAxis, chart(T0 + MIN))).toBe(1); + }); + + it("puts a PromQL instant in its own column and the moment after it in the next", () => { + expect(heatmapColumnAt(promAxis, chart(T0))).toBe(0); + expect(heatmapColumnAt(promAxis, chart(T0 + 1))).toBe(1); + expect(heatmapColumnAt(promAxis, chart(T0 + MIN))).toBe(1); + }); + + it("finds no column outside the drawn ones", () => { + expect(heatmapColumnAt(sqlAxis, chart(T0 - 1))).toBe(-1); + expect(heatmapColumnAt(sqlAxis, chart(T0 + 2 * MIN))).toBe(-1); + expect(heatmapColumnAt(promAxis, chart(T0 - MIN))).toBe(-1); + expect(heatmapColumnAt(promAxis, chart(T0 + MIN + 1))).toBe(-1); + expect(heatmapColumnAt(null, chart(T0))).toBe(-1); + expect(heatmapColumnAt(sqlAxis, NaN)).toBe(-1); + }); +}); + +describe("heatmapZoomRange", () => { + const axis = sqlHeatmapTimeAxis([T0, T0 + MIN, T0 + 2 * MIN], MIN, [T0, T0 + 3 * MIN], "UTC"); + + it("maps selected column indices to the first column's start and the last column's end", () => { + expect(heatmapZoomRange(axis, 1, 2)).toEqual({ + start: chart(T0 + MIN), + end: chart(T0 + 3 * MIN), + }); + }); + + it("rounds fractional indices, clamps them to the columns and accepts a reversed box", () => { + expect(heatmapZoomRange(axis, 2.4, -3)).toEqual({ start: chart(T0), end: chart(T0 + 3 * MIN) }); + expect(heatmapZoomRange(axis, 0.6, 0.6)).toEqual({ + start: chart(T0 + MIN), + end: chart(T0 + 2 * MIN), + }); + }); + + it("has no range without time columns", () => { + expect(heatmapZoomRange(undefined, 0, 1)).toBeNull(); + expect(heatmapZoomRange(axis, NaN, 1)).toBeNull(); + }); +}); + +describe("cursor sync", () => { + const axis = promqlHeatmapTimeAxis( + [ + [T0 / 1000, "a"], + [T0 / 1000 + 60, "b"], + ], + 60, + T0 / 1000 - 60, + "UTC", + ); + + it("reports the hovered heatmap cell's column time, and nothing for another series", () => { + expect(heatmapHoverTime(axis, { seriesType: "heatmap", value: [1, 0, 5] })).toBe("b"); + expect(heatmapHoverTime(axis, { seriesType: "scatter", value: [1, 0] })).toBeUndefined(); + expect(heatmapHoverTime(null, { seriesType: "heatmap", value: [1, 0, 5] })).toBeUndefined(); + }); + + it("moves the axis pointer to the column holding the hovered time, as a Date or a zone-less string", () => { + const pixelOf = (column: number) => [column * 10 + 5, 50]; + expect(heatmapSyncPointer(axis, new Date(chart(T0 + MIN)), pixelOf)).toEqual({ + type: "updateAxisPointer", + currTrigger: "mousemove", + x: 15, + y: 50, + }); + expect(heatmapSyncPointer(axis, "2026-01-01T10:00:00.000", pixelOf)).toMatchObject({ x: 5 }); + }); + + it("clears the pointer off the drawn columns or while the chart cannot place it", () => { + const leave = { type: "updateAxisPointer", currTrigger: "leave" }; + expect(heatmapSyncPointer(axis, new Date(chart(T0 + 5 * MIN)), () => [1, 1])).toEqual(leave); + expect(heatmapSyncPointer(axis, new Date(chart(T0)), () => undefined)).toEqual(leave); + expect(heatmapSyncPointer(axis, new Date(chart(T0)), () => [NaN, 1])).toEqual(leave); + }); +}); + +describe("heatmapZoomToolbox", () => { + it("arms the x-only zoom brush with its buttons hidden", () => { + const toolbox = heatmapZoomToolbox() as any; + expect(toolbox.show).toBe(true); + expect(toolbox.itemSize).toBe(0); + expect(toolbox.feature.dataZoom.yAxisIndex).toBe("none"); + }); +}); + +describe("applyHeatmapTimePointer", () => { + it("draws the cursor on every x axis only, linked across grids, without a tooltip cross", () => { + const options: any = { + tooltip: { axisPointer: { type: "cross" }, formatter: () => "" }, + xAxis: [{ type: "category" }, { type: "category" }], + yAxis: [{ type: "category" }, { type: "category" }], + }; + applyHeatmapTimePointer(options); + expect(options.tooltip.axisPointer).toBeUndefined(); + expect( + options.xAxis.map((a: any) => [a.axisPointer.show, a.axisPointer.triggerTooltip]), + ).toEqual([ + [true, false], + [true, false], + ]); + expect(options.yAxis.every((a: any) => a.axisPointer === undefined)).toBe(true); + expect(options.axisPointer).toEqual({ link: [{ xAxisIndex: "all" }] }); + }); + + it("draws the cursor on a layer of its own, so moving it does not repaint the cells", () => { + const options: any = { tooltip: {}, xAxis: [{}, {}], yAxis: {} }; + applyHeatmapTimePointer(options); + expect(options.xAxis.map((a: any) => a.axisPointer.zlevel)).toEqual([1, 1]); + }); + + it("links nothing for a single grid", () => { + const options: any = { tooltip: {}, xAxis: [{}], yAxis: {} }; + applyHeatmapTimePointer(options); + expect(options.axisPointer).toBeUndefined(); + }); + + it("moves the cursor line on every grid without scanning the cells of any series", () => { + const columns = Array.from({ length: 120 }, (_unused, index) => `c${index}`); + const cells = Array.from({ length: 120 * 10 }, (_unused, index) => [ + index % 120, + Math.floor(index / 120), + 1, + ]); + const render = (scanSeries: boolean) => { + const options: any = { + animation: false, + visualMap: { show: false, min: 0, max: 1 }, + grid: [ + { top: 0, height: 100 }, + { top: 120, height: 100 }, + ], + xAxis: [0, 1].map((gridIndex) => ({ type: "category", gridIndex, data: columns })), + yAxis: [0, 1].map((gridIndex) => ({ + type: "category", + gridIndex, + data: [...Array(10).keys()], + })), + series: [0, 1].map((index) => ({ + type: "heatmap", + xAxisIndex: index, + yAxisIndex: index, + data: cells, + })), + }; + applyHeatmapTimePointer(options); + if (scanSeries) for (const series of options.series) delete series.axisPointer; + const chart = echarts.init(null as any, null, { + renderer: "svg", + ssr: true, + width: 600, + height: 240, + }); + chart.setOption(options); + const model = (chart as any).getModel(); + const scans = [0, 1].map((index) => + vi.spyOn(model.getSeriesByIndex(index), "indicesOfNearest"), + ); + chart.dispatchAction({ type: "updateAxisPointer", currTrigger: "mousemove", x: 300, y: 50 }); + const axesInfo = model.getComponent("axisPointer").coordSysAxesInfo.axesInfo; + const pointers = Object.values(axesInfo).map( + (info: any) => info.axisPointerModel.option.status, + ); + const scanned = scans.reduce((count, scan) => count + scan.mock.calls.length, 0); + chart.dispose(); + return { pointers, scanned }; + }; + + expect(render(true).scanned).toBeGreaterThan(0); + expect(render(false)).toEqual({ pointers: ["show", "show"], scanned: 0 }); + }); +}); diff --git a/web/src/utils/dashboard/heatmapTimeAxis.ts b/web/src/utils/dashboard/heatmapTimeAxis.ts new file mode 100644 index 0000000000..193a48c6dc --- /dev/null +++ b/web/src/utils/dashboard/heatmapTimeAxis.ts @@ -0,0 +1,205 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { toZonedTime } from "date-fns-tz"; +import { dataZoomBrushStyle } from "@/utils/chartTheme"; +import { toChartTimeMs } from "./exemplars/applyExemplarSeries"; + +/** A column's span in the zoned wall-clock ms that time-series panels plot at, and the x a time-series panel would report for it. */ +export interface HeatmapTimeColumn { + start: number; + end: number; + x: unknown; +} + +/** A heatmap's time x axis, columns ascending; `endInclusive` columns cover (start, end], the others [start, end). */ +export interface HeatmapTimeAxis { + columns: HeatmapTimeColumn[]; + endInclusive: boolean; +} + +/** SQL `histogram(_timestamp)` buckets, each covering [bucket start, start + interval) clipped to the query range. */ +export function sqlHeatmapTimeAxis( + bucketStartsMs: number[], + intervalMs: number, + rangeMs: [number, number], + timezone: string, +): HeatmapTimeAxis | null { + if (!bucketStartsMs.length || !(intervalMs > 0)) return null; + const [rangeStart, rangeEnd] = rangeMs; + return { + endInclusive: false, + columns: bucketStartsMs.map((ms) => { + const start = Number.isFinite(rangeStart) ? Math.max(ms, rangeStart) : ms; + const end = Number.isFinite(rangeEnd) ? Math.min(ms + intervalMs, rangeEnd) : ms + intervalMs; + return { + start: toChartTimeMs(start, timezone), + end: toChartTimeMs(Math.max(end, start), timezone), + x: toZonedTime(ms, timezone), + }; + }), + }; +} + +/** PromQL evaluation instants; a sample at `ts` covers the step before it, (ts - step, ts], clipped to the query start. */ +export function promqlHeatmapTimeAxis( + timestamps: Array<[any, any]>, + stepSec: number, + rangeStartSec: number, + timezone: string, +): HeatmapTimeAxis | null { + if (!timestamps.length) return null; + const step = stepSec > 0 ? stepSec : smallestGap(timestamps.map(([ts]) => Number(ts))); + return { + endInclusive: true, + columns: timestamps.map(([ts, formatted]) => { + const end = Number(ts); + const start = Number.isFinite(rangeStartSec) + ? Math.max(end - step, rangeStartSec) + : end - step; + return { + start: toChartTimeMs(Math.min(start, end) * 1000, timezone), + end: toChartTimeMs(end * 1000, timezone), + x: formatted, + }; + }), + }; +} + +/** The index of the column holding chart time `t`, or -1; an `endInclusive` axis also matches `t === end`. */ +export function heatmapColumnAt(axis: HeatmapTimeAxis | null | undefined, t: number): number { + const columns = axis?.columns ?? []; + if (!Number.isFinite(t) || !columns.length) return -1; + let lo = 0; + let hi = columns.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const after = axis!.endInclusive ? columns[mid].end >= t : columns[mid].end > t; + if (after) { + found = mid; + hi = mid - 1; + } else lo = mid + 1; + } + if (found < 0) return -1; + const { start, end } = columns[found]; + const inside = axis!.endInclusive ? t > start || t === end : t >= start; + return inside ? found : -1; +} + +/** A zoom box's category indices as the time range from the start of its first column to the end of its last. */ +export function heatmapZoomRange( + axis: HeatmapTimeAxis | null | undefined, + startIndex: number, + endIndex: number, +): { start: number; end: number } | null { + const count = axis?.columns.length ?? 0; + if (!count || !Number.isFinite(startIndex) || !Number.isFinite(endIndex)) return null; + const clamp = (index: number) => Math.min(Math.max(Math.round(index), 0), count - 1); + const first = clamp(Math.min(startIndex, endIndex)); + const last = clamp(Math.max(startIndex, endIndex)); + return { start: axis!.columns[first].start, end: axis!.columns[last].end }; +} + +/** The one time axis every tile shares, or null when any tile has none or its columns differ. */ +export function sharedHeatmapTimeAxis( + axes: Array, +): HeatmapTimeAxis | null { + const [first, ...rest] = axes; + if (!first) return null; + const same = (axis: HeatmapTimeAxis | null | undefined) => + !!axis && + axis.endInclusive === first.endInclusive && + axis.columns.length === first.columns.length && + axis.columns.every( + (column, index) => + column.start === first.columns[index].start && column.end === first.columns[index].end, + ); + return rest.every(same) ? first : null; +} + +/** The x a time-series panel would report for the hovered heatmap cell, or undefined off a heatmap cell. */ +export function heatmapHoverTime( + axis: HeatmapTimeAxis | null | undefined, + params: { seriesType?: string; value?: unknown }, +): unknown { + if (!axis || params?.seriesType !== "heatmap" || !Array.isArray(params.value)) return undefined; + return axis.columns[Number(params.value[0])]?.x; +} + +/** The axis-pointer action that draws the cursor at the column holding the time another panel hovers; a tooltip would show an empty box there. */ +export function heatmapSyncPointer( + axis: HeatmapTimeAxis | null | undefined, + hoveredTime: unknown, + pixelOfColumn: (column: number) => number[] | null | undefined, +): Record { + const column = heatmapColumnAt(axis, new Date(hoveredTime as string | number | Date).getTime()); + const pixel = column < 0 ? null : pixelOfColumn(column); + if (!pixel || !Number.isFinite(pixel[0]) || !Number.isFinite(pixel[1])) { + return { type: "updateAxisPointer", currTrigger: "leave" }; + } + return { type: "updateAxisPointer", currTrigger: "mousemove", x: pixel[0], y: pixel[1] }; +} + +/** Puts the hover cursor on the x axes only, linked across stacked grids, as a bare line; a pointer label snaps by pixel and can name a different cell than the tooltip. */ +export function applyHeatmapTimePointer(options: any): void { + if (options.tooltip) delete options.tooltip.axisPointer; + const xAxes = Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]; + for (const axis of xAxes) { + if (!axis) continue; + axis.axisPointer = { + show: true, + // Its own canvas layer, so moving the cursor line does not repaint every cell under it. + zlevel: 1, + type: "line", + triggerTooltip: false, + label: { show: false }, + }; + } + if (xAxes.length > 1) options.axisPointer = { link: [{ xAxisIndex: "all" }] }; + // Otherwise every pointer move scans each cell of every series on a category axis for a snap target the line never uses. + for (const series of [options.series ?? []].flat()) { + if (series) series.axisPointer = { show: false }; + } +} + +/** The hidden toolbox that arms drag-to-zoom along x, as the time-series panels configure it. */ +export function heatmapZoomToolbox(): Record { + return { + orient: "vertical", + show: true, + showTitle: false, + tooltip: { show: false }, + itemSize: 0, + itemGap: 0, + bottom: "100%", + feature: { + dataZoom: { + brushStyle: dataZoomBrushStyle(), + filterMode: "none", + yAxisIndex: "none", + }, + }, + }; +} + +function smallestGap(timestamps: number[]): number { + let gap = Infinity; + for (let i = 1; i < timestamps.length; i++) { + const delta = timestamps[i] - timestamps[i - 1]; + if (delta > 0 && delta < gap) gap = delta; + } + return Number.isFinite(gap) ? gap : 0; +} diff --git a/web/src/utils/dashboard/heatmapYAxis.spec.ts b/web/src/utils/dashboard/heatmapYAxis.spec.ts new file mode 100644 index 0000000000..b5d2e9e871 --- /dev/null +++ b/web/src/utils/dashboard/heatmapYAxis.spec.ts @@ -0,0 +1,280 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, expect, it } from "vitest"; +import { + HEATMAP_Y_NON_POSITIVE_LABEL, + fillHeatmapYBuckets, + heatmapYBucketCount, + heatmapYBucketError, + heatmapYBucketInRange, + heatmapYBucketLabel, + heatmapYBucketSql, + heatmapYBucketing, + heatmapYRangeOf, + heatmapYRangeSql, + isHeatmapYRangeInverted, + isNumericYField, + isHeatmapYBucketLowerBound, + isPlainYField, + sortHeatmapCategories, +} from "@/utils/dashboard/heatmapYAxis"; +import { HEATMAP_MAX_ROWS } from "@/utils/dashboard/heatmapDefaults"; + +const identity = (value: unknown) => value; + +describe("fillHeatmapYBuckets", () => { + it("adds every empty bucket between the smallest and largest, keeping returned values as given", () => { + expect( + fillHeatmapYBuckets([30, 0], { mode: "size", size: 10 }, 100).sort((a, b) => a - b), + ).toEqual([0, 10, 20, 30]); + expect( + fillHeatmapYBuckets([0.30000000000000004, 0.1], { mode: "size", size: 0.1 }, 100), + ).toEqual([0.1, 0.2, 0.30000000000000004]); + }); + + it("fills log buckets by powers of the base and keeps the <= 0 bucket", () => { + expect(fillHeatmapYBuckets([1000, 0, 1], { mode: "log", base: 10 }, 100)).toEqual([ + 0, 1, 10, 100, 1000, + ]); + }); + + it("leaves the values alone when filling would pass the row cap", () => { + expect(fillHeatmapYBuckets([0, 1000], { mode: "size", size: 1 }, 50)).toEqual([0, 1000]); + }); +}); + +describe("sortHeatmapCategories", () => { + it.each([undefined, null, "data", "bogus"])("keeps data order for %s", (order) => { + expect(sortHeatmapCategories(["b", "a", "c"], identity, order)).toEqual(["b", "a", "c"]); + }); + + it("sorts numerically when every key is a finite number, including numeric strings", () => { + expect(sortHeatmapCategories(["10", 9, "100", -1, " 2 "], identity, "asc")).toEqual([ + -1, + " 2 ", + 9, + "10", + "100", + ]); + expect(sortHeatmapCategories([10, 9, 100], identity, "desc")).toEqual([100, 10, 9]); + }); + + it("falls back to string compare when any key is not a number", () => { + expect(sortHeatmapCategories(["10", "9", "x"], identity, "asc")).toEqual(["10", "9", "x"]); + expect(sortHeatmapCategories(["b", "a", "c"], identity, "desc")).toEqual(["c", "b", "a"]); + }); + + it("treats an empty string as text, not as 0", () => { + expect(sortHeatmapCategories(["2", "", "1"], identity, "asc")).toEqual(["", "1", "2"]); + }); + + it("is stable for equal keys and sorts by the key, not the item", () => { + const items = [ + { name: "b", id: 1 }, + { name: "a", id: 2 }, + { name: "b", id: 3 }, + ]; + expect(sortHeatmapCategories(items, (it) => it.name, "asc").map((it) => it.id)).toEqual([ + 2, 1, 3, + ]); + }); + + it("does not mutate the input", () => { + const input = [3, 1, 2]; + sortHeatmapCategories(input, identity, "asc"); + expect(input).toEqual([3, 1, 2]); + }); +}); + +describe("heatmapYBucketing / heatmapYBucketError", () => { + it("is off when unset or the mode is unknown", () => { + expect(heatmapYBucketing({})).toBeNull(); + expect(heatmapYBucketing({ heatmap_y_bucket: { mode: "count", count: 5 } })).toBeNull(); + expect(heatmapYBucketError({ heatmap_y_bucket: { mode: "count", count: 5 } })).toBeNull(); + }); + + it("validates the size as a finite number > 0", () => { + for (const size of [0, -1, Infinity, NaN, "", null, "abc"]) { + const config = { heatmap_y_bucket: { mode: "size", size } }; + expect(heatmapYBucketing(config)).toBeNull(); + expect(heatmapYBucketError(config)).toBe("invalidSize"); + } + expect(heatmapYBucketing({ heatmap_y_bucket: { mode: "size", size: 2.5 } })).toEqual({ + bucket: { mode: "size", size: 2.5 }, + range: null, + }); + }); + + it("defaults log to base 10 and accepts only 2 or 10", () => { + expect(heatmapYBucketing({ heatmap_y_bucket: { mode: "log" } })?.bucket).toEqual({ + mode: "log", + base: 10, + }); + expect(heatmapYBucketing({ heatmap_y_bucket: { mode: "log", base: 2 } })?.bucket).toEqual({ + mode: "log", + base: 2, + }); + expect(heatmapYBucketing({ heatmap_y_bucket: { mode: "log", base: 3 } })).toBeNull(); + }); + + it("rejects a size whose known range makes more rows than a full-width heatmap keeps, and not when an end is open", () => { + const tooMany = { + heatmap_y_bucket: { mode: "size", size: 1 }, + heatmap_y_min: 0, + heatmap_y_max: HEATMAP_MAX_ROWS, + }; + expect(HEATMAP_MAX_ROWS).toBe(416); + expect(heatmapYBucketError(tooMany)).toBe("tooManyBuckets"); + expect(heatmapYBucketError({ ...tooMany, heatmap_y_max: HEATMAP_MAX_ROWS - 1 })).toBeNull(); + expect(heatmapYBucketError({ ...tooMany, heatmap_y_min: null })).toBeNull(); + }); + + it("counts buckets from the lower bound of each end", () => { + expect(heatmapYBucketCount({ mode: "size", size: 10 }, { min: 5, max: 25 })).toBe(3); + expect(heatmapYBucketCount({ mode: "size", size: 10 }, { min: -5, max: 5 })).toBe(2); + expect(heatmapYBucketCount({ mode: "size", size: 10 }, { max: 5 })).toBeNull(); + expect(heatmapYBucketCount({ mode: "log", base: 2 }, { min: 1, max: 1e300 })).toBeNull(); + }); +}); + +describe("heatmapYRangeOf", () => { + it("reads either end and ignores an inverted pair", () => { + expect(heatmapYRangeOf({})).toBeNull(); + expect(heatmapYRangeOf({ heatmap_y_min: 5 })).toEqual({ min: 5, max: undefined }); + expect(heatmapYRangeOf({ heatmap_y_max: "7" })).toEqual({ min: undefined, max: 7 }); + expect(heatmapYRangeOf({ heatmap_y_min: 10, heatmap_y_max: 10 })).toBeNull(); + expect(isHeatmapYRangeInverted({ heatmap_y_min: 10, heatmap_y_max: 10 })).toBe(true); + expect(isHeatmapYRangeInverted({ heatmap_y_min: 1, heatmap_y_max: 10 })).toBe(false); + expect(isHeatmapYRangeInverted({ heatmap_y_min: 10 })).toBe(false); + }); +}); + +describe("heatmapYBucketSql / heatmapYRangeSql", () => { + it("floors a double-cast value to the bucket size", () => { + expect(heatmapYBucketSql("latency", { mode: "size", size: 10 })).toBe( + "floor(CAST(latency AS DOUBLE) / 10) * 10", + ); + }); + + it("uses the exact log function of the base and keeps null y null", () => { + expect(heatmapYBucketSql("t.latency", { mode: "log", base: 10 })).toBe( + "CASE WHEN t.latency > 0 THEN pow(10, floor(log10(CAST(t.latency AS DOUBLE)))) WHEN t.latency <= 0 THEN 0 END", + ); + expect(heatmapYBucketSql("latency", { mode: "log", base: 2 })).toContain("log2("); + }); + + it("builds the range condition from the set ends only", () => { + expect(heatmapYRangeSql("latency", { min: -5, max: 500 })).toBe( + "latency >= -5 AND latency <= 500", + ); + expect(heatmapYRangeSql("latency", { min: 1 })).toBe("latency >= 1"); + expect(heatmapYRangeSql("latency", null)).toBe(""); + }); +}); + +describe("heatmapYBucketLabel / heatmapYBucketInRange", () => { + it("labels a size bucket as its range without float noise", () => { + expect(heatmapYBucketLabel(10, { mode: "size", size: 10 })).toBe("10–20"); + expect(heatmapYBucketLabel(0.30000000000000004, { mode: "size", size: 0.1 })).toBe("0.3–0.4"); + expect(heatmapYBucketLabel(-10, { mode: "size", size: 10 })).toBe("-10–0"); + }); + + it("labels a log bucket up to the next power, and the 0 bucket as <= 0", () => { + expect(heatmapYBucketLabel(100, { mode: "log", base: 10 })).toBe("100–1000"); + expect(heatmapYBucketLabel(0.5, { mode: "log", base: 2 })).toBe("0.5–1"); + expect(heatmapYBucketLabel(0, { mode: "log", base: 10 })).toBe(HEATMAP_Y_NON_POSITIVE_LABEL); + }); + + it("keeps buckets that overlap the range", () => { + const size = { mode: "size", size: 10 } as const; + expect(heatmapYBucketInRange(0, size, { min: 5, max: 25 })).toBe(true); + expect(heatmapYBucketInRange(20, size, { min: 5, max: 25 })).toBe(true); + expect(heatmapYBucketInRange(-10, size, { min: 0 })).toBe(false); + expect(heatmapYBucketInRange(30, size, { min: 5, max: 25 })).toBe(false); + expect(heatmapYBucketInRange(30, size, null)).toBe(true); + }); + + it("keeps the log <= 0 bucket only while the range reaches 0", () => { + const log = { mode: "log", base: 10 } as const; + expect(heatmapYBucketInRange(0, log, { min: 0 })).toBe(true); + expect(heatmapYBucketInRange(0, log, { min: -1 })).toBe(true); + expect(heatmapYBucketInRange(0, log, { min: 1 })).toBe(false); + expect(heatmapYBucketInRange(1, log, { min: 5 })).toBe(true); + expect(heatmapYBucketInRange(1, log, { min: 10 })).toBe(false); + }); + + it("keeps the log <= 0 bucket for a negative max, whose values the SQL puts there", () => { + const log = { mode: "log", base: 10 } as const; + expect(heatmapYBucketInRange(0, log, { max: -5 })).toBe(true); + expect(heatmapYBucketInRange(0, log, { min: -50, max: -5 })).toBe(true); + expect(heatmapYBucketInRange(1, log, { max: -5 })).toBe(false); + }); +}); + +describe("isPlainYField / isNumericYField", () => { + const fieldOf = (field: string, extra: Record = {}) => ({ + alias: "y_axis_1", + functionName: null, + args: [{ type: "field", value: { field } }], + ...extra, + }); + const panelWith = (schema: any[], streamAlias: string | null = null) => ({ + meta: { streamFields: { groupedFields: [{ stream_alias: streamAlias, schema }] } }, + }); + + it("accepts only a bare column", () => { + expect(isPlainYField(fieldOf("latency"))).toBe(true); + expect(isPlainYField(fieldOf("latency", { functionName: "count" }))).toBe(false); + expect(isPlainYField(fieldOf("latency", { isDerived: true }))).toBe(false); + expect(isPlainYField({ args: [{ type: "string", value: "x" }] })).toBe(false); + expect(isPlainYField(undefined)).toBe(false); + }); + + it.each(["Int64", "UInt32", "Float64", "Int8"])("treats %s as numeric", (type) => { + expect(isNumericYField(fieldOf("latency"), panelWith([{ name: "latency", type }]))).toBe(true); + }); + + it.each(["Utf8", "Boolean", undefined])("treats %s as not numeric", (type) => { + expect(isNumericYField(fieldOf("latency"), panelWith([{ name: "latency", type }]))).toBe(false); + }); + + it("looks the column up in the stream its alias names", () => { + const field = { + ...fieldOf("latency"), + args: [{ type: "field", value: { field: "latency", streamAlias: "b" } }], + }; + expect(isNumericYField(field, panelWith([{ name: "latency", type: "Int64" }], "b"))).toBe(true); + expect(isNumericYField(field, panelWith([{ name: "latency", type: "Int64" }]))).toBe(false); + }); +}); + +describe("isHeatmapYBucketLowerBound", () => { + it("accepts multiples of the size, within float noise", () => { + expect(isHeatmapYBucketLowerBound(20, { mode: "size", size: 10 })).toBe(true); + expect(isHeatmapYBucketLowerBound(-10, { mode: "size", size: 10 })).toBe(true); + expect(isHeatmapYBucketLowerBound(0.30000000000000004, { mode: "size", size: 0.1 })).toBe(true); + expect(isHeatmapYBucketLowerBound(25, { mode: "size", size: 10 })).toBe(false); + }); + + it("accepts 0 and powers of the base", () => { + expect(isHeatmapYBucketLowerBound(0, { mode: "log", base: 10 })).toBe(true); + expect(isHeatmapYBucketLowerBound(0.001, { mode: "log", base: 10 })).toBe(true); + expect(isHeatmapYBucketLowerBound(1024, { mode: "log", base: 2 })).toBe(true); + expect(isHeatmapYBucketLowerBound(50, { mode: "log", base: 10 })).toBe(false); + expect(isHeatmapYBucketLowerBound(-10, { mode: "log", base: 10 })).toBe(false); + expect(isHeatmapYBucketLowerBound(NaN, { mode: "size", size: 1 })).toBe(false); + }); +}); diff --git a/web/src/utils/dashboard/heatmapYAxis.ts b/web/src/utils/dashboard/heatmapYAxis.ts new file mode 100644 index 0000000000..92e25c03e9 --- /dev/null +++ b/web/src/utils/dashboard/heatmapYAxis.ts @@ -0,0 +1,262 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +/** Heatmap y-axis ordering (`heatmap_y_sort`), and the numeric y bucketing (`heatmap_y_bucket`) and range (`heatmap_y_min` / `heatmap_y_max`) the SQL builder generates. */ + +import { HEATMAP_MAX_ROWS, type HeatmapConfig } from "./heatmapDefaults"; + +/** The orders `heatmap_y_sort` accepts; unset is "data". ECharts draws category 0 at the bottom, so "asc" puts the smallest value there. */ +export const HEATMAP_Y_SORTS = ["data", "asc", "desc"] as const; + +/** The log bases `heatmap_y_bucket.base` accepts; the first is the default. */ +export const HEATMAP_Y_LOG_BASES = [10, 2] as const; + +export const HEATMAP_Y_NON_POSITIVE_LABEL = "≤ 0"; + +const NUMERIC_SCHEMA_TYPE = /^(U?Int(8|16|32|64)|Float(16|32|64))$/; + +export type HeatmapYBucket = { mode: "size"; size: number } | { mode: "log"; base: 2 | 10 }; + +/** The y range kept; an unset end is open. */ +export interface HeatmapYRange { + min?: number; + max?: number; +} + +export interface HeatmapYBucketing { + bucket: HeatmapYBucket; + range: HeatmapYRange | null; +} + +export type HeatmapYBucketError = "invalidSize" | "tooManyBuckets"; + +export interface HeatmapYField { + isDerived?: boolean; + functionName?: string | null; + args?: Array<{ type?: string; value?: { field?: string; streamAlias?: string } }>; +} + +export interface HeatmapYFieldSchema { + meta?: { + streamFields?: { + groupedFields?: Array<{ + stream_alias?: string | null; + schema?: Array<{ name: string; type?: string }>; + }>; + }; + }; +} + +/** `items` ordered by `keyOf` per `order`: numerically when every key is a finite number, else by locale string compare; "data" or unset keeps them as given. */ +export function sortHeatmapCategories( + items: T[], + keyOf: (item: T) => unknown, + order: unknown, +): T[] { + if (order !== "asc" && order !== "desc") return items; + const keys = items.map(keyOf); + const numbers = keys.map(finiteNumberOf); + const numeric = numbers.every((value) => value !== undefined); + const compare = (a: number, b: number) => + numeric + ? (numbers[a] as number) - (numbers[b] as number) + : String(keys[a]).localeCompare(String(keys[b])); + const sign = order === "asc" ? 1 : -1; + return items + .map((_item, index) => index) + .sort((a, b) => sign * compare(a, b) || a - b) + .map((index) => items[index]); +} + +/** The bucketing `config` asks for with its y range, or null when bucketing is off or its settings are unusable. */ +export function heatmapYBucketing( + config: HeatmapConfig | null | undefined, +): HeatmapYBucketing | null { + const bucket = heatmapYBucketOf(config?.heatmap_y_bucket); + return bucket ? { bucket, range: heatmapYRangeOf(config) } : null; +} + +/** Why `config`'s bucketing must not run: a size that is not a finite number > 0, or a known range that makes more buckets than a grid can hold. */ +export function heatmapYBucketError( + config: HeatmapConfig | null | undefined, +): HeatmapYBucketError | null { + const mode = config?.heatmap_y_bucket?.mode; + if (mode !== "size") return null; + const size = finiteNumberOf(config?.heatmap_y_bucket?.size); + if (size === undefined || size <= 0) return "invalidSize"; + const count = heatmapYBucketCount({ mode, size }, heatmapYRangeOf(config)); + return count !== null && count > HEATMAP_MAX_ROWS ? "tooManyBuckets" : null; +} + +/** How many buckets `bucket` makes over `range`, or null when an open end leaves it unknown until the data arrives. */ +export function heatmapYBucketCount( + bucket: HeatmapYBucket, + range: HeatmapYRange | null, +): number | null { + // Log buckets are bounded by the double exponent range (about 2,100 at base 2), far under the cell budget. + if (bucket.mode !== "size" || range?.min === undefined || range?.max === undefined) return null; + return Math.floor(range.max / bucket.size) - Math.floor(range.min / bucket.size) + 1; +} + +/** `heatmap_y_min` / `heatmap_y_max` as numbers, or null when neither is set or they are inverted (min >= max). */ +export function heatmapYRangeOf(config: HeatmapConfig | null | undefined): HeatmapYRange | null { + const min = finiteNumberOf(config?.heatmap_y_min); + const max = finiteNumberOf(config?.heatmap_y_max); + if (min === undefined && max === undefined) return null; + if (min !== undefined && max !== undefined && min >= max) return null; + return { min, max }; +} + +/** Whether both y range ends are set with min >= max, which the builder and converter ignore. */ +export function isHeatmapYRangeInverted(config: HeatmapConfig | null | undefined): boolean { + const min = finiteNumberOf(config?.heatmap_y_min); + const max = finiteNumberOf(config?.heatmap_y_max); + return min !== undefined && max !== undefined && min >= max; +} + +/** Whether `field` is a bare column of the builder, the only y the converter treats as bucketed. */ +export function isPlainYField(field: HeatmapYField | null | undefined): boolean { + return ( + !!field && + !field.isDerived && + !field.functionName && + field.args?.[0]?.type === "field" && + !!field.args[0]?.value?.field + ); +} + +/** Whether `field` is a bare builder column whose stream schema types it as an integer or float. */ +export function isNumericYField( + field: HeatmapYField | null | undefined, + dashboardPanelData: HeatmapYFieldSchema | null | undefined, +): boolean { + if (!isPlainYField(field)) return false; + const { field: name, streamAlias } = field?.args?.[0]?.value ?? {}; + const streamFields = dashboardPanelData?.meta?.streamFields?.groupedFields?.find((it) => + streamAlias ? it.stream_alias === streamAlias : (it.stream_alias ?? null) === null, + ); + const type = streamFields?.schema?.find((it) => it.name === name)?.type; + return NUMERIC_SCHEMA_TYPE.test(String(type ?? "")); +} + +/** The SQL for the lower bound of the bucket holding `expr`; null y stays null rather than joining the <= 0 bucket. */ +export function heatmapYBucketSql(expr: string, bucket: HeatmapYBucket): string { + // Cast first: integer division truncates toward zero and would merge the two buckets around 0. + const value = `CAST(${expr} AS DOUBLE)`; + if (bucket.mode === "size") return `floor(${value} / ${bucket.size}) * ${bucket.size}`; + // log10 / log2 are exact on powers of their base, where log(base, y) puts 1000 in the 100 bucket. + return `CASE WHEN ${expr} > 0 THEN pow(${bucket.base}, floor(log${bucket.base}(${value}))) WHEN ${expr} <= 0 THEN 0 END`; +} + +/** The SQL condition keeping `expr` inside `range`, or "" when both ends are open. */ +export function heatmapYRangeSql(expr: string, range: HeatmapYRange | null): string { + const conditions: string[] = []; + if (range?.min !== undefined) conditions.push(`${expr} >= ${range.min}`); + if (range?.max !== undefined) conditions.push(`${expr} <= ${range.max}`); + return conditions.join(" AND "); +} + +/** The y label for the bucket starting at `lower`, as "lower–upper"; the log-mode bucket at 0 holds every value <= 0. */ +export function heatmapYBucketLabel(lower: number, bucket: HeatmapYBucket): string { + if (bucket.mode === "log" && lower === 0) return HEATMAP_Y_NON_POSITIVE_LABEL; + return `${tidyNumber(lower)}–${tidyNumber(bucketUpper(lower, bucket))}`; +} + +/** The log-mode bucket at 0 holds every value <= 0, so it overlaps any range reaching 0. */ +export function heatmapYBucketInRange( + lower: number, + bucket: HeatmapYBucket, + range: HeatmapYRange | null, +): boolean { + // The log bucket at 0 holds every value <= 0, so a negative max still reaches it. + if (bucket.mode === "log" && lower === 0) return range?.min === undefined || range.min <= 0; + if (range?.max !== undefined && lower > range.max) return false; + if (range?.min === undefined) return true; + return bucketUpper(lower, bucket) > range.min; +} + +/** Whether `value` can be the lower bound the builder's SQL gives a bucket, which a query not bucketed by it rarely returns for every row. */ +export function isHeatmapYBucketLowerBound(value: number, bucket: HeatmapYBucket): boolean { + if (!Number.isFinite(value)) return false; + if (bucket.mode === "size") return isNearInteger(value / bucket.size); + if (value === 0) return true; + return value > 0 && isNearInteger(Math.log(value) / Math.log(bucket.base)); +} + +/** `lowerBounds` plus every empty bucket between the smallest and largest, so adjacent rows are adjacent ranges; returned as given when that would pass `maxRows`. */ +export function fillHeatmapYBuckets( + lowerBounds: number[], + bucket: HeatmapYBucket, + maxRows: number, +): number[] { + // Keyed by bucket index so a returned float such as 0.30000000000000004 stays the row its cells are looked up by. + const byIndex = new Map(); + const extra: number[] = []; + for (const value of lowerBounds) { + if (bucket.mode === "log" && value <= 0) extra.push(value); + else byIndex.set(bucketIndexOf(value, bucket), value); + } + if (!byIndex.size) return lowerBounds; + const indices = [...byIndex.keys()]; + const first = Math.min(...indices); + const last = Math.max(...indices); + if (last - first + 1 + extra.length > maxRows) return lowerBounds; + const filled = [...extra]; + for (let index = first; index <= last; index++) { + filled.push(byIndex.get(index) ?? bucketLowerOf(index, bucket)); + } + return filled; +} + +function heatmapYBucketOf(raw: HeatmapConfig["heatmap_y_bucket"]): HeatmapYBucket | null { + if (raw?.mode === "size") { + const size = finiteNumberOf(raw.size); + return size !== undefined && size > 0 ? { mode: "size", size } : null; + } + if (raw?.mode === "log") { + const base = Number(raw.base ?? HEATMAP_Y_LOG_BASES[0]); + return base === 2 || base === 10 ? { mode: "log", base } : null; + } + return null; +} + +function bucketIndexOf(lower: number, bucket: HeatmapYBucket): number { + if (bucket.mode === "size") return Math.round(lower / bucket.size); + return Math.round(Math.log(lower) / Math.log(bucket.base)); +} + +function bucketLowerOf(index: number, bucket: HeatmapYBucket): number { + return bucket.mode === "size" ? index * bucket.size : Math.pow(bucket.base, index); +} + +function bucketUpper(lower: number, bucket: HeatmapYBucket): number { + return bucket.mode === "size" ? lower + bucket.size : lower * bucket.base; +} + +function finiteNumberOf(value: unknown): number | undefined { + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + if (typeof value !== "string" || value.trim() === "") return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function isNearInteger(value: number): boolean { + return Math.abs(value - Math.round(value)) <= 1e-9 * Math.max(1, Math.abs(value)); +} + +/** `value` without binary float noise, so 0.1 + 0.2 reads as 0.3. */ +function tidyNumber(value: number): string { + return String(Number(value.toPrecision(12))); +} diff --git a/web/src/utils/dashboard/panelValidation.spec.ts b/web/src/utils/dashboard/panelValidation.spec.ts index 79a8ab32cd..91c70e6d54 100644 --- a/web/src/utils/dashboard/panelValidation.spec.ts +++ b/web/src/utils/dashboard/panelValidation.spec.ts @@ -372,6 +372,58 @@ describe("validatePanel on a PromQL formula", () => { }); }); +describe("validatePanel on heatmap settings", () => { + const numericY = { alias: "y", args: [{ type: "field", value: { field: "took" } }] }; + const run = (config: any, { queryType = "sql", customQuery = false } = {}) => { + const errors: string[] = []; + validatePanel( + gt, + { + data: { + type: "heatmap", + queryType, + config, + queries: [{ customQuery, query: "", fields: { y: [numericY] } }], + }, + layout: { currentQueryIndex: 0 }, + meta: { + streamFields: { groupedFields: [{ schema: [{ name: "took", type: "Int64" }] }] }, + }, + }, + errors, + false, + [], + "dashboard", + { state: { zoConfig: {} } }, + () => true, + ); + return errors; + }; + + it("blocks a bucket size that would leave the builder query empty", () => { + expect(run({ heatmap_y_bucket: { mode: "size", size: 0 } })).toEqual([ + gt("dashboard.utils.heatmapYBucketSizeInvalid"), + ]); + expect( + run({ heatmap_y_bucket: { mode: "size", size: 1 }, heatmap_y_min: 0, heatmap_y_max: 1e6 }), + ).toHaveLength(1); + }); + + it("blocks inverted y and colour ranges, which the renderer would silently ignore", () => { + expect( + run({ heatmap_y_bucket: { mode: "log", base: 10 }, heatmap_y_min: 9, heatmap_y_max: 1 }), + ).toEqual([gt("dashboard.utils.heatmapYRangeInverted")]); + expect(run({ heatmap_color_min: 50, heatmap_color_max: 5 }, { queryType: "promql" })).toContain( + gt("dashboard.utils.heatmapColorRangeInverted"), + ); + }); + + it("ignores bucket settings that do not apply to custom SQL", () => { + expect(run({ heatmap_y_bucket: { mode: "size", size: 0 } }, { customQuery: true })).toEqual([]); + expect(run({ heatmap_color_min: 1, heatmap_color_max: 5 })).toEqual([]); + }); +}); + describe("validateConditions", () => { const t = gt as any; diff --git a/web/src/utils/dashboard/panelValidation.ts b/web/src/utils/dashboard/panelValidation.ts index 281018528f..b80bfdbe30 100644 --- a/web/src/utils/dashboard/panelValidation.ts +++ b/web/src/utils/dashboard/panelValidation.ts @@ -2,6 +2,13 @@ import type { TranslateFn } from "@/types/i18n"; import { CURRENT_DASHBOARD_SCHEMA_VERSION } from "@/utils/dashboard/convertDashboardSchemaVersion"; import functionValidation from "@/components/dashboards/addPanel/dynamicFunction/functionValidation.json"; import { isFormulaQuery } from "@/utils/dashboard/promql/formula"; +import { HEATMAP_MAX_ROWS, isHeatmapColorRangeInverted } from "@/utils/dashboard/heatmapDefaults"; +import { + heatmapYBucketError, + isHeatmapYRangeInverted, + isNumericYField, +} from "@/utils/dashboard/heatmapYAxis"; +import { addCommasToNumber } from "@/utils/formatters"; import { validateAnnotationSources } from "@/utils/dashboard/validateAnnotationSources"; /** @@ -893,6 +900,34 @@ const validatePanelContent = (t: TranslateFn, panel: any): string[] => { return errors; }; +/** Heatmap settings the renderer would silently ignore, or under which the builder generates an empty query. */ +const validateHeatmapConfig = ( + t: TranslateFn, + panelData: any, + errors: string[], + isPromQLMode: boolean, +) => { + if (panelData?.data?.type !== "heatmap") return; + const config = panelData.data.config; + if (isHeatmapColorRangeInverted(config)) { + errors.push(t("dashboard.utils.heatmapColorRangeInverted")); + } + if (isPromQLMode || !config?.heatmap_y_bucket?.mode) return; + // Only a builder query over a numeric y column is bucketed; elsewhere the setting is hidden and unused. + const bucketed = (panelData.data.queries ?? []).some( + (query: any) => !query?.customQuery && isNumericYField(query?.fields?.y?.[0], panelData), + ); + if (!bucketed) return; + const bucketError = heatmapYBucketError(config); + if (bucketError === "invalidSize") errors.push(t("dashboard.utils.heatmapYBucketSizeInvalid")); + if (bucketError === "tooManyBuckets") { + errors.push( + t("dashboard.utils.heatmapYBucketTooMany", { max: addCommasToNumber(HEATMAP_MAX_ROWS) }), + ); + } + if (isHeatmapYRangeInverted(config)) errors.push(t("dashboard.utils.heatmapYRangeInverted")); +}; + /** * Validates a dashboard panel's configuration * @param {object} panelData - The panel data object to validate @@ -919,6 +954,7 @@ export const validatePanel = ( // Validate panel content based on type validatePanelContentByType(t, panelData?.data, errors); + validateHeatmapConfig(t, panelData, errors, isPromQLMode); // Validate timestamp alias for SQL queries with custom query mode if (panelData?.data?.queryType === "sql") { diff --git a/web/src/utils/dashboard/promql/convertPromQLChartData.spec.ts b/web/src/utils/dashboard/promql/convertPromQLChartData.spec.ts index 0e30343366..a64024dae9 100644 --- a/web/src/utils/dashboard/promql/convertPromQLChartData.spec.ts +++ b/web/src/utils/dashboard/promql/convertPromQLChartData.spec.ts @@ -251,6 +251,30 @@ describe("convertPromQLChartData", () => { expect(result.options.grid.left).toBe(100); }); + it("applies axis_width to every grid of a stacked heatmap", async () => { + const { processPromQLData } = await import("./shared/dataProcessor"); + const bucket = (pod: string, le: string, value: string) => ({ + metric: { pod, le }, + name: `${pod} ${le}`, + data: { 1: value }, + values: [[1, value]], + }); + vi.mocked(processPromQLData).mockResolvedValueOnce([ + { + series: [bucket("a", "1", "1"), bucket("a", "+Inf", "2"), bucket("b", "1", "3")], + timestamps: [[1, "00:00:01"]], + queryIndex: 0, + }, + ] as any); + mockContext.panelSchema.type = "heatmap"; + mockContext.panelSchema.config = { axis_width: 100, heatmap_mode: "prometheus_histogram" }; + + const result = await convertPromQLChartData(mockPromQLResponse, mockContext); + + expect(result.options.grid).toHaveLength(2); + expect(result.options.grid.map((grid: any) => grid.left)).toEqual([100, 100]); + }); + it("should preserve existing grid configuration when applying axis_width", async () => { mockContext.panelSchema.config = { axis_width: 100, diff --git a/web/src/utils/dashboard/promql/convertPromQLChartData.ts b/web/src/utils/dashboard/promql/convertPromQLChartData.ts index 97245fa670..436b9b151a 100644 --- a/web/src/utils/dashboard/promql/convertPromQLChartData.ts +++ b/web/src/utils/dashboard/promql/convertPromQLChartData.ts @@ -104,7 +104,14 @@ export async function convertPromQLChartData( // Step 4: Delegate to chart-specific converter // Each converter knows how to transform data for its chart type(s) - const chartConfig = converter.convert(processedData, panelSchema, store, extras, chartPanelRef); + const chartConfig = converter.convert( + processedData, + panelSchema, + store, + extras, + chartPanelRef, + context, + ); // Step 5: Apply common chart configurations const options: { series?: any[]; columns?: unknown[]; [key: string]: any } = { @@ -113,10 +120,9 @@ export async function convertPromQLChartData( // Apply panel-level overrides ...(panelSchema.config?.axis_width && { - grid: { - ...chartConfig.grid, - left: panelSchema.config.axis_width, - }, + grid: Array.isArray(chartConfig.grid) + ? chartConfig.grid.map((grid: any) => ({ ...grid, left: panelSchema.config.axis_width })) + : { ...chartConfig.grid, left: panelSchema.config.axis_width }, }), }; diff --git a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts index 54e1e9c1e4..b1c6948262 100644 --- a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts +++ b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.spec.ts @@ -16,8 +16,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { HeatmapConverter, PROMETHEUS_HISTOGRAM_MODE } from "./convertPromQLHeatmapChart"; import type { ProcessedPromQLData } from "./shared/types"; -import { SPECTRAL_HEATMAP_STOP_COUNT } from "./shared/spectral"; +import { HEATMAP_STOP_COUNT } from "../heatmapPalettes"; import { chartColor } from "@/utils/chartTheme"; +import * as echarts from "echarts"; // Mock dependencies vi.mock("../convertDataIntoUnitValue", () => ({ @@ -67,6 +68,65 @@ describe("HeatmapConverter", () => { }; }); + describe("heatmap_y_sort in generic mode", () => { + const processedData = (): ProcessedPromQLData[] => [ + { + series: [ + { name: "10", values: [], data: { "1": "1" } }, + { name: "9", values: [], data: { "1": "2" } }, + { name: "100", values: [], data: { "1": "3" } }, + ], + timestamps: [[1, "00:00:01"]], + queryIndex: 0, + }, + ]; + const convert = (config: Record) => + converter.convert(processedData(), { type: "heatmap", config }, mockStore, mockExtras); + + it("keeps series order when unset", () => { + expect(convert({}).yAxis.data).toEqual(["10", "9", "100"]); + }); + + it("sorts numeric names numerically, smallest at index 0 (the bottom) for ascending", () => { + const options = convert({ heatmap_y_sort: "asc" }); + expect(options.yAxis.data).toEqual(["9", "10", "100"]); + expect(options.series[0].data).toEqual([ + [0, 0, 2], + [0, 1, 1], + [0, 2, 3], + ]); + }); + + it("sorts descending and keeps each row's values with its name", () => { + const options = convert({ heatmap_y_sort: "desc" }); + expect(options.yAxis.data).toEqual(["100", "10", "9"]); + expect(options.series[0].data.map((cell: any[]) => cell[2])).toEqual([3, 1, 2]); + }); + + it("does not reorder histogram buckets, which always follow le", () => { + const histogram: ProcessedPromQLData[] = [ + { + series: [ + { name: "b", metric: { le: "+Inf" }, values: [], data: { "1": "5" } }, + { name: "a", metric: { le: "1" }, values: [], data: { "1": "2" } }, + ], + timestamps: [[1, "00:00:01"]], + queryIndex: 0, + } as any, + ]; + const options = converter.convert( + histogram, + { + type: "heatmap", + config: { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE, heatmap_y_sort: "desc" }, + }, + mockStore, + mockExtras, + ); + expect(options.yAxis.data[options.yAxis.data.length - 1]).toBe("+Inf"); + }); + }); + describe("the time axis spans every query, not just the first", () => { const panelSchema = { type: "heatmap", config: {} }; const histogramSchema = { @@ -134,6 +194,8 @@ describe("HeatmapConverter", () => { expect(result.series[0].data).toEqual([ [0, 0, 1], [1, 0, 2], + [2, 0, "-"], + [0, 1, "-"], [1, 1, 5], [2, 1, 6], ]); @@ -160,9 +222,10 @@ describe("HeatmapConverter", () => { const result = converter.convert(processedData, histogramSchema, mockStore, mockExtras); expect(result.xAxis[0].data).toEqual(["00:00:00", "00:00:01"]); + expect(result.xAxis[1].data).toEqual(["00:00:00", "00:00:01"]); // The second histogram's 8 must survive — it used to be read as 0. expect(result.visualMap.max).toBe(8); - expect(result.series[0].data).toContainEqual([1, 1, 8]); + expect(result.series[1].data).toContainEqual([1, 0, 8]); }); }); @@ -287,7 +350,7 @@ describe("HeatmapConverter", () => { ]); }); - it("should handle missing data values as 0", () => { + it("leaves a missing sample as an empty cell, not 0", () => { const processedData: ProcessedPromQLData[] = [ { series: [ @@ -315,7 +378,10 @@ describe("HeatmapConverter", () => { const result = converter.convert(processedData, panelSchema, mockStore, mockExtras); - expect(result.series[0].data).toContainEqual([1, 0, 0]); // Missing value defaults to 0 + expect(result.series[0].data).toEqual([ + [0, 0, 10], + [1, 0, "-"], + ]); }); it("should calculate min and max values correctly", () => { @@ -530,6 +596,8 @@ describe("HeatmapConverter", () => { expect(result.tooltip.textStyle.color).toBe(chartColor("--color-tooltip-text")); expect(result.tooltip.backgroundColor).toBe(chartColor("--color-tooltip-bg")); + // Without it, entering the enterable tooltip counts as leaving the chart and restores every panel. + expect(result.tooltip.className).toBe("o2-echarts-tooltip"); }); it("should configure tooltip for dark theme", () => { @@ -1239,7 +1307,7 @@ describe("HeatmapConverter", () => { expect(result.visualMap.min).toBe(0); expect(result.visualMap.max).toBe(6); expect(result.visualMap.calculable).toBe(true); - expect(result.visualMap.inRange.color).toHaveLength(SPECTRAL_HEATMAP_STOP_COUNT); + expect(result.visualMap.inRange.color).toHaveLength(HEATMAP_STOP_COUNT); // Cool at the LOW end, hot at the high end. Reversed from ColorBrewer's // published (warm-first) Spectral order: the eye reads hot as "more", so // red-at-low made a histogram's near-empty high-`le` rows the loudest @@ -1263,6 +1331,18 @@ describe("HeatmapConverter", () => { expect(result.visualMap.max).toBe(40); }); + it("marks the histogram tooltip so entering it does not restore every panel", () => { + const result: any = converter.convert( + histogramData(), + histogramSchema(), + mockStore, + mockExtras, + ); + + expect(result.tooltip.enterable).toBe(true); + expect(result.tooltip.className).toBe("o2-echarts-tooltip"); + }); + it("keeps config.unit as the cell-intensity unit in the tooltip", () => { const result: any = converter.convert( histogramData(), @@ -1331,7 +1411,7 @@ describe("HeatmapConverter", () => { // heatmap gets the same Spectral ramp and cell borders as every other // heatmap in the app. Styling is not what corrupts a heatmap; the // de-accumulation is, and that is still gated on heatmap_mode. - expect(generic.visualMap.inRange.color).toHaveLength(SPECTRAL_HEATMAP_STOP_COUNT); + expect(generic.visualMap.inRange.color).toHaveLength(HEATMAP_STOP_COUNT); expect(generic.visualMap.inRange.color).toEqual(histogram.visualMap.inRange.color); expect(generic.series[0].itemStyle.borderWidth).toBe(1); expect(generic.xAxis[0].splitArea.show).toBe(false); @@ -1353,7 +1433,7 @@ describe("HeatmapConverter", () => { expect(result.yAxis.data).toEqual(['{le="1"}', '{le="0.1"}', '{le="inf"}', '{le="0.5"}']); expect(result.series[0].data[0]).toEqual([0, 0, 9]); // The shared look still applies — it is presentation, not a transform. - expect(result.visualMap.inRange.color).toHaveLength(SPECTRAL_HEATMAP_STOP_COUNT); + expect(result.visualMap.inRange.color).toHaveLength(HEATMAP_STOP_COUNT); }); }); }); @@ -1456,16 +1536,17 @@ describe("prometheus_histogram mode with more than one query", () => { { legends: [] }, ); - // Both histograms survive: 2 buckets each, 4 rows — not 2. - expect(result.yAxis.data).toHaveLength(4); - expect(result.series[0].data).toHaveLength(4); // 4 buckets x 1 timestamp + // Both histograms survive as their own grid of 2 buckets. + expect(result.yAxis.map((axis: any) => axis.data.length)).toEqual([2, 2]); + expect(result.title.map((title: any) => title.text)).toEqual(["Query 1", "Query 2"]); // Query 1: 10 in the first bucket, 30-10=20 in +Inf. // Query 2: 100 and 300-100=200. Nothing merged, nothing subtracted across. - const values = result.series[0].data - .map((cell: any) => cell[2]) - .sort((a: number, b: number) => a - b); - expect(values).toEqual([10, 20, 100, 200]); + const values = result.series.map((series: any) => series.data.map((cell: any) => cell[2])); + expect(values).toEqual([ + [10, 20], + [100, 200], + ]); }); }); @@ -1494,9 +1575,11 @@ describe("bucket labels must be unique — ECharts category axes require it", () { legends: [] }, ); - const labels: string[] = result.yAxis.data; - expect(labels).toHaveLength(6); // 3 histograms x 2 buckets - expect(new Set(labels).size).toBe(labels.length); // ...all distinct + expect(result.yAxis).toHaveLength(3); + for (const axis of result.yAxis) { + expect(axis.data).toHaveLength(2); + expect(new Set(axis.data).size).toBe(axis.data.length); + } }); }); @@ -1573,3 +1656,662 @@ describe("compact_preview shrinks the heatmap for the metrics explorer cards", ( expect(convertWith({ compact_preview: true }).tooltip.appendToBody).toBe(true); }); }); + +describe("pinned colour range (heatmap_color_min / heatmap_color_max)", () => { + const store = { state: { timezone: "UTC", theme: "light" } }; + const timestamps: Array<[number, string]> = [ + [1, "2024-01-01T00:00:00"], + [2, "2024-01-01T00:01:00"], + ]; + const genericData = (): ProcessedPromQLData[] => [ + { + timestamps: [...timestamps], + series: [ + { name: "a", values: [], data: { 1: "10", 2: "20" } }, + { name: "b", values: [], data: { 1: "30", 2: "40" } }, + ], + queryIndex: 0, + queryConfig: {}, + } as any, + ]; + const histogramData = (): ProcessedPromQLData[] => [ + { + timestamps: [...timestamps], + series: [ + { name: '{le="1"}', metric: { le: "1" }, values: [], data: { 1: "2", 2: "4" } }, + { name: '{le="+Inf"}', metric: { le: "+Inf" }, values: [], data: { 1: "10", 2: "10" } }, + ], + queryIndex: 0, + queryConfig: {}, + } as any, + ]; + + const convert = (data: ProcessedPromQLData[], config: Record) => + new HeatmapConverter().convert(data, { type: "heatmap", config }, store, { + legends: [], + }) as any; + const generic = (config: Record) => convert(genericData(), config); + const histogram = (config: Record) => + convert(histogramData(), { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE, ...config }); + const rangeOf = (result: any) => [result.visualMap.min, result.visualMap.max]; + + describe.each([ + ["generic", generic, 40], + ["prometheus_histogram", histogram, 8], + ])("%s mode", (_mode, run, autoMax) => { + it("keeps the automatic range when nothing is pinned", () => { + const result = run({ heatmap_color_min: null, heatmap_color_max: null }); + expect(rangeOf(result)).toEqual([0, autoMax]); + }); + + it("pins only the min", () => { + const result = run({ heatmap_color_min: 3 }); + expect(rangeOf(result)).toEqual([3, autoMax]); + }); + + it("pins only the max", () => { + const result = run({ heatmap_color_max: 5 }); + expect(rangeOf(result)).toEqual([0, 5]); + }); + + it("pins both ends", () => { + expect(rangeOf(run({ heatmap_color_min: 3, heatmap_color_max: 5 }))).toEqual([3, 5]); + }); + + it("ignores an inverted pair and uses the automatic range", () => { + const result = run({ heatmap_color_min: 5, heatmap_color_max: 3 }); + expect(rangeOf(result)).toEqual([0, autoMax]); + }); + + it("keeps the real value in the cell and tooltip above the pinned max", () => { + const result = run({ heatmap_color_max: 5 }); + const top = result.series[0].data.find((cell: any) => cell[2] === autoMax); + expect(top).toHaveLength(3); + const params = { value: top, name: "t", marker: "", seriesName: "s" }; + expect(result.tooltip.formatter(params)).toContain(`: ${autoMax}.00`); + }); + + it("keeps the pin through the compact preview", () => { + const result = run({ heatmap_color_max: 5, compact_preview: true }); + expect(rangeOf(result)).toEqual([0, 5]); + }); + }); + + it("shows the real value in the generic cell label above the pinned max", () => { + const result = generic({ heatmap_color_max: 5 }); + expect(result.series[0].label.formatter({ value: [1, 1, 40] })).toBe("40.00"); + }); + + it("takes the user's pin over the explorer's shared visual_map_range", () => { + const result = histogram({ + visual_map_range: { min: 0, max: 100 }, + heatmap_color_max: 50, + }); + expect(rangeOf(result)).toEqual([0, 50]); + }); + + it("keeps the explorer's shared range for the end the user did not pin", () => { + const result = histogram({ visual_map_range: { min: 1, max: 100 }, heatmap_color_min: 2 }); + expect(rangeOf(result)).toEqual([2, 100]); + }); + + it("falls back to the explorer's shared range, not the data's, for an inverted pair", () => { + const result = histogram({ + visual_map_range: { min: 0, max: 100 }, + heatmap_color_min: 50, + heatmap_color_max: 10, + }); + expect(rangeOf(result)).toEqual([0, 100]); + }); +}); + +describe("HeatmapConverter time grid, empty cells and cell budget", () => { + const store = { state: { timezone: "UTC", theme: "light" } }; + const iso = (ts: number) => new Date(ts * 1000).toISOString().slice(0, -1); + const query = (series: Array<{ name: string; data: Record }>, ts: number[]) => + ({ + series: series.map((it) => ({ ...it, metric: {}, values: [] })), + timestamps: ts.map((t) => [t, iso(t)]), + queryIndex: 0, + }) as any; + const context = (startSec: number, endSec: number, stepSeconds: number) => + ({ + metadata: { queries: [{ startTime: startSec * 1e6, endTime: endSec * 1e6 }] }, + stepSeconds, + }) as any; + const convert = (processed: any[], config: any = {}, ctx?: any, extras: any = { legends: [] }) => + new HeatmapConverter().convert(processed, { config }, store, extras, undefined, ctx); + + it("gives every step of the query range a column, empty where no series has a sample", () => { + const result = convert( + [query([{ name: "a", data: { 60: "1", 180: "3" } }], [60, 180])], + {}, + context(0, 270, 60), + ); + expect(result.xAxis[0].data).toEqual([ + "00:00:00", + "00:01:00", + "00:02:00", + "00:03:00", + "00:04:00", + ]); + expect(result.series[0].data.map((cell: any) => cell[2])).toEqual(["-", 1, "-", 3, "-"]); + }); + + it("keeps only the reported instants without a server step", () => { + const result = convert([query([{ name: "a", data: { 60: "1", 180: "3" } }], [60, 180])]); + expect(result.xAxis[0].data).toEqual(["00:01:00", "00:03:00"]); + }); + + it("caps the generated columns to the cell budget", () => { + const end = 200_000; + const result = convert( + [query([{ name: "a", data: { [end]: "1" } }], [end])], + {}, + context(0, end, 1), + ); + expect(result.xAxis[0].data).toHaveLength(50_000); + }); + + it("leaves histogram buckets empty where a bound has no sample, not 0", () => { + const processed = query( + [ + { name: "le1", data: { 60: "1" } }, + { name: "inf", data: { 60: "3", 120: "5" } }, + ], + [60, 120], + ); + processed.series[0].metric = { le: "1" }; + processed.series[1].metric = { le: "+Inf" }; + + const result = convert([processed], { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE }); + + expect(result.series[0].data).toEqual([ + [0, 0, 1], + [1, 0, "-"], + [0, 1, 2], + [1, 1, "-"], + ]); + }); + + it("keeps the largest series and warns once the grid exceeds the cell budget", () => { + const series = Array.from({ length: 50_001 }, (_unused, i) => ({ + name: `s${i}`, + data: { 60: String(i) }, + })); + const extras: any = { legends: [] }; + const result = convert([query(series, [60])], {}, undefined, extras); + + expect(result.yAxis.data).toHaveLength(50_000); + expect(result.yAxis.data[0]).toBe("s1"); + expect(extras.legends).toHaveLength(50_000); + expect(extras.limitNumberOfSeriesWarningMessage).toBe( + "Showing 50,000 of 50,001 rows to stay within the heatmap cell limit", + ); + }); + + it("keeps every histogram bucket and cuts the oldest columns to fit the cell budget", () => { + const columns = Array.from({ length: 120 }, (_unused, i) => 60 * (i + 1)); + const series = Array.from({ length: 500 }, (_unused, i) => ({ + name: `b${i}`, + // Bucket 250 is the quietest, which a row cut would drop from the middle of the ladder. + metric: { le: String(i + 1) }, + values: [], + data: Object.fromEntries(columns.map((ts) => [ts, String(i === 250 ? i : i + 1)])), + })); + const extras: any = { legends: [] }; + const result = new HeatmapConverter().convert( + [{ series, timestamps: columns.map((ts) => [ts, iso(ts)]), queryIndex: 0 } as any], + { config: { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE } }, + store, + extras, + ); + + expect(result.yAxis.data).toHaveLength(500); + expect(result.xAxis[0].data).toHaveLength(100); + expect(result.xAxis[0].data[0]).toBe("00:21:00"); + expect(result.series[0].data).toHaveLength(50_000); + expect(extras.limitNumberOfSeriesWarningMessage).toBe( + "Showing the last 100 of 120 columns to stay within the heatmap cell limit", + ); + }); + + it("adds the date to the labels when the range crosses a calendar day", () => { + const day = 86_400; + const result = convert([ + query([{ name: "a", data: { [day - 60]: "1", [day + 60]: "2" } }], [day - 60, day + 60]), + ]); + expect(result.xAxis[0].data).toEqual(["1970-01-01 23:59:00", "1970-01-02 00:01:00"]); + }); + + it("formats zoned Date labels with the date when they cross a calendar day", () => { + const processed = [ + { + series: [{ name: "a", metric: {}, values: [], data: { 1: "1", 2: "2" } }], + timestamps: [ + [1, new Date(2024, 0, 1, 23, 0, 0)], + [2, new Date(2024, 0, 2, 1, 0, 0)], + ], + queryIndex: 0, + } as any, + ]; + expect(convert(processed).xAxis[0].data).toEqual([ + "2024-01-01 23:00:00", + "2024-01-02 01:00:00", + ]); + }); + + it("keeps time-only labels within one day", () => { + const processed = [ + { + series: [{ name: "a", metric: {}, values: [], data: { 1: "1", 2: "2" } }], + timestamps: [ + [1, new Date(2024, 0, 1, 9, 0, 0)], + [2, new Date(2024, 0, 1, 10, 0, 0)], + ], + queryIndex: 0, + } as any, + ]; + expect(convert(processed).xAxis[0].data).toEqual(["09:00:00", "10:00:00"]); + }); +}); + +describe("prometheus_histogram mode with several histograms in one query", () => { + const store = { state: { timezone: "UTC", theme: "light" } }; + const histogramConfig = { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE }; + const columns = [60, 120]; + /** One pod's cumulative buckets, `cumulative[i]` at le = i + 1 and the last at +Inf, the same at every column. */ + const pod = (name: string, cumulative: number[], timestamps: number[] = columns) => + cumulative.map((value, index) => ({ + name: `${name}-${index}`, + metric: { + __name__: "x_bucket", + pod: name, + le: index === cumulative.length - 1 ? "+Inf" : String(index + 1), + }, + values: [], + data: Object.fromEntries(timestamps.map((ts) => [ts, String(value)])), + })); + const query = (series: any[], timestamps: number[] = columns) => + ({ + series, + timestamps: timestamps.map((ts) => [ts, new Date(ts * 1000).toISOString().slice(0, -1)]), + queryIndex: 0, + }) as any; + const convert = ( + processed: any[], + config: any = histogramConfig, + extras: any = { legends: [] }, + ) => + new HeatmapConverter().convert(processed, { config }, store, extras, { + value: { offsetHeight: 500 }, + }); + + it("keeps the default axis lines on every stacked grid even when the editor stored the toggle off", () => { + const processed = [query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])]; + const result = convert(processed, { ...histogramConfig, axis_border_show: false }); + const lines = [...result.xAxis, ...result.yAxis].map((axis: any) => axis.axisLine); + expect(lines).toEqual([undefined, undefined, undefined, undefined]); + }); + + it("leaves stacked grids out of the legend, whose entries would cover the colour bar", () => { + const result = convert([query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])]); + expect(result.series.map((series: any) => series.name)).toEqual([undefined, undefined]); + }); + + it("shrinks the labels of every stacked grid on a compact card, not just the first", () => { + const result = convert([query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])], { + ...histogramConfig, + compact_preview: true, + }); + expect(result.yAxis.map((axis: any) => axis.axisLabel.fontSize)).toEqual([9, 9]); + expect(result.xAxis.map((axis: any) => axis.axisLabel.fontSize)).toEqual([9, 9]); + expect(result.xAxis.map((axis: any) => axis.axisLabel.show)).toEqual([false, true]); + }); + + it("names a time-shifted query's histogram by its period, so the two titles differ", () => { + const result = convert([ + query(pod("a", [2, 5])), + { ...query(pod("a", [1, 3])), seriesRole: "shifted", nameSuffix: "1 day ago" }, + ]); + expect(result.title.map((title: any) => title.text)).toEqual([ + 'Query 1, pod="a"', + 'Query 1, pod="a" (1 day ago)', + ]); + }); + + it("draws one titled grid per label set, stacked, each with its own le ladder", () => { + const result = convert([query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])]); + + expect(result.title.map((title: any) => title.text)).toEqual(['pod="a"', 'pod="b"']); + expect(result.grid).toHaveLength(2); + expect(result.yAxis.map((axis: any) => axis.data)).toEqual([ + ["1.00", "+Inf"], + ["1.00", "2.00", "+Inf"], + ]); + expect(result.yAxis.map((axis: any) => axis.gridIndex)).toEqual([0, 1]); + expect(result.xAxis.map((axis: any) => axis.gridIndex)).toEqual([0, 1]); + expect(result.series.map((series: any) => [series.xAxisIndex, series.yAxisIndex])).toEqual([ + [0, 0], + [1, 1], + ]); + // Pod a: 2 then 5-2=3. Pod b: 1, 0, 6. De-accumulated per pod, never across. + expect(result.series[0].data).toEqual([ + [0, 0, 2], + [1, 0, 2], + [0, 1, 3], + [1, 1, 3], + ]); + expect(result.series[1].data.map((cell: any) => cell[2])).toEqual([1, 1, 0, 0, 6, 6]); + }); + + it("stacks the grids top to bottom without overlap, above the colour bar", () => { + const result = convert([ + query([...pod("a", [1, 2]), ...pod("b", [1, 2]), ...pod("c", [1, 2])]), + ]); + const pct = (value: string) => parseFloat(value); + const grids = result.grid.map((grid: any) => ({ + top: pct(grid.top), + bottom: pct(grid.top) + pct(grid.height), + })); + for (let i = 1; i < grids.length; i++) { + expect(grids[i].top).toBeGreaterThan(grids[i - 1].bottom); + expect(pct(result.title[i].top)).toBeGreaterThanOrEqual(grids[i - 1].bottom); + expect(pct(result.title[i].top)).toBeLessThan(grids[i].top); + } + // 60px of a 500px panel is left for the colour bar. + expect(grids.at(-1).bottom).toBeLessThanOrEqual(88); + expect(result.xAxis.map((axis: any) => axis.axisLabel.show)).toEqual([false, false, true]); + }); + + it("caps the stacked grids to the busiest that fit at a readable height, and warns", () => { + const pods = ["a", "b", "c", "d", "e", "f"].map((name, index) => pod(name, [1, index + 1])); + const extras: any = { legends: [] }; + const result = new HeatmapConverter().convert( + [query(pods.flat())], + { config: histogramConfig }, + store, + extras, + { value: { offsetHeight: 365 } }, + ); + expect(result.title.map((title: any) => title.text)).toEqual([ + 'pod="b"', + 'pod="c"', + 'pod="d"', + 'pod="e"', + 'pod="f"', + ]); + for (const grid of result.grid) { + expect((parseFloat(grid.height) / 100) * 365).toBeGreaterThanOrEqual(30); + } + expect(extras.limitNumberOfSeriesWarningMessage).toContain("5"); + expect(extras.limitNumberOfSeriesWarningMessage).toContain("6"); + }); + + it("warns when the series limit left whole histograms out", () => { + const extras: any = { legends: [] }; + convert( + [ + { + ...query([...pod("a", [1, 2]), ...pod("b", [1, 2])]), + histogramsLimited: { shown: 2, total: 9 }, + }, + ], + histogramConfig, + extras, + ); + expect(extras.limitNumberOfSeriesWarningMessage).toContain("9"); + }); + + it("hands drilldowns each grid's raw le values", () => { + const extras: any = { legends: [] }; + convert([query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])], histogramConfig, extras); + expect(extras.heatmapRowValues).toEqual([ + ["1", "+Inf"], + ["1", "2", "+Inf"], + ]); + }); + + it("shares one colour range across the grids, with pins applied to it", () => { + const processed = [query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])]; + const shared = convert(processed); + expect([shared.visualMap.min, shared.visualMap.max]).toEqual([0, 6]); + expect(shared.visualMap.seriesIndex).toBeUndefined(); + + const pinned = convert(processed, { ...histogramConfig, heatmap_color_max: 4 }); + expect([pinned.visualMap.min, pinned.visualMap.max]).toEqual([0, 4]); + }); + + it("renders in ECharts with one colour bar driving every grid", () => { + const options: any = convert([query([...pod("a", [2, 5]), ...pod("b", [1, 1, 7])])]); + const chart = echarts.init(null as any, null, { + renderer: "svg", + ssr: true, + width: 400, + height: 500, + }); + chart.setOption({ animation: false, ...options, tooltip: undefined }); + const fill = (seriesIndex: number, cell: number) => + (chart as any).getModel().getSeriesByIndex(seriesIndex).getData().getItemVisual(cell, "style") + .fill; + // Pod b's top bucket holds 6, the shared max, so it takes the colour bar's last stop. + const top = fill(1, 4); + const low = fill(0, 0); + chart.dispose(); + expect(top).toBe("rgba(158,1,66,1)"); + expect(low).not.toBe(top); + }); + + it("names the histogram in the tooltip", () => { + const result = convert([query([...pod("a", [2, 5]), ...pod("b", [1, 7])])]); + const html = result.tooltip.formatter({ + seriesIndex: 1, + value: [0, 1, 6], + marker: "*", + name: "00:01:00", + }); + expect(html).toContain("pod="b<i>""); + expect(html).toContain("le +Inf"); + }); + + it("names the query in each title once the panel has several", () => { + const result = convert([ + query(pod("a", [1, 2])), + { ...query([...pod("a", [1, 2]), ...pod("b", [1, 2])]), queryIndex: 1 }, + ]); + expect(result.title.map((title: any) => title.text)).toEqual([ + 'Query 1, pod="a"', + 'Query 2, pod="a"', + 'Query 2, pod="b"', + ]); + }); + + it("keeps a single histogram on the single-grid layout", () => { + const result = convert([query(pod("a", [2, 5]))]); + expect(result.title).toBeUndefined(); + expect(Array.isArray(result.grid)).toBe(false); + expect(result.yAxis.data).toEqual(["1.00", "+Inf"]); + expect(result.grid).toEqual({ left: "3%", right: "4%", bottom: 60, containLabel: true }); + }); + + it("drops the quietest histograms first once the stack exceeds the cell budget", () => { + const ts = Array.from({ length: 100 }, (_unused, i) => 60 * (i + 1)); + const ladder = (scale: number) => Array.from({ length: 300 }, (_unused, i) => (i + 1) * scale); + const extras: any = { legends: [] }; + const result = convert( + [ + query( + [...pod("a", ladder(1), ts), ...pod("b", ladder(3), ts), ...pod("c", ladder(2), ts)], + ts, + ), + ], + histogramConfig, + extras, + ); + + // 300 buckets x 100 columns is 30,000 cells, so one grid fits the 50,000 budget and two do not. + expect(result.title).toBeUndefined(); + expect(Array.isArray(result.yAxis)).toBe(false); + expect(result.yAxis.data).toHaveLength(300); + expect(result.xAxis[0].data).toHaveLength(100); + expect(extras.limitNumberOfSeriesWarningMessage).toBe( + "Showing the 1 busiest of 3 histograms to stay within the heatmap cell limit", + ); + expect(result.series[0].data.find((cell: any) => cell[1] === 0)[2]).toBe(3); + }); + + it("keeps the busiest histograms in their original order", () => { + const ts = Array.from({ length: 60 }, (_unused, i) => 60 * (i + 1)); + const ladder = (scale: number) => Array.from({ length: 300 }, (_unused, i) => (i + 1) * scale); + const extras: any = { legends: [] }; + const result = convert( + [ + query( + [...pod("a", ladder(3), ts), ...pod("b", ladder(1), ts), ...pod("c", ladder(2), ts)], + ts, + ), + ], + histogramConfig, + extras, + ); + + expect(result.title.map((title: any) => title.text)).toEqual(['pod="a"', 'pod="c"']); + expect(result.yAxis.map((axis: any) => axis.data.length)).toEqual([300, 300]); + expect(extras.limitNumberOfSeriesWarningMessage).toBe( + "Showing the 2 busiest of 3 histograms to stay within the heatmap cell limit", + ); + }); +}); + +describe("PromQL heatmap time interaction and exemplar layout", () => { + const store = { state: { timezone: "UTC", theme: "light" } }; + const iso = (ts: number) => new Date(ts * 1000).toISOString().slice(0, -1); + const series = (name: string, metric: Record, data: Record) => ({ + name, + metric, + values: [], + data, + }); + const query = (list: any[], ts: number[], extra: Record = {}) => + ({ series: list, timestamps: ts.map((t) => [t, iso(t)]), queryIndex: 0, ...extra }) as any; + const context = { + metadata: { queries: [{ startTime: 0, endTime: 180 * 1e6 }] }, + stepSeconds: 60, + }; + const convert = (processed: any[], config: any = {}, extras: any = { legends: [] }) => ({ + options: new HeatmapConverter().convert( + processed, + { id: "p1", config }, + store, + extras, + { value: { offsetHeight: 500 } }, + context as any, + ), + extras, + }); + + it("arms drag-to-zoom and cursor sync over the step-wide columns", () => { + const { options, extras } = convert([ + query([series("a", { pod: "a" }, { 60: "1", 120: "2" })], [60, 120]), + ]); + expect(extras.panelId).toBe("p1"); + expect(extras.isTimeSeries).toBe(true); + expect(extras.heatmapTimeAxis.endInclusive).toBe(true); + expect(extras.heatmapTimeAxis.columns.map((c: any) => c.x)).toEqual([0, 60, 120, 180].map(iso)); + expect(options.toolbox.feature.dataZoom.yAxisIndex).toBe("none"); + expect(options.tooltip.axisPointer).toBeUndefined(); + expect(options.xAxis[0].axisPointer).toMatchObject({ show: true, triggerTooltip: false }); + }); + + it("does the same for a histogram heatmap", () => { + const { options, extras } = convert( + [ + query( + [series("1", { le: "1" }, { 60: "1" }), series("inf", { le: "+Inf" }, { 60: "3" })], + [60], + ), + ], + { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE }, + ); + expect(extras.heatmapTimeAxis.columns).toHaveLength(4); + expect(options.toolbox).toBeDefined(); + }); + + it("blanks the tooltip while another panel is hovered, so only the synced axis pointer shows", () => { + const hovered = { value: { panelId: "other" } }; + const { options } = convert( + [query([series("a", {}, { 60: "1" })], [60])], + {}, + { legends: [], hoveredSeriesState: hovered }, + ); + const params = { value: [1, 0, 1], name: "00:01:00", marker: "" }; + expect(options.tooltip.formatter(params)).toBe(""); + hovered.value.panelId = "p1"; + expect(options.tooltip.formatter(params)).toContain("a"); + }); + + it("records each drawn row's query and labels in row order, without __name__, and no source for a shifted row", () => { + const { extras } = convert( + [ + query([series("b", { __name__: "up", pod: "b" }, { 60: "1" })], [60]), + query([series("a", { pod: "a" }, { 60: "1" })], [60], { queryIndex: 1 }), + query([series("a (1d ago)", { pod: "a" }, { 60: "1" })], [60], { + queryIndex: 1, + seriesRole: "shifted", + }), + ], + { heatmap_y_sort: "asc" }, + ); + expect(extras.heatmapExemplarLayout).toEqual({ + mode: "series", + rows: [ + { queryIndex: 1, labels: { pod: "a" } }, + null, + { queryIndex: 0, labels: { pod: "b" } }, + ], + }); + }); + + it("records each drawn histogram's labels and ascending le bounds", () => { + const bucket = (pod: string, le: string, value: string) => + series(`${pod}-${le}`, { __name__: "x_bucket", pod, le }, { 60: value }); + const { extras } = convert( + [query([bucket("a", "+Inf", "4"), bucket("a", "0.5", "1"), bucket("b", "1", "2")], [60])], + { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE }, + ); + expect(extras.heatmapExemplarLayout).toEqual({ + mode: "histogram", + groups: [ + { queryIndex: 0, labels: { pod: "a" }, bounds: [0.5, Infinity] }, + { queryIndex: 0, labels: { pod: "b" }, bounds: [1] }, + ], + }); + }); +}); + +describe("heatmap axis border", () => { + const store = { state: { timezone: "UTC", theme: "light" } }; + const processed = [ + { + series: [ + { name: "1", metric: { le: "1" }, values: [], data: { 60: "2" } }, + { name: "+Inf", metric: { le: "+Inf" }, values: [], data: { 60: "5" } }, + ], + timestamps: [[60, "1970-01-01T00:01:00"]], + queryIndex: 0, + } as any, + ]; + const axisLines = (config: any) => { + const result = new HeatmapConverter().convert(processed, { config }, store, { legends: [] }); + return [result.xAxis[0].axisLine, result.yAxis.axisLine]; + }; + + it.each([ + ["a generic heatmap", {}], + ["a single histogram", { heatmap_mode: PROMETHEUS_HISTOGRAM_MODE }], + ])("keeps the default axis lines of %s whatever the stored toggle", (_name, config) => { + expect(axisLines({ ...config, axis_border_show: true })).toEqual([undefined, undefined]); + expect(axisLines({ ...config, axis_border_show: false })).toEqual([undefined, undefined]); + expect(axisLines(config)).toEqual([undefined, undefined]); + }); +}); diff --git a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts index 80abca780e..90c92c2348 100644 --- a/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts +++ b/web/src/utils/dashboard/promql/convertPromQLHeatmapChart.ts @@ -13,24 +13,94 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { PromQLChartConverter, ProcessedPromQLData, TOOLTIP_SCROLL_STYLE } from "./shared/types"; +import { + ConversionContext, + PromQLChartConverter, + ProcessedPromQLData, + TOOLTIP_SCROLL_STYLE, +} from "./shared/types"; import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue"; import { chartColor } from "@/utils/chartTheme"; import { escapeHtml } from "@/utils/html"; -import { deaccumulateHistogramSeries, HistogramSeriesInput } from "./shared/histogramBuckets"; import { + deaccumulateHistogramSeries, + groupHistogramSeries, + HistogramSeriesOutput, + PROMETHEUS_HISTOGRAM_MODE, +} from "./shared/histogramBuckets"; +import { formatTimestamps } from "./shared/dataProcessor"; +import { formatDate } from "../dateTimeUtils"; +import { + HEATMAP_MAX_CELLS, HEATMAP_SPLIT_AREA, - HEATMAP_VISUAL_MAP_COLORS, + addHeatmapWarning, + cutHeatmapToBudget, + cutHistogramsToBudget, + heatmapBudgetWarning, heatmapCellItemStyle, + heatmapColorRange, + heatmapColorScale, heatmapLargeGridDefaults, heatmapValueLabel, + histogramBudgetWarnings, + visualMapRangeOf, } from "../heatmapDefaults"; +import { sortHeatmapCategories } from "../heatmapYAxis"; +import { + applyHeatmapTimePointer, + heatmapZoomToolbox, + promqlHeatmapTimeAxis, +} from "../heatmapTimeAxis"; +import type { HeatmapExemplarGroup, HeatmapExemplarLayout } from "../exemplars/heatmapExemplars"; +import { gt } from "@/types/i18n"; +import { addCommasToNumber } from "@/utils/formatters"; -/** - * Opt-in heatmap mode that treats the queried series as the cumulative buckets - * of a Prometheus classic histogram (`sum by(le) (rate(x_bucket[W]))`). - */ -export const PROMETHEUS_HISTOGRAM_MODE = "prometheus_histogram"; +export { PROMETHEUS_HISTOGRAM_MODE }; + +const STACK_TITLE_PX = 20; + +const STACK_BOTTOM_PX = 60; + +const STACK_GAP_PX = 6; + +const STACK_X_LABEL_PX = 24; + +const STACK_MIN_GRID_PX = 30; + +const UNMEASURED_PANEL_PX = 400; + +interface PromQLTimeGrid { + startSec: number; + endSec: number; + stepSec: number; +} + +interface HeatmapGrid { + data: any[]; + rows: number[]; + timeAxis: Array<[any, any]>; + min: number; + max: number; +} + +interface HeatmapCells { + data: any[]; + min: number; + max: number; +} + +interface HistogramGroupBuckets { + label: string; + source: Omit | null; + buckets: HistogramSeriesOutput[]; +} + +interface HistogramGrid { + label: string; + bucketLabels: string[]; + bucketValues: string[]; + data: any[]; +} /** * The X axis for a heatmap: every timestamp any query reported, ascending. @@ -40,7 +110,11 @@ export const PROMETHEUS_HISTOGRAM_MODE = "prometheus_histogram"; * different grids, later samples get looked up against query 0's timestamps and * read as 0. The union degrades to the truth instead of to blankness. */ -function buildTimeAxis(processedData: ProcessedPromQLData[]): Array<[any, any]> { +function buildTimeAxis( + processedData: ProcessedPromQLData[], + grid?: PromQLTimeGrid | null, + timezone?: string, +): Array<[any, any]> { const byTs = new Map(); for (const queryData of processedData ?? []) { @@ -52,53 +126,444 @@ function buildTimeAxis(processedData: ProcessedPromQLData[]): Array<[any, any]> } } + if (grid && byTs.size > 0) { + const missing = new Set(); + for (const ts of gridTimestamps(Number(byTs.keys().next().value), grid)) { + if (!byTs.has(String(ts))) missing.add(ts); + } + for (const entry of formatTimestamps(missing, timezone ?? "UTC")) { + byTs.set(String(entry[0]), entry); + } + } + return [...byTs.values()].sort((a, b) => Number(a[0]) - Number(b[0])); } -/** - * The `visualMap` range for a set of cell values. - * - * Zero is the floor whenever the data is non-negative — which is the ordinary - * case, and the only case for a de-accumulated histogram, whose cells are counts - * clamped at zero. But a heatmap of a metric that is negative throughout (a - * delta, a `deriv()`, a temperature) has a max below zero, and a hardcoded floor - * of 0 handed ECharts the inverted range [0, negative]: every cell came out the - * same colour. Both paths ask this one question so neither can answer it wrongly - * on its own. - */ -function visualMapRangeOf(minValue: number, maxValue: number): { min: number; max: number } { +/** Every evaluation instant of `grid` on the step lattice through `anchor`, capped to the cell budget's column count. */ +function gridTimestamps(anchor: number, grid: PromQLTimeGrid): number[] { + const { startSec, endSec, stepSec } = grid; + let first = anchor - Math.floor((anchor - startSec) / stepSec) * stepSec; + const last = anchor + Math.floor((endSec - anchor) / stepSec) * stepSec; + if ((last - first) / stepSec + 1 > HEATMAP_MAX_CELLS) { + first = last - (HEATMAP_MAX_CELLS - 1) * stepSec; + } + const timestamps: number[] = []; + for (let ts = first; ts <= last; ts += stepSec) timestamps.push(ts); + return timestamps; +} + +function timeGridOf(context?: ConversionContext): PromQLTimeGrid | null { + const startSec = Number(context?.metadata?.queries?.[0]?.startTime) / 1_000_000; + const endSec = Number(context?.metadata?.queries?.[0]?.endTime) / 1_000_000; + const stepSec = Number(context?.stepSeconds); + if (!(stepSec > 0) || !Number.isFinite(startSec) || !Number.isFinite(endSec)) return null; + return endSec >= startSec ? { startSec, endSec, stepSec } : null; +} + +/** The summed magnitude of a series' samples, which ranks its row for the cell budget so strongly negative rows count as strong. */ +function sampleMagnitude(data: Record): number { + let total = 0; + for (const raw of Object.values(data ?? {})) { + const value = sampleValue(raw); + if (value !== "-") total += Math.abs(value); + } + return total; +} + +/** A sample as a cell value; a missing or non-numeric sample is an empty cell, never 0. */ +function sampleValue(raw: unknown): number | "-" { + if (raw === null || raw === undefined || raw === "") return "-"; + const value = typeof raw === "number" ? raw : parseFloat(String(raw)); + return Number.isFinite(value) ? value : "-"; +} + +/** The cells of a `rowCount` x `timeAxis` grid, cut to the cell budget before any cell is allocated. */ +function buildHeatmapGrid( + rowTotals: number[], + timeAxis: Array<[any, any]>, + valueAt: (row: number, ts: any) => number | "-", + extras: any, +): HeatmapGrid { + const cut = cutHeatmapToBudget(timeAxis.length, rowTotals); + if (cut) addHeatmapWarning(extras, heatmapBudgetWarning(cut, timeAxis.length, rowTotals.length)); + const rows = cut ? cut.rows : rowTotals.map((_total, row) => row); + const keptAxis = cut ? timeAxis.slice(cut.firstColumn) : timeAxis; + return { ...fillHeatmapCells(rows, keptAxis, valueAt), rows, timeAxis: keptAxis }; +} + +function fillHeatmapCells( + rows: number[], + timeAxis: Array<[any, any]>, + valueAt: (row: number, ts: any) => number | "-", +): HeatmapCells { + const data: any[] = []; + let min = Infinity; + let max = -Infinity; + rows.forEach((row, rowIndex) => { + timeAxis.forEach(([ts], timeIndex) => { + const value = valueAt(row, ts); + data.push([timeIndex, rowIndex, value]); + if (value === "-") return; + if (value > max) max = value; + if (value < min) min = value; + }); + }); + return { data, min, max }; +} + +/** Each query's histograms, split by their non-`le` labels and de-accumulated on their own, in query order; titles name the query once there are several. */ +function histogramGroupsOf(processedData: ProcessedPromQLData[]): HistogramGroupBuckets[] { + return processedData.flatMap((queryData, queryPosition) => + groupHistogramSeries(queryData.series ?? []).map((group) => ({ + label: [ + [ + processedData.length > 1 + ? gt("common.queryNumber", { index: (queryData.queryIndex ?? queryPosition) + 1 }) + : "", + group.label, + ] + .filter(Boolean) + .join(", "), + queryData.nameSuffix ? `(${queryData.nameSuffix})` : "", + ] + .filter(Boolean) + .join(" "), + source: + queryData.seriesRole === "shifted" + ? null + : { queryIndex: queryData.queryIndex ?? queryPosition, labels: group.labels }, + buckets: deaccumulateHistogramSeries( + group.series.map((seriesData) => ({ + le: extractLeLabel(seriesData), + data: seriesData.data ?? {}, + })), + ), + })), + ); +} + +/** The sum of every bucket of a histogram, which ranks it against the others for the cell budget. */ +function histogramTotal(group: HistogramGroupBuckets): number { + let total = 0; + for (const bucket of group.buckets) { + for (const value of Object.values(bucket.data)) total += value; + } + return total; +} + +/** Y-axis labels for a histogram's buckets in ascending `le` order, widened until unique as ECharts category axes require. */ +function uniqueBucketLabels(buckets: HistogramSeriesOutput[], config: any): string[] { + const usedLabels = new Set(); + return buckets.map((bucket) => { + const base = formatBucketLabel(bucket.le, bucket.leValue, config); + let label = base; + if (usedLabels.has(label)) label = `${base} (${bucket.le})`; + for (let n = 2; usedLabels.has(label); n++) { + label = `${base} (${bucket.le}) #${n}`; + } + usedLabels.add(label); + return label; + }); +} + +/** How many stacked histogram grids fit a `heightPx` panel with every grid at least STACK_MIN_GRID_PX tall. */ +function maxStackedGrids(heightPx: number): number { + const usable = heightPx - STACK_BOTTOM_PX - STACK_X_LABEL_PX; + return Math.max(1, Math.floor(usable / (STACK_TITLE_PX + STACK_MIN_GRID_PX + STACK_GAP_PX))); +} + +function stackedGridLayout( + count: number, + heightPx: number, +): Array<{ titleTop: number; gridTop: number; height: number }> { + const pct = (px: number) => (px / heightPx) * 100; + const slot = (heightPx - STACK_BOTTOM_PX - STACK_X_LABEL_PX) / count; + const gridPx = Math.max(slot - STACK_TITLE_PX - STACK_GAP_PX, STACK_MIN_GRID_PX); + // Only the bottom grid shows time labels, and containLabel takes their height out of its own. + return Array.from({ length: count }, (_unused, index) => ({ + titleTop: pct(index * slot), + gridTop: pct(index * slot + STACK_TITLE_PX), + height: pct(gridPx + (index === count - 1 ? STACK_X_LABEL_PX : 0)), + })); +} + +/** The groups (indices, in order) a panel can stack: the busiest ones that fit its height. */ +function groupsThatFit(groups: HistogramGroupBuckets[], heightPx: number): number[] { + const all = groups.map((_group, index) => index); + const fit = maxStackedGrids(heightPx); + if (all.length <= fit) return all; + return all + .sort((a, b) => histogramTotal(groups[b]) - histogramTotal(groups[a]) || a - b) + .slice(0, fit) + .sort((a, b) => a - b); +} + +function warnDroppedHistograms( + processedData: ProcessedPromQLData[], + shownForHeight: number, + groupCount: number, + extras: any, +): void { + let shown = 0; + let total = 0; + for (const queryData of processedData) { + if (!queryData.histogramsLimited) continue; + shown += queryData.histogramsLimited.shown; + total += queryData.histogramsLimited.total; + } + if (total > shown) { + addHeatmapWarning( + extras, + gt("dashboard.utils.heatmapHistogramsSeriesLimited", { + shown: addCommasToNumber(shown), + total: addCommasToNumber(total), + }), + ); + } + if (shownForHeight < groupCount) { + addHeatmapWarning( + extras, + gt("dashboard.utils.heatmapHistogramsHeightCapped", { + shown: addCommasToNumber(shownForHeight), + total: addCommasToNumber(groupCount), + }), + ); + } +} + +/** The heatmap series for one histogram grid; `cellCount` is the whole chart's, which decides chunked rendering. */ +function histogramSeries(data: any[], cellCount: number, store: any): any { return { - min: Number.isFinite(minValue) ? Math.min(0, minValue) : 0, - max: Number.isFinite(maxValue) ? maxValue : 0, + type: "heatmap", + data, + ...heatmapLargeGridDefaults(cellCount), + // Histogram heatmaps are far too dense for per-cell labels. + label: { + show: false, + }, + itemStyle: heatmapCellItemStyle(store), + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, 0.5)", + }, + }, }; } -/** - * Format timestamps to extract the time portion (consistent with bar charts) - */ +function histogramVisualMap( + config: any, + range: { min: number; max: number }, + grids: HistogramGrid[], +): any { + const { color, ...scaledRange } = heatmapColorScale( + config, + range, + grids.map((grid) => grid.data), + ); + return { + ...scaledRange, + calculable: true, + orient: "horizontal", + left: "center", + inRange: { color }, + }; +} + +/** The histogram tooltip; `rowTitle` names the hovered cell's row, as HTML-safe text. */ +function histogramTooltip(config: any, rowTitle: (params: any) => string): any { + return { + position: "top", + // Rendered into so the panel's overflow does not clip it. + appendToBody: true, + // ChartRenderer's mouseleave skips this class, so entering the tooltip does not reset every panel's hover. + className: "o2-echarts-tooltip", + textStyle: { + color: chartColor("--color-tooltip-text"), + fontSize: 12, + }, + enterable: true, + backgroundColor: chartColor("--color-tooltip-bg"), + extraCssText: TOOLTIP_SCROLL_STYLE, + formatter: (params: any) => { + try { + // config.unit remains the CELL INTENSITY unit (e.g. count/s). + const value = + formatUnitValue( + getUnitValue(params?.value?.[2], config?.unit, config?.unit_custom, config?.decimals), + ) || params?.value?.[2]; + return `${rowTitle(params)}
${params?.marker} ${escapeHtml(params?.name)} : ${escapeHtml(value)}`; + } catch (error) { + return ""; + } + }, + }; +} + +/** A single histogram grid, the layout every one-histogram panel has always had. */ +function singleHistogramOptions( + grid: HistogramGrid, + xAxisData: string[], + visualMapRange: { min: number; max: number }, + config: any, + store: any, +): any { + return { + series: [histogramSeries(grid.data, grid.data.length, store)], + xAxis: [ + { + type: "category", + data: xAxisData, + splitArea: HEATMAP_SPLIT_AREA, + }, + ], + yAxis: { + type: "category", + data: grid.bucketLabels, + splitArea: HEATMAP_SPLIT_AREA, + axisLabel: { + overflow: "truncate", + width: config.axis_width || 150, + }, + }, + visualMap: histogramVisualMap(config, visualMapRange, [grid]), + tooltip: histogramTooltip( + config, + (params) => `le ${escapeHtml(grid.bucketLabels[params?.value?.[1]] ?? params?.seriesName)}`, + ), + grid: { + left: "3%", + right: "4%", + bottom: 60, + containLabel: true, + }, + }; +} + +function stackedHistogramOptions( + grids: HistogramGrid[], + xAxisData: string[], + visualMapRange: { min: number; max: number }, + config: any, + store: any, + heightPx: number, +): any { + const layout = stackedGridLayout(grids.length, heightPx); + const cellCount = grids.reduce((count, grid) => count + grid.data.length, 0); + const last = grids.length - 1; + return { + title: grids.map((grid, index) => ({ + text: grid.label, + top: `${layout[index].titleTop}%`, + left: "3%", + textStyle: { fontSize: 12, fontWeight: "bold" }, + })), + grid: layout.map((cell) => ({ + left: "3%", + right: "4%", + top: `${cell.gridTop}%`, + height: `${cell.height}%`, + containLabel: true, + })), + xAxis: grids.map((_grid, index) => ({ + type: "category", + gridIndex: index, + data: xAxisData, + splitArea: HEATMAP_SPLIT_AREA, + // The grids share one time axis, so only the bottom one spends height on its labels. + axisLabel: { show: index === last }, + })), + yAxis: grids.map((grid, index) => ({ + type: "category", + gridIndex: index, + data: grid.bucketLabels, + splitArea: HEATMAP_SPLIT_AREA, + axisLabel: { + overflow: "truncate", + width: config.axis_width || 150, + }, + })), + series: grids.map((grid, index) => ({ + // Unnamed, so no legend entry overlaps the colour bar; each grid's title names it. + ...histogramSeries(grid.data, cellCount, store), + xAxisIndex: index, + yAxisIndex: index, + })), + visualMap: histogramVisualMap(config, visualMapRange, grids), + tooltip: histogramTooltip(config, (params) => { + const grid = grids[params?.seriesIndex]; + const bucket = grid?.bucketLabels[params?.value?.[1]] ?? params?.seriesName; + return `${escapeHtml(grid?.label ?? "")}
le ${escapeHtml(bucket)}`; + }), + }; +} + +/** The calendar day of a formatted axis timestamp, in the zone it was formatted for. */ +function dayOf(formatted: Date | string): string { + if (formatted instanceof Date) { + return `${formatted.getFullYear()}-${formatted.getMonth()}-${formatted.getDate()}`; + } + return String(formatted).slice(0, 10); +} + +/** X labels: the time of day, or the full date and time when the axis spans more than one calendar day. */ function buildXAxisData(timestamps: Array<[any, any]>): string[] { + const days = new Set((timestamps ?? []).map(([, formatted]) => dayOf(formatted))); + const withDate = days.size > 1; return ( timestamps?.map(([, formatted]) => { - // formatted can be Date object or ISO string - let timeString: string; if (formatted instanceof Date) { - // Format as HH:MM:SS - const hours = String(formatted.getHours()).padStart(2, "0"); - const minutes = String(formatted.getMinutes()).padStart(2, "0"); - const seconds = String(formatted.getSeconds()).padStart(2, "0"); - timeString = `${hours}:${minutes}:${seconds}`; - } else { - // ISO string - extract time portion - const dateStr = formatted.toString(); - // Try to extract time (HH:MM:SS) from datetime string - const timeMatch = dateStr.match(/(\d{2}:\d{2}:\d{2})/); - timeString = timeMatch ? timeMatch[1] : dateStr; + return withDate ? formatDate(formatted) : formatDate(formatted).slice(11); } - return timeString; + const dateStr = formatted.toString(); + const match = dateStr.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/); + if (match) return withDate ? `${match[1]} ${match[2]}` : match[2]; + return dateStr.match(/(\d{2}:\d{2}:\d{2})/)?.[1] ?? dateStr; }) || [] ); } +/** True while another panel is hovered, when a synced tooltip shows only its axis pointer. */ +function otherPanelHovered(extras: any, panelId: unknown): boolean { + const state = extras?.hoveredSeriesState?.value; + return !!(state && panelId && state.panelId !== panelId); +} + +function applyTimeInteraction( + options: any, + extras: any, + panelId: unknown, + timestamps: Array<[any, any]>, + grid: PromQLTimeGrid | null, + context: ConversionContext | undefined, + store: any, +): any { + const axis = promqlHeatmapTimeAxis( + timestamps, + grid?.stepSec ?? Number(context?.stepSeconds), + grid?.startSec ?? Number(context?.metadata?.queries?.[0]?.startTime) / 1_000_000, + store?.state?.timezone ?? "UTC", + ); + extras.panelId = panelId; + if (!axis) return options; + extras.heatmapTimeAxis = axis; + extras.isTimeSeries = true; + options.toolbox = heatmapZoomToolbox(); + const formatter = options.tooltip?.formatter; + options.tooltip = { + ...options.tooltip, + formatter: (params: any) => (otherPanelHovered(extras, panelId) ? "" : formatter?.(params)), + }; + applyHeatmapTimePointer(options); + return options; +} + +/** Labels that identify a series' row, without the metric name a function result drops. */ +function rowLabels(metric: Record | undefined): Record { + const { __name__: _name, ...labels } = metric ?? {}; + return labels; +} + /** * Extract the `le` label for a series. * Prefers the parsed `metric.le` label; when the metric labels are absent @@ -163,12 +628,13 @@ function applyCompactPreview(options: any): any { precision: 2, textStyle: { fontSize: 9 }, }; - options.grid = { ...(options.grid ?? {}), top: 8, bottom: 48 }; + if (!Array.isArray(options.grid)) + options.grid = { ...(options.grid ?? {}), top: 8, bottom: 48 }; } - const yAxis = Array.isArray(options.yAxis) ? options.yAxis[0] : options.yAxis; - const rowCount = yAxis?.data?.length ?? 0; - if (yAxis && rowCount) { + for (const yAxis of Array.isArray(options.yAxis) ? options.yAxis : [options.yAxis]) { + const rowCount = yAxis?.data?.length ?? 0; + if (!yAxis || !rowCount) continue; // Thin the bucket labels to what a card can render, but ALWAYS keep the top // row: a histogram's `+Inf` bucket is the "everything slower than this" row, // and ECharts' hideOverlap would otherwise drop it. Count DOWN from the top @@ -184,8 +650,8 @@ function applyCompactPreview(options: any): any { }; } - const xAxis = Array.isArray(options.xAxis) ? options.xAxis[0] : options.xAxis; - if (xAxis) { + for (const xAxis of Array.isArray(options.xAxis) ? options.xAxis : [options.xAxis]) { + if (!xAxis) continue; xAxis.axisLabel = { ...(xAxis.axisLabel ?? {}), fontSize: 9, @@ -203,56 +669,70 @@ function applyCompactPreview(options: any): any { export class HeatmapConverter implements PromQLChartConverter { supportedTypes = ["heatmap"]; - convert(processedData: ProcessedPromQLData[], panelSchema: any, store: any, extras: any) { + convert( + processedData: ProcessedPromQLData[], + panelSchema: any, + store: any, + extras: any, + chartPanelRef?: any, + context?: ConversionContext, + ) { const config = panelSchema.config || {}; + const grid = timeGridOf(context); // Opt-in only: Prometheus classic-histogram mode. Generic heatmaps keep // their existing behavior untouched. if (config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE) { - return this.convertHistogram(processedData, config, store, extras); + return this.convertHistogram( + processedData, + panelSchema, + store, + extras, + grid, + chartPanelRef, + context, + ); } - // Heatmap requires 3D data: [x, y, value] - // X-axis: timestamps - // Y-axis: series names - // Value: metric values + // Rows span every query because per-query series indices overlap. + const sourceOf = new Map(); + const allSeries = sortHeatmapCategories( + processedData.flatMap((queryData) => { + for (const series of queryData.series) sourceOf.set(series, queryData); + return queryData.series; + }), + (series) => series.name, + config?.heatmap_y_sort, + ); + const { + data, + rows, + timeAxis, + min: minValue, + max: maxValue, + } = buildHeatmapGrid( + allSeries.map((series) => sampleMagnitude(series.data)), + buildTimeAxis(processedData, grid, store?.state?.timezone), + (row, ts) => sampleValue(allSeries[row].data[ts]), + extras, + ); + const seriesNames = rows.map((row) => allSeries[row].name); + seriesNames.forEach((name) => extras.legends.push(name)); + extras.heatmapRowValues = [seriesNames]; + extras.heatmapExemplarLayout = { + mode: "series", + rows: rows.map((row) => { + const source = sourceOf.get(allSeries[row]); + if (!source || source.seriesRole === "shifted") return null; + return { queryIndex: source.queryIndex, labels: rowLabels(allSeries[row].metric) }; + }), + } satisfies HeatmapExemplarLayout; - const seriesNames: string[] = []; - const data: any[] = []; - let maxValue = -Infinity; - let minValue = Infinity; - - // Shared across every query, so a cell's column is its INSTANT — not its - // offset within whichever query happened to report it. - const timeAxis = buildTimeAxis(processedData); - const columnOfTs = new Map(timeAxis.map(([ts], index) => [String(ts), index])); - - processedData.forEach((queryData) => { - queryData.series.forEach((seriesData) => { - seriesNames.push(seriesData.name); - extras.legends.push(seriesData.name); - - // The row is the series' position on the Y AXIS — which spans every - // query — not its position within its own query (per-query indices - // overlap when there are multiple queries). - const rowIndex = seriesNames.length - 1; - - queryData.timestamps.forEach(([ts]) => { - // The column is this timestamp's slot on the SHARED axis, not the - // per-query loop index (which diverges when queries use different grids). - const timeIndex = columnOfTs.get(String(ts)); - if (timeIndex === undefined) return; - - const value = parseFloat(seriesData.data[ts] ?? "0"); - data.push([timeIndex, rowIndex, value]); - - if (value > maxValue) maxValue = value; - if (value < minValue) minValue = value; - }); - }); - }); - - const visualMapRange = visualMapRangeOf(minValue, maxValue); + const { color, ...visualMapRange } = heatmapColorScale( + config, + heatmapColorRange(config, visualMapRangeOf(minValue, maxValue)), + [data], + ); // Format timestamps to extract time portion (consistent with bar charts) const xAxisData = buildXAxisData(timeAxis); @@ -316,7 +796,7 @@ export class HeatmapConverter implements PromQLChartConverter { orient: "horizontal", left: "center", inRange: { - color: HEATMAP_VISUAL_MAP_COLORS, + color, }, }, tooltip: { @@ -324,6 +804,8 @@ export class HeatmapConverter implements PromQLChartConverter { // render into so the tooltip is not clipped by the panel's // overflow — same container treatment as the other PromQL charts appendToBody: true, + // ChartRenderer's mouseleave skips this class, so entering the tooltip does not reset every panel's hover. + className: "o2-echarts-tooltip", textStyle: { color: chartColor("--color-tooltip-text"), fontSize: 12, @@ -357,6 +839,7 @@ export class HeatmapConverter implements PromQLChartConverter { }, }; + applyTimeInteraction(options, extras, panelSchema.id, timeAxis, grid, context, store); return config?.compact_preview ? applyCompactPreview(options) : options; } @@ -370,153 +853,81 @@ export class HeatmapConverter implements PromQLChartConverter { */ private convertHistogram( processedData: ProcessedPromQLData[], - config: any, + panelSchema: any, store: any, extras: any, + grid: PromQLTimeGrid | null, + chartPanelRef?: any, + context?: ConversionContext, ) { - // De-accumulate PER QUERY, then concatenate. deaccumulateHistogramSeries - // sorts and de-duplicates by `le`, so flattening all queries first would - // interleave two histograms' buckets. Each query is its own cumulative - // histogram. - const buckets = processedData.flatMap((queryData) => { - const inputs: HistogramSeriesInput[] = (queryData.series ?? []).map((seriesData) => ({ - le: extractLeLabel(seriesData), - data: seriesData.data ?? {}, - })); - return deaccumulateHistogramSeries(inputs); - }); + const config = panelSchema.config || {}; + const groups = histogramGroupsOf(processedData); + const timeAxis = buildTimeAxis(processedData, grid, store?.state?.timezone); - // Y-axis categories: formatted bucket bounds, in ascending le order. - // Formatting can collapse two distinct bounds onto the same label (e.g. - // rounding), and ECharts category axes need unique names, so disambiguate - // collisions with the raw le value. - const usedLabels = new Set(); - const bucketLabels: string[] = buckets.map((bucket) => { - const base = formatBucketLabel(bucket.le, bucket.leValue, config); - - // ECharts category axes need UNIQUE names, but two bounds can round to the - // same label and two histograms can carry the identical `le`. Keep widening - // until it is unique. - let label = base; - if (usedLabels.has(label)) label = `${base} (${bucket.le})`; - for (let n = 2; usedLabels.has(label); n++) { - label = `${base} (${bucket.le}) #${n}`; + const heightPx = chartPanelRef?.value?.offsetHeight || UNMEASURED_PANEL_PX; + const fitting = groupsThatFit(groups, heightPx).map((index) => groups[index]); + warnDroppedHistograms(processedData, fitting.length, groups.length, extras); + const cut = cutHistogramsToBudget( + timeAxis.length, + fitting.map((group) => group.buckets.length), + fitting.map(histogramTotal), + ); + if (cut) { + for (const warning of histogramBudgetWarnings(cut, timeAxis.length, fitting.length)) { + addHeatmapWarning(extras, warning); } + } + const kept = cut ? cut.groups.map((index) => fitting[index]) : fitting; + const timestamps = cut ? timeAxis.slice(cut.firstColumn) : timeAxis; - usedLabels.add(label); - return label; - }); - - bucketLabels.forEach((label) => extras.legends.push(label)); - - // Every query's timestamps, not just the first query's — the buckets above - // were flat-mapped across ALL of them, and looking their samples up against - // one query's grid is what dropped them. - const timestamps = buildTimeAxis(processedData); - - const data: any[] = []; - let maxValue = -Infinity; let minValue = Infinity; - - buckets.forEach((bucket, bucketIndex) => { - timestamps.forEach(([ts], timeIndex) => { - const value = bucket.data[String(ts)] ?? 0; - data.push([timeIndex, bucketIndex, value]); - if (value > maxValue) maxValue = value; - if (value < minValue) minValue = value; - }); + let maxValue = -Infinity; + const grids: HistogramGrid[] = kept.map((group) => { + const cells = fillHeatmapCells( + group.buckets.map((_bucket, row) => row), + timestamps, + (row, ts) => sampleValue(group.buckets[row].data[String(ts)]), + ); + minValue = Math.min(minValue, cells.min); + maxValue = Math.max(maxValue, cells.max); + return { + label: group.label, + bucketLabels: uniqueBucketLabels(group.buckets, config), + bucketValues: group.buckets.map((bucket) => bucket.le), + data: cells.data, + }; }); + for (const histogram of grids) { + for (const label of histogram.bucketLabels) extras.legends.push(label); + } + extras.heatmapRowValues = grids.map((histogram) => histogram.bucketValues); + extras.heatmapExemplarLayout = { + mode: "histogram", + groups: kept.map((group) => + group.source + ? { ...group.source, bounds: group.buckets.map((bucket) => bucket.leValue) } + : null, + ), + } satisfies HeatmapExemplarLayout; // [0, max] for clamped counts, unless heatmaps drawn side by side share a range so colours compare. - const visualMapRange = config?.visual_map_range ?? visualMapRangeOf(minValue, maxValue); - + const visualMapRange = heatmapColorRange( + config, + config?.visual_map_range ?? visualMapRangeOf(minValue, maxValue), + ); const xAxisData = buildXAxisData(timestamps); - const options: any = { - series: [ - { - type: "heatmap", - data, - // Dense by construction: chunk the render and skip the animation once - // the grid gets big. - ...heatmapLargeGridDefaults(data.length), - // Histogram heatmaps are far too dense for per-cell labels. - label: { - show: false, - }, - itemStyle: heatmapCellItemStyle(store), - emphasis: { - itemStyle: { - shadowBlur: 10, - shadowColor: "rgba(0, 0, 0, 0.5)", - }, - }, - }, - ], - xAxis: [ - { - type: "category", - data: xAxisData, - splitArea: HEATMAP_SPLIT_AREA, - }, - ], - yAxis: { - type: "category", - data: bucketLabels, - splitArea: HEATMAP_SPLIT_AREA, - axisLabel: { - overflow: "truncate", - width: config.axis_width || 150, - }, - }, - visualMap: { - ...visualMapRange, - calculable: true, - orient: "horizontal", - left: "center", - inRange: { - color: HEATMAP_VISUAL_MAP_COLORS, - }, - }, - tooltip: { - position: "top", - // render into so the tooltip is not clipped by the panel's - // overflow — same container treatment as the other PromQL charts - appendToBody: true, - textStyle: { - color: chartColor("--color-tooltip-text"), - fontSize: 12, - }, - enterable: true, - backgroundColor: chartColor("--color-tooltip-bg"), - extraCssText: TOOLTIP_SCROLL_STYLE, - formatter: (params: any) => { - try { - const bucketLabel = bucketLabels[params?.value?.[1]] ?? params?.seriesName; - // config.unit remains the CELL INTENSITY unit (e.g. count/s). - const value = - formatUnitValue( - getUnitValue( - params?.value?.[2], - config?.unit, - config?.unit_custom, - config?.decimals, - ), - ) || params?.value?.[2]; - return `le ${escapeHtml(bucketLabel)}
${params?.marker} ${escapeHtml(params?.name)} : ${escapeHtml(value)}`; - } catch (error) { - return ""; - } - }, - }, - grid: { - left: "3%", - right: "4%", - bottom: 60, - containLabel: true, - }, - }; - + const options = + grids.length > 1 + ? stackedHistogramOptions(grids, xAxisData, visualMapRange, config, store, heightPx) + : singleHistogramOptions( + grids[0] ?? { label: "", bucketLabels: [], bucketValues: [], data: [] }, + xAxisData, + visualMapRange, + config, + store, + ); + applyTimeInteraction(options, extras, panelSchema.id, timestamps, grid, context, store); return config?.compact_preview ? applyCompactPreview(options) : options; } } diff --git a/web/src/utils/dashboard/promql/shared/dataProcessor.spec.ts b/web/src/utils/dashboard/promql/shared/dataProcessor.spec.ts index d94a30babc..55d3c6caee 100644 --- a/web/src/utils/dashboard/promql/shared/dataProcessor.spec.ts +++ b/web/src/utils/dashboard/promql/shared/dataProcessor.spec.ts @@ -261,6 +261,41 @@ describe("dataProcessor", () => { expect(processed[0].series.map((s: any) => s.metric.job)).toEqual(["a", "b", "c", "d", "e"]); expect(processed[1].series.map((s: any) => s.metric.job)).toEqual(["e", "d", "c", "b"]); + expect(processed.map((q: any) => q.nameSuffix)).toEqual([undefined, "1 day ago"]); + }); + + it("keeps whole histograms under the series limit in histogram mode and reports the ones dropped", async () => { + const buckets = ["1", "2", "+Inf"]; + // Interleaved, as Prometheus returns them, so a plain slice would cut every histogram short. + const result = buckets.flatMap((le) => + ["a", "b", "c"].map((pod) => ({ metric: { le, pod }, values: [[1000, "1"]] })), + ); + const processed = await processPromQLData( + [{ result }] as any, + { + ...mockPanelSchema, + type: "heatmap", + config: { promql_series_limit: 7, heatmap_mode: "prometheus_histogram" }, + }, + mockStore, + ); + + const kept = processed[0].series.map((s: any) => `${s.metric.pod}${s.metric.le}`); + expect(kept.sort()).toEqual(["a+Inf", "a1", "a2", "b+Inf", "b1", "b2"]); + expect(processed[0].histogramsLimited).toEqual({ shown: 2, total: 3 }); + }); + + it("slices plain heatmaps by series, as before", async () => { + const result = ["1", "2"].flatMap((le) => + ["a", "b"].map((pod) => ({ metric: { le, pod }, values: [[1000, "1"]] })), + ); + const processed = await processPromQLData( + [{ result }] as any, + { ...mockPanelSchema, type: "heatmap", config: { promql_series_limit: 3 } }, + mockStore, + ); + expect(processed[0].series).toHaveLength(3); + expect(processed[0].histogramsLimited).toBeUndefined(); }); it("should use default series limit when not specified", async () => { diff --git a/web/src/utils/dashboard/promql/shared/dataProcessor.ts b/web/src/utils/dashboard/promql/shared/dataProcessor.ts index 8cd0e414c5..14cd42747d 100644 --- a/web/src/utils/dashboard/promql/shared/dataProcessor.ts +++ b/web/src/utils/dashboard/promql/shared/dataProcessor.ts @@ -18,6 +18,7 @@ import { PromQLResponse, ProcessedPromQLData, AggregationFunction } from "./type import { buildPromqlSeriesNames } from "./legendBuilder"; import { getCachedSemanticGroups } from "@/utils/semanticGroupsCache"; import { legendFallbackOf } from "@/utils/dashboard/promql/formula"; +import { PROMETHEUS_HISTOGRAM_MODE, limitWholeHistograms } from "./histogramBuckets"; /** * Preprocess PromQL responses into a common format for chart converters @@ -39,11 +40,23 @@ export async function processPromQLData( // Apply series limit const seriesLimit = panelSchema.config?.promql_series_limit || 100; + const histogramMode = + panelSchema.type === "heatmap" && + panelSchema.config?.heatmap_mode === PROMETHEUS_HISTOGRAM_MODE; + const histogramsLimited: Array<{ shown: number; total: number } | undefined> = []; const limitedData = applySeriesLimit( searchQueryData, seriesLimit, queryIndexOf, shift.nameSuffixes, + histogramMode + ? (result, limit, index) => { + const cut = limitWholeHistograms(result, limit); + if (cut.shown < cut.total) + histogramsLimited[index] = { shown: cut.shown, total: cut.total }; + return cut.kept; + } + : undefined, ); // Named through the same builder the line/bar path uses, so a panel flipped @@ -102,6 +115,8 @@ export async function processPromQLData( series, queryIndex: queryIndexOf(index), seriesRole: shift.nameSuffixes?.[index] ? "shifted" : "primary", + ...(shift.nameSuffixes?.[index] ? { nameSuffix: shift.nameSuffixes[index] } : {}), + ...(histogramsLimited[index] ? { histogramsLimited: histogramsLimited[index] } : {}), queryConfig: panelSchema.queries[queryIndexOf(index)]?.config || {}, }); }); @@ -141,7 +156,7 @@ function collectAllTimestamps(data: PromQLResponse[]): Set { * @param timezone - Timezone string (e.g., "America/New_York" or "UTC") * @returns Array of [unix_ts, formatted_date] tuples */ -function formatTimestamps( +export function formatTimestamps( timestamps: Set, timezone: string, ): Array<[number, Date | string]> { @@ -164,6 +179,7 @@ function formatTimestamps( * @param limit - Maximum number of series to keep per query * @param queryIndexOf - panel query index of each expanded result * @param nameSuffixes - shift suffix of each expanded result ("" for a primary) + * @param sliceResult - keeps at most `limit` series of one result * @returns Limited data array */ function applySeriesLimit( @@ -171,6 +187,8 @@ function applySeriesLimit( limit: number, queryIndexOf: (index: number) => number = (index) => index, nameSuffixes: string[] = [], + sliceResult: (result: any[], limit: number, index: number) => any[] = (result, max) => + result.slice(0, max), ): PromQLResponse[] { const resultOf = (queryData: PromQLResponse) => queryData?.data?.result || queryData?.result; const withResult = (queryData: PromQLResponse, result: any[]): PromQLResponse => { @@ -183,7 +201,7 @@ function applySeriesLimit( const result = resultOf(queryData); return !result || nameSuffixes[index] ? queryData - : withResult(queryData, result.slice(0, limit)); + : withResult(queryData, sliceResult(result, limit, index)); }); const kept = new Map>(); @@ -198,7 +216,7 @@ function applySeriesLimit( if (!result || !nameSuffixes[index]) return queryData; const primaries = kept.get(queryIndexOf(index)); const survivors = primaries ? result.filter((m: any) => primaries.has(m?.metric)) : result; - return withResult(queryData, survivors.slice(0, limit)); + return withResult(queryData, sliceResult(survivors, limit, index)); }); } diff --git a/web/src/utils/dashboard/promql/shared/histogramBuckets.spec.ts b/web/src/utils/dashboard/promql/shared/histogramBuckets.spec.ts index e111e27ea7..e1b77f4942 100644 --- a/web/src/utils/dashboard/promql/shared/histogramBuckets.spec.ts +++ b/web/src/utils/dashboard/promql/shared/histogramBuckets.spec.ts @@ -14,7 +14,32 @@ // along with this program. If not, see . import { describe, it, expect } from "vitest"; -import { parseLe, deaccumulateHistogramSeries, HistogramSeriesInput } from "./histogramBuckets"; +import { + parseLe, + deaccumulateHistogramSeries, + groupHistogramSeries, + HistogramSeriesInput, + limitWholeHistograms, +} from "./histogramBuckets"; + +describe("limitWholeHistograms", () => { + const bucket = (pod: string, le: string) => ({ metric: { pod, le } }); + + it("keeps the first histograms that fit whole, in input order", () => { + const series = [bucket("a", "1"), bucket("b", "1"), bucket("a", "+Inf"), bucket("b", "+Inf")]; + expect(limitWholeHistograms(series, 3)).toEqual({ + kept: [series[0], series[2]], + shown: 1, + total: 2, + }); + expect(limitWholeHistograms(series, 4).kept).toEqual(series); + }); + + it("keeps one histogram whole even when it alone passes the limit", () => { + const series = [bucket("a", "1"), bucket("a", "2"), bucket("a", "+Inf")]; + expect(limitWholeHistograms(series, 2)).toEqual({ kept: series, shown: 1, total: 1 }); + }); +}); describe("parseLe", () => { it("parses finite bounds", () => { @@ -146,9 +171,7 @@ describe("deaccumulateHistogramSeries", () => { expect(result.map((b) => b.data["1"])).toEqual([3, 2]); }); - it("carries the previous cumulative forward for missing buckets", () => { - // The 0.5 bucket has no sample at t=1: carrying 0.5's cumulative forward - // from 0.1 (=2) means 0.5 contributes 0 and 1's diff stays correct (5-2=3). + it("leaves a missing bucket and the bucket above it empty, and the rest exact", () => { const input: HistogramSeriesInput[] = [ { le: "0.1", data: { 1: 2, 2: 1 } }, { le: "0.5", data: { 2: 4 } }, @@ -158,13 +181,12 @@ describe("deaccumulateHistogramSeries", () => { const result = deaccumulateHistogramSeries(input); - // t=1: 0.1 -> 2, 0.5 missing -> 0, 1 -> 5-2 = 3, +Inf -> 6-5 = 1 - expect(result.map((b) => b.data["1"])).toEqual([2, 0, 3, 1]); - // t=2: everything present -> 1, 3, 3, 2 + expect(result.map((b) => b.data["1"])).toEqual([2, undefined, undefined, 1]); + expect(result.map((b) => "1" in b.data)).toEqual([true, false, false, true]); expect(result.map((b) => b.data["2"])).toEqual([1, 3, 3, 2]); }); - it("carries forward when the FIRST bucket is missing (implicit lower bound 0)", () => { + it("leaves the first bucket and the one above it empty when the first is missing", () => { const input: HistogramSeriesInput[] = [ { le: "0.1", data: {} }, { le: "1", data: { 1: 4 } }, @@ -173,12 +195,10 @@ describe("deaccumulateHistogramSeries", () => { const result = deaccumulateHistogramSeries(input); - // Missing bucket 0 carries the implicit 0 forward, so it contributes 0 and - // bucket "1" gets the whole 4. - expect(result.map((b) => b.data["1"])).toEqual([0, 4, 2]); + expect(result.map((b) => b.data["1"])).toEqual([undefined, undefined, 2]); }); - it("treats NaN / null / undefined / empty cumulative values as missing", () => { + it("treats NaN / null / undefined / empty / non-numeric cumulative values as missing", () => { const input: HistogramSeriesInput[] = [ { le: "0.1", data: { 1: 2 } }, { le: "0.5", data: { 1: NaN } }, @@ -191,9 +211,16 @@ describe("deaccumulateHistogramSeries", () => { const result = deaccumulateHistogramSeries(input); - // Every intermediate bucket carries 2 forward and contributes 0; - // +Inf sees 9 - 2 = 7. - expect(result.map((b) => b.data["1"])).toEqual([2, 0, 0, 0, 0, 0, 7]); + expect(result.map((b) => "1" in b.data)).toEqual([ + true, + false, + false, + false, + false, + false, + false, + ]); + expect(result[0].data["1"]).toBe(2); }); it("accepts string cumulative values (PromQL wire format)", () => { @@ -233,7 +260,7 @@ describe("deaccumulateHistogramSeries", () => { expect(result[1].data["1"]).toBe(0); }); - it("populates every timestamp of the union on every bucket", () => { + it("never fills a timestamp a bucket's bounds did not report with 0", () => { const input: HistogramSeriesInput[] = [ { le: "1", data: { 100: 1 } }, { le: "+Inf", data: { 200: 3 } }, @@ -241,12 +268,8 @@ describe("deaccumulateHistogramSeries", () => { const result = deaccumulateHistogramSeries(input); - expect(Object.keys(result[0].data).sort()).toEqual(["100", "200"]); - expect(Object.keys(result[1].data).sort()).toEqual(["100", "200"]); - // t=100: le 1 -> 1, +Inf missing -> carries 1 -> 0 - expect(result.map((b) => b.data["100"])).toEqual([1, 0]); - // t=200: le 1 missing -> 0, +Inf -> 3 - expect(result.map((b) => b.data["200"])).toEqual([0, 3]); + expect(result[0].data).toEqual({ 100: 1 }); + expect(result[1].data).toEqual({}); }); it("does not mutate the input series", () => { @@ -261,3 +284,43 @@ describe("deaccumulateHistogramSeries", () => { expect(input).toEqual(snapshot); }); }); + +describe("groupHistogramSeries", () => { + const bucket = (metric: Record) => ({ metric }); + + it("groups by every label except le and __name__, in first-seen order", () => { + const series = [ + bucket({ __name__: "x_bucket", pod: "b", le: "1" }), + bucket({ __name__: "x_bucket", pod: "a", le: "1" }), + bucket({ __name__: "x_bucket", pod: "b", le: "+Inf" }), + bucket({ __name__: "x_bucket", pod: "a", le: "+Inf" }), + ]; + const groups = groupHistogramSeries(series); + expect(groups.map((group) => group.label)).toEqual(['pod="b"', 'pod="a"']); + expect(groups[0].series).toEqual([series[0], series[2]]); + expect(groups[1].series).toEqual([series[1], series[3]]); + expect(groups.map((group) => group.labels)).toEqual([{ pod: "b" }, { pod: "a" }]); + }); + + it("keeps a sum by (le) query as one unlabelled group", () => { + const series = [bucket({ le: "1" }), bucket({ le: "+Inf" }), { name: 'le="2"' } as any]; + expect(groupHistogramSeries(series)).toEqual([{ label: "", labels: {}, series }]); + }); + + it("orders the labels by name, so label order in the response does not split a group", () => { + const groups = groupHistogramSeries([ + bucket({ pod: "a", job: "api", le: "1" }), + bucket({ le: "2", job: "api", pod: "a" }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe('job="api", pod="a"'); + }); + + it("does not merge label sets whose rendered text collides", () => { + const groups = groupHistogramSeries([ + bucket({ a: 'x", b="y', le: "1" }), + bucket({ a: "x", b: "y", le: "1" }), + ]); + expect(groups).toHaveLength(2); + }); +}); diff --git a/web/src/utils/dashboard/promql/shared/histogramBuckets.ts b/web/src/utils/dashboard/promql/shared/histogramBuckets.ts index 656f4a63ec..d6784df963 100644 --- a/web/src/utils/dashboard/promql/shared/histogramBuckets.ts +++ b/web/src/utils/dashboard/promql/shared/histogramBuckets.ts @@ -42,7 +42,7 @@ export interface HistogramSeriesOutput { le: string; /** Numeric value of `le` (`Infinity` for the +Inf bucket) */ leValue: number; - /** timestamp (string key) -> non-cumulative, non-negative value */ + /** timestamp (string key) -> non-cumulative, non-negative value; absent where the bucket is empty */ data: Record; } @@ -54,6 +54,18 @@ export interface HistogramSeriesOutput { */ const INFINITY_SPELLINGS = new Set(["inf", "infinity"]); +/** The `heatmap_mode` that reads a PromQL heatmap's series as the cumulative buckets of classic histograms. */ +export const PROMETHEUS_HISTOGRAM_MODE = "prometheus_histogram"; + +/** Labels that vary within one histogram rather than between histograms. */ +const BUCKET_LABELS = new Set(["le", "__name__"]); + +export interface HistogramGroup { + label: string; + labels: Record; + series: T[]; +} + /** * Parse a Prometheus `le` label value into a number. * @@ -105,6 +117,43 @@ function toNumericValue(raw: number | string | null | undefined): number { return Number.isNaN(parsed) ? NaN : parsed; } +/** Splits bucket series into histograms by every label except `le` and `__name__`, in first-seen order. */ +export function groupHistogramSeries }>( + series: T[], +): HistogramGroup[] { + const groups = new Map>(); + for (const entry of series ?? []) { + const labels = Object.entries(entry?.metric ?? {}) + .filter(([name]) => !BUCKET_LABELS.has(name)) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + // Keyed on the pairs, not the rendered text, which a value containing `", ` could forge. + const key = JSON.stringify(labels); + const group = groups.get(key); + if (group) group.series.push(entry); + else { + const label = labels.map(([name, value]) => `${name}="${value}"`).join(", "); + groups.set(key, { label, labels: Object.fromEntries(labels), series: [entry] }); + } + } + return [...groups.values()]; +} + +/** The first whole histograms of `series` that fit in `limit` series, at least one, in input order; a histogram missing a bucket would fold its counts into the next. */ +export function limitWholeHistograms }>( + series: T[], + limit: number, +): { kept: T[]; shown: number; total: number } { + const groups = groupHistogramSeries(series); + const keep = new Set(); + let shown = 0; + for (const group of groups) { + if (shown > 0 && keep.size + group.series.length > limit) break; + for (const entry of group.series) keep.add(entry); + shown++; + } + return { kept: series.filter((entry) => keep.has(entry)), shown, total: groups.length }; +} + /** * Convert cumulative Prometheus histogram bucket series into per-bucket * (non-cumulative) series. @@ -118,20 +167,9 @@ function toNumericValue(raw: number | string | null | undefined): number { * is `cumulative[i] - cumulative[i - 1]` (for `i = 0` it is `cumulative[0]`, * i.e. the implicit lower bound of 0). * - * Missing / NaN / null cumulative values are treated as "carry forward the - * previous bucket's cumulative value at this timestamp". Because buckets are - * cumulative and monotonically non-decreasing, an absent bucket cannot be - * distinguished from one that gained no observations, so carrying the - * previous cumulative forward makes it contribute exactly 0 to the diff - * while leaving the NEXT bucket's diff correct relative to the last value we - * actually observed. (Treating it as 0 instead would fabricate a huge - * negative step followed by a huge positive one.) A missing bucket 0 carries - * forward the implicit lower bound of 0. + * A missing cumulative value leaves that bucket and the one above it empty, never 0. * 5. Negative differences (float artifacts or malformed, non-monotonic data) * are clamped to 0. - * - * Every returned series carries an entry for every timestamp in the union, so - * the heatmap grid is fully populated. */ export function deaccumulateHistogramSeries( series: HistogramSeriesInput[], @@ -182,14 +220,11 @@ export function deaccumulateHistogramSeries( let previousCumulative = 0; for (let i = 0; i < buckets.length; i++) { - const rawValue = toNumericValue(buckets[i].data[ts]); - - // Carry the previous cumulative forward when this bucket has no value. - const cumulative = Number.isNaN(rawValue) ? previousCumulative : rawValue; - - const diff = cumulative - previousCumulative; - output[i].data[ts] = diff > 0 ? diff : 0; - + const cumulative = toNumericValue(buckets[i].data[ts]); + if (!Number.isNaN(cumulative) && !Number.isNaN(previousCumulative)) { + const diff = cumulative - previousCumulative; + output[i].data[ts] = diff > 0 ? diff : 0; + } previousCumulative = cumulative; } } diff --git a/web/src/utils/dashboard/promql/shared/spectral.ts b/web/src/utils/dashboard/promql/shared/spectral.ts deleted file mode 100644 index f982124c66..0000000000 --- a/web/src/utils/dashboard/promql/shared/spectral.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2026 OpenObserve Inc. -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -/** - * Spectral colour ramp helpers. - * - * Pure, dependency-free. Used by the Prometheus histogram heatmap mode to - * build the `visualMap.inRange.color` stop list. - */ - -/** - * Canonical 11-class Spectral diverging ramp (ColorBrewer), COOL-to-WARM. - * - * These eleven values are ColorBrewer's, by Cynthia Brewer, Mark Harrower and - * The Pennsylvania State University, and are used under the Apache License 2.0 - * (https://colorbrewer2.org). They are reproduced here rather than pulled from - * d3-scale-chromatic so this module stays dependency-free. - * - * Position 0 is the "low" end, position 1 is the "high" end. ColorBrewer - * publishes Spectral warm-first (dark red -> purple); this ramp is listed - * cool-first because a heatmap reads hot as "more" — otherwise a histogram's - * near-empty high-`le` rows would dominate as a dark-red slab while the busy - * low buckets sink into blue. - */ -export const SPECTRAL_STOPS: string[] = [ - "#5e4fa2", - "#3288bd", - "#66c2a5", - "#abdda4", - "#e6f598", - "#ffffbf", - "#fee08b", - "#fdae61", - "#f46d43", - "#d53e4f", - "#9e0142", -]; - -/** - * Convert a `#rrggbb` string to an [r, g, b] triple. - */ -function hexToRgb(hex: string): [number, number, number] { - const value = hex.replace("#", ""); - return [ - parseInt(value.slice(0, 2), 16), - parseInt(value.slice(2, 4), 16), - parseInt(value.slice(4, 6), 16), - ]; -} - -/** - * Convert an [r, g, b] triple back to a lowercase `#rrggbb` string. - */ -function rgbToHex(rgb: [number, number, number]): string { - return ( - "#" + - rgb - .map((channel) => { - const clamped = Math.max(0, Math.min(255, Math.round(channel))); - return clamped.toString(16).padStart(2, "0"); - }) - .join("") - ); -} - -/** - * Sample the Spectral ramp at position `t` (clamped to [0, 1]) using linear - * interpolation in RGB space between the neighbouring ColorBrewer stops. - */ -export function sampleSpectral(t: number): string { - const stops = SPECTRAL_STOPS; - - if (!Number.isFinite(t)) return stops[0]; - - const clamped = Math.max(0, Math.min(1, t)); - const scaled = clamped * (stops.length - 1); - const lowerIndex = Math.floor(scaled); - const upperIndex = Math.min(lowerIndex + 1, stops.length - 1); - const fraction = scaled - lowerIndex; - - const lower = hexToRgb(stops[lowerIndex]); - const upper = hexToRgb(stops[upperIndex]); - - return rgbToHex([ - lower[0] + (upper[0] - lower[0]) * fraction, - lower[1] + (upper[1] - lower[1]) * fraction, - lower[2] + (upper[2] - lower[2]) * fraction, - ]); -} - -/** - * Build a discrete list of colour stops sampled from the Spectral ramp. - * - * ECharts' continuous `visualMap` spreads `inRange.color` **linearly** across - * [min, max]; it has no notion of a colour-scale exponent. To emulate an - * exponent we pre-warp the sample positions: stop `k` (which ECharts will - * place at value fraction `t = k / (steps - 1)`) is sampled from the ramp at - * `t ** exponent`. An exponent below 1 therefore stretches the low end of the - * value range over more of the ramp, so small cell values remain - * distinguishable — the same visual effect a sqrt colour scale gives. - * - * Both knobs are required: there is no defensible default for either without - * knowing what is being charted, and the one caller states its choices below. - * - * @param steps - number of colour stops to emit - * @param exponent - colour-scale exponent to bake into the stop positions - */ -export function buildSpectralColorStops(steps: number, exponent: number): string[] { - const count = Math.max(2, Math.floor(steps)); - const colors: string[] = []; - - for (let k = 0; k < count; k++) { - const t = k / (count - 1); - colors.push(sampleSpectral(Math.pow(t, exponent))); - } - - return colors; -} - -/** - * Sub-samples per class boundary. The ramp above has 11 canonical classes, so - * this many samples between each adjacent pair renders the gradient smoothly - * without emitting more stops than a colour bar can distinguish. - */ -const SAMPLES_PER_CLASS = 4; - -/** (11 classes - 1) boundaries x 4 samples, plus the closing stop. */ -export const SPECTRAL_HEATMAP_STOP_COUNT = (SPECTRAL_STOPS.length - 1) * SAMPLES_PER_CLASS + 1; - -/** - * Compresses the low end of the scale, so that the difference between a cell - * holding 1 observation and one holding 10 is visible at all. - * - * A histogram heatmap is count data, and counts are Poisson: their variance - * grows with the mean, so a linear colour scale spends almost its whole range - * on the few busiest cells and renders everything else as one flat floor colour. - * The square root is the variance-stabilizing transform for a Poisson variable, - * which makes a fixed colour step mean roughly the same amount of evidence - * everywhere along the ramp. - */ -const HEATMAP_SCALE_EXPONENT = 0.5; - -/** Default ramp used by the Prometheus histogram heatmap: cool-to-warm. */ -export const SPECTRAL_HEATMAP_STOPS: string[] = buildSpectralColorStops( - SPECTRAL_HEATMAP_STOP_COUNT, - HEATMAP_SCALE_EXPONENT, -); diff --git a/web/src/utils/dashboard/promql/shared/types.ts b/web/src/utils/dashboard/promql/shared/types.ts index caab5cd18a..e767fe66d9 100644 --- a/web/src/utils/dashboard/promql/shared/types.ts +++ b/web/src/utils/dashboard/promql/shared/types.ts @@ -61,6 +61,10 @@ export interface ProcessedPromQLData { queryIndex: number; /** "shifted" for a past period drawn over its panel query. */ seriesRole?: "primary" | "shifted"; + /** The shifted period's name, e.g. "1 day ago", which its series names already carry. */ + nameSuffix?: string; + /** In histogram mode, the whole histograms the series limit kept out of all returned. */ + histogramsLimited?: { shown: number; total: number }; queryConfig: any; } @@ -77,6 +81,7 @@ export interface PromQLChartConverter { store: any, extras: any, chartPanelRef?: any, + context?: ConversionContext, ): { // Optional: metric/table converters return values/columns instead of ECharts series series?: any[]; @@ -108,6 +113,7 @@ export interface ConversionContext { parentQueryIndex?: number[]; /** periodAsStr per expanded result, "" for a current-period one. */ nameSuffixes?: string[]; + stepSeconds?: number; } /** diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts index d23bbad44a..8a33312e5a 100644 --- a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts +++ b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.spec.ts @@ -149,3 +149,604 @@ describe("applyHeatmapChart", () => { expect(ctx.options.tooltip.position).toBe("top"); }); }); + +describe("applyHeatmapChart pinned colour range", () => { + const withConfig = (config: Record) => { + const ctx = makeMockContext(); + ctx.panelSchema.config = { ...ctx.panelSchema.config, ...config }; + applyHeatmapChart(ctx); + return ctx.options; + }; + + it("keeps the automatic [0, max] range when nothing is pinned", () => { + const { visualMap } = withConfig({ heatmap_color_min: null, heatmap_color_max: null }); + expect(visualMap.min).toBe(0); + expect(visualMap.max).toBe(40); + }); + + it("pins only the min", () => { + const { visualMap } = withConfig({ heatmap_color_min: 15 }); + expect([visualMap.min, visualMap.max]).toEqual([15, 40]); + }); + + it("pins only the max", () => { + const { visualMap } = withConfig({ heatmap_color_max: 25 }); + expect([visualMap.min, visualMap.max]).toEqual([0, 25]); + }); + + it("pins both ends", () => { + const { visualMap } = withConfig({ heatmap_color_min: 15, heatmap_color_max: 25 }); + expect([visualMap.min, visualMap.max]).toEqual([15, 25]); + }); + + it("ignores an inverted pair and uses the automatic range", () => { + const { visualMap } = withConfig({ heatmap_color_min: 25, heatmap_color_max: 15 }); + expect([visualMap.min, visualMap.max]).toEqual([0, 40]); + }); + + it("keeps real values in the cells, tooltip and label when a cell exceeds the max", () => { + const options = withConfig({ heatmap_color_max: 25 }); + const above = options.series[0].data.find((cell: any) => cell[2] === 40); + expect(above).toEqual([1, 1, 40]); + + const params = { value: above, name: "B", marker: "", seriesName: "Value" }; + expect(options.tooltip.formatter(params)).toContain(": 40"); + expect(options.series[0].label.formatter(params)).toBe("40"); + }); +}); + +describe("applyHeatmapChart palette and colour scale", () => { + const withConfig = (config: Record) => { + const ctx = makeMockContext(); + ctx.panelSchema.config = { ...ctx.panelSchema.config, ...config }; + applyHeatmapChart(ctx); + return ctx.options.visualMap; + }; + + it("uses the chosen palette, reversed on request", () => { + expect(withConfig({ heatmap_palette: "magma" }).inRange.color[0]).toBe("#000004"); + const reversed = withConfig({ heatmap_palette: "magma", heatmap_palette_reverse: true }); + expect(reversed.inRange.color[0]).toBe("#fcfdbf"); + }); + + it("keeps the square-root Spectral ramp by default", () => { + const { inRange } = withConfig({}); + expect(inRange.color[0]).toBe("#5e4fa2"); + expect(inRange.color[1]).toBe("#50aaaf"); + }); + + it("starts a log scale at the smallest positive cell", () => { + const visualMap = withConfig({ heatmap_color_scale: "log" }); + expect(visualMap.min).toBe(10); + expect(visualMap.max).toBe(40); + expect(visualMap.inRange.color[1]).not.toBe(withConfig({}).inRange.color[1]); + }); +}); + +describe("applyHeatmapChart cell values and categories", () => { + const render = (rows: any[], overrides: Partial = {}) => { + const ctx = makeMockContext({ searchQueryData: [rows], extras: {}, ...overrides }); + applyHeatmapChart(ctx); + return ctx; + }; + const cellAt = (ctx: any, x: number, y: number) => + ctx.options.series[0].data.find((cell: any) => cell[0] === x && cell[1] === y)?.[2]; + + it("draws a real z of 0 as 0 and only a missing pair or empty z as '-'", () => { + const ctx = render([ + { x: "A", y: "r1", z: 0 }, + { x: "B", y: "r1", z: null }, + { x: "C", y: "r1", z: "" }, + { x: "A", y: "r2", z: 5 }, + ]); + expect(cellAt(ctx, 0, 0)).toBe(0); + expect(cellAt(ctx, 1, 0)).toBe("-"); + expect(cellAt(ctx, 2, 0)).toBe("-"); + expect(cellAt(ctx, 1, 1)).toBe("-"); + }); + + it("keeps 0 and empty-string categories, labels the empty one, and drops null", () => { + const ctx = render([ + { x: 0, y: "", z: 1 }, + { x: "", y: 0, z: 2 }, + { x: null, y: "r", z: 3 }, + { x: "A", y: undefined, z: 4 }, + ]); + expect(ctx.options.xAxis[0].data).toEqual([0, "(empty)", "A"]); + expect(ctx.options.yAxis.data).toEqual(["(empty)", 0, "r"]); + expect(cellAt(ctx, 0, 0)).toBe(1); + expect(cellAt(ctx, 1, 1)).toBe(2); + }); + + it("shows a 0 y category in the tooltip instead of the series name", () => { + const ctx = render([{ x: "A", y: 0, z: 7 }]); + const params = { value: [0, 0, 7], name: "A", marker: "", seriesName: "Value" }; + expect(ctx.options.tooltip.formatter(params)).toContain("0
"); + expect(ctx.options.tooltip.formatter(params)).not.toContain("Value"); + }); + + it("does not collide values that contain the old key separator", () => { + const ctx = render([ + { x: "a||b", y: "c", z: 1 }, + { x: "b", y: "c||a", z: 2 }, + ]); + expect(cellAt(ctx, 0, 0)).toBe(1); + expect(cellAt(ctx, 1, 1)).toBe(2); + expect(cellAt(ctx, 1, 0)).toBe("-"); + expect(ctx.extras.limitNumberOfSeriesWarningMessage).toBeUndefined(); + }); + + it("keeps the first of duplicate (x, y) rows and warns about them", () => { + const ctx = render([ + { x: "A", y: "r", z: 1 }, + { x: "A", y: "r", z: 9 }, + { x: "A", y: "r", z: 8 }, + ]); + expect(cellAt(ctx, 0, 0)).toBe(1); + expect(ctx.extras.limitNumberOfSeriesWarningMessage).toBe( + "Duplicate (x, y) rows found: 2; each affected cell shows the first row's value", + ); + }); + + it("colours all-negative data over its own range instead of [0, max]", () => { + const ctx = render([ + { x: "A", y: "r", z: -30 }, + { x: "B", y: "r", z: -5 }, + ]); + expect([ctx.options.visualMap.min, ctx.options.visualMap.max]).toEqual([-30, -5]); + }); +}); + +describe("applyHeatmapChart axis border", () => { + const axisLines = (axisBorderShow: unknown) => { + const ctx = makeMockContext(); + ctx.panelSchema.config.axis_border_show = axisBorderShow; + applyHeatmapChart(ctx); + return [ctx.options.xAxis[0].axisLine, ctx.options.yAxis.axisLine]; + }; + + it("keeps the ECharts default axis lines whatever the stored toggle, so saved panels look unchanged", () => { + expect(axisLines(true)).toEqual([undefined, undefined]); + expect(axisLines(false)).toEqual([undefined, undefined]); + expect(axisLines(undefined)).toEqual([undefined, undefined]); + }); +}); + +describe("applyHeatmapChart cell budget", () => { + it("keeps every column and the largest rows when rows overflow the budget", () => { + const rows = Array.from({ length: 25_001 }, (_unused, i) => [ + { x: "A", y: `r${i}`, z: i }, + { x: "B", y: `r${i}`, z: i }, + ]).flat(); + const ctx = makeMockContext({ searchQueryData: [rows], extras: {} }); + applyHeatmapChart(ctx); + + expect(ctx.options.xAxis[0].data).toEqual(["A", "B"]); + expect(ctx.options.yAxis.data).toHaveLength(25_000); + expect(ctx.options.yAxis.data[0]).toBe("r1"); + expect(ctx.options.series[0].data).toHaveLength(50_000); + expect(ctx.extras.limitNumberOfSeriesWarningMessage).toBe( + "Showing 25,000 of 25,001 rows to stay within the heatmap cell limit", + ); + }); + + it("keeps the last columns and the largest row when columns alone overflow it", () => { + const rows = Array.from({ length: 50_002 }, (_unused, i) => [ + { x: `c${i}`, y: "small", z: 1 }, + { x: `c${i}`, y: "big", z: 2 }, + ]).flat(); + const ctx = makeMockContext({ searchQueryData: [rows], extras: {} }); + applyHeatmapChart(ctx); + + expect(ctx.options.xAxis[0].data).toHaveLength(50_000); + expect(ctx.options.xAxis[0].data[0]).toBe("c2"); + expect(ctx.options.yAxis.data).toEqual(["big"]); + expect(ctx.options.series[0].data).toHaveLength(50_000); + }); + + it("ranks rows by magnitude, so all-negative data keeps its strongest rows", () => { + const rows = Array.from({ length: 25_001 }, (_unused, i) => [ + { x: "A", y: `r${i}`, z: i === 0 ? -1 : -100 }, + { x: "B", y: `r${i}`, z: i === 0 ? -1 : -100 }, + ]).flat(); + const ctx = makeMockContext({ searchQueryData: [rows], extras: {} }); + applyHeatmapChart(ctx); + + expect(ctx.options.yAxis.data).toHaveLength(25_000); + expect(ctx.options.yAxis.data).not.toContain("r0"); + }); + + it("uses the smaller budget a multi-query panel hands each tile", () => { + const rows = Array.from({ length: 101 }, (_unused, i) => [ + { x: "A", y: `r${i}`, z: i }, + { x: "B", y: `r${i}`, z: i }, + ]).flat(); + const ctx = makeMockContext({ searchQueryData: [rows], extras: {} }); + ctx.panelSchema.heatmapCellBudget = 200; + applyHeatmapChart(ctx); + + expect(ctx.options.series[0].data).toHaveLength(200); + expect(ctx.options.yAxis.data[0]).toBe("r1"); + }); +}); + +describe("applyHeatmapChart histogram time axis", () => { + const histogramContext = (rows: any[], startIso: string, endIso: string, interval = 60) => + makeMockContext({ + searchQueryData: [rows], + extras: {}, + resultMetaData: [{ histogram_interval: interval }], + metadata: { + queries: [{ startTime: Date.parse(startIso) * 1000, endTime: Date.parse(endIso) * 1000 }], + }, + panelSchema: { + type: "heatmap", + config: {}, + queries: [ + { + customQuery: false, + fields: { + x: [ + { + alias: "x", + functionName: "histogram", + args: [{ value: { field: "_timestamp" } }], + }, + ], + y: [{ alias: "y" }], + z: [{ alias: "z" }], + }, + }, + ], + }, + }); + + it("sorts buckets by time and adds an empty column for every missing bucket in the range", () => { + const ctx = histogramContext( + [ + { x: "2024-01-15T10:03:00", y: "r", z: 3 }, + { x: "2024-01-15T10:00:00", y: "r", z: 1 }, + ], + "2024-01-15T10:00:30Z", + "2024-01-15T10:04:30Z", + ); + applyHeatmapChart(ctx); + + expect(ctx.options.xAxis[0].data).toHaveLength(5); + expect(ctx.options.series[0].data.map((cell: any) => cell[2])).toEqual([1, "-", "-", 3, "-"]); + }); + + it("keeps the returned order without a server interval", () => { + const ctx = histogramContext( + [ + { x: "2024-01-15T10:03:00", y: "r", z: 3 }, + { x: "2024-01-15T10:00:00", y: "r", z: 1 }, + ], + "2024-01-15T10:00:00Z", + "2024-01-15T10:04:00Z", + 0, + ); + applyHeatmapChart(ctx); + + expect(ctx.options.series[0].data.map((cell: any) => cell[2])).toEqual([3, 1]); + }); + + it("generates no more columns than the cell budget for a fine interval over a long range", () => { + const ctx = histogramContext( + [{ x: "2024-01-15T10:00:00", y: "r", z: 1 }], + "2024-01-01T00:00:00Z", + "2024-01-31T00:00:00Z", + 1, + ); + applyHeatmapChart(ctx); + + expect(ctx.options.xAxis[0].data).toHaveLength(50_000); + // The window ends at the newest returned bucket, so data early in the range is still drawn. + expect(ctx.extras.heatmapTimeAxis.columns.at(-1).x).toBe(Date.parse("2024-01-15T10:00:00Z")); + expect(ctx.options.series[0].data.at(-1)[2]).toBe(1); + }); + + it("ends the window at the range end when data reaches it", () => { + const ctx = histogramContext( + [ + { x: "2024-01-15T10:00:00", y: "r", z: 1 }, + { x: "2024-01-31T00:00:00", y: "r", z: 2 }, + ], + "2024-01-01T00:00:00Z", + "2024-01-31T00:00:00Z", + 1, + ); + applyHeatmapChart(ctx); + + expect(ctx.options.xAxis[0].data).toHaveLength(50_000); + expect(ctx.extras.heatmapTimeAxis.columns.at(-1).x).toBe(Date.parse("2024-01-31T00:00:00Z")); + }); +}); + +describe("applyHeatmapChart y-axis order and buckets", () => { + const plainY = { + alias: "y", + functionName: null, + args: [{ type: "field", value: { field: "age" } }], + }; + const contextOf = (rows: any[], config: Record, customQuery: boolean) => + makeMockContext({ + searchQueryData: [rows], + panelSchema: { + type: "heatmap", + config, + queries: [{ customQuery, fields: { x: [], y: [plainY], z: [{ alias: "z" }] } }], + }, + }); + const rowsOf = (ys: any[]) => ys.map((y, index) => ({ x: "A", y, z: index + 1 })); + const zByLabel = (ctx: any) => + Object.fromEntries( + ctx.options.series[0].data.map(([, row, z]: any[]) => [ctx.options.yAxis.data[row], z]), + ); + + it("keeps data order when heatmap_y_sort is unset", () => { + const ctx = contextOf(rowsOf(["b", "c", "a"]), {}, true); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["b", "c", "a"]); + }); + + it("puts the smallest value at index 0, the bottom of the axis, for ascending", () => { + const ctx = contextOf(rowsOf(["10", "9", "100"]), { heatmap_y_sort: "asc" }, true); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["9", "10", "100"]); + expect(zByLabel(ctx)).toEqual({ "10": 1, "9": 2, "100": 3 }); + }); + + it("sorts text descending with a locale compare", () => { + const ctx = contextOf(rowsOf(["b", "c", "a"]), { heatmap_y_sort: "desc" }, true); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["c", "b", "a"]); + }); + + it("labels builder buckets with their range in ascending numeric order", () => { + const ctx = contextOf( + rowsOf([20, 0, 10]), + { heatmap_y_bucket: { mode: "size", size: 10 } }, + false, + ); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["0–10", "10–20", "20–30"]); + expect(zByLabel(ctx)).toEqual({ "20–30": 1, "0–10": 2, "10–20": 3 }); + }); + + it("reverses buckets for descending", () => { + const ctx = contextOf( + rowsOf([20, 0, 10]), + { heatmap_y_bucket: { mode: "size", size: 10 }, heatmap_y_sort: "desc" }, + false, + ); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["20–30", "10–20", "0–10"]); + }); + + it("labels log buckets and the <= 0 bucket", () => { + const ctx = contextOf(rowsOf([100, 0, 1]), { heatmap_y_bucket: { mode: "log" } }, false); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["≤ 0", "1–10", "10–100", "100–1000"]); + }); + + it("fills empty buckets between returned ones so adjacent rows are adjacent ranges", () => { + const ctx = contextOf(rowsOf([90, 0]), { heatmap_y_bucket: { mode: "size", size: 10 } }, false); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual([ + "0–10", + "10–20", + "20–30", + "30–40", + "40–50", + "50–60", + "60–70", + "70–80", + "80–90", + "90–100", + ]); + expect(zByLabel(ctx)).toMatchObject({ "0–10": 2, "50–60": "-", "90–100": 1 }); + }); + + it("keeps one unbroken run of buckets when the cell budget cuts rows", () => { + const ctx = makeMockContext({ + searchQueryData: [ + [ + { x: "A", y: 0, z: 1 }, + { x: "A", y: 10, z: 1 }, + { x: "A", y: 20, z: 50 }, + { x: "A", y: 30, z: 1 }, + { x: "A", y: 40, z: 40 }, + { x: "A", y: 50, z: 40 }, + ], + ], + panelSchema: { + type: "heatmap", + config: { heatmap_y_bucket: { mode: "size", size: 10 } }, + heatmapCellBudget: 2, + queries: [{ customQuery: false, fields: { x: [], y: [plainY], z: [{ alias: "z" }] } }], + }, + }); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["40–50", "50–60"]); + }); + + it("hands drilldowns the raw row values, not their range or empty labels", () => { + const bucketed = contextOf( + rowsOf([20, 0]), + { heatmap_y_bucket: { mode: "size", size: 10 } }, + false, + ); + bucketed.extras = {}; + applyHeatmapChart(bucketed); + expect(bucketed.extras.heatmapRowValues).toEqual([[0, 10, 20]]); + const empty = contextOf(rowsOf(["", "api"]), {}, true); + empty.extras = {}; + applyHeatmapChart(empty); + expect(empty.options.yAxis.data[0]).not.toBe(""); + expect(empty.extras.heatmapRowValues).toEqual([["", "api"]]); + }); + + it("drops buckets outside the y range", () => { + const ctx = contextOf( + rowsOf([20, 0, 10, 30]), + { heatmap_y_bucket: { mode: "size", size: 10 }, heatmap_y_min: 5, heatmap_y_max: 15 }, + false, + ); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["0–10", "10–20"]); + expect(ctx.options.series[0].data).toHaveLength(2); + }); + + it("ignores an inverted y range", () => { + const ctx = contextOf( + rowsOf([20, 0]), + { heatmap_y_bucket: { mode: "size", size: 10 }, heatmap_y_min: 15, heatmap_y_max: 5 }, + false, + ); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["0–10", "10–20", "20–30"]); + }); + + it("keeps plain categories when a y value is not a bucket lower bound, as an unbucketed stored query returns", () => { + const sized = contextOf( + rowsOf([20, 7, 10]), + { heatmap_y_bucket: { mode: "size", size: 10 } }, + false, + ); + applyHeatmapChart(sized); + expect(sized.options.yAxis.data).toEqual([20, 7, 10]); + const logged = contextOf(rowsOf([100, 50]), { heatmap_y_bucket: { mode: "log" } }, false); + applyHeatmapChart(logged); + expect(logged.options.yAxis.data).toEqual([100, 50]); + const fractional = contextOf( + rowsOf([0.30000000000000004, 0.1, 0.001]), + { heatmap_y_bucket: { mode: "size", size: 0.1 } }, + false, + ); + applyHeatmapChart(fractional); + expect(fractional.options.yAxis.data).toEqual([0.30000000000000004, 0.1, 0.001]); + }); + + it("accepts float-noisy lower bounds from the generated SQL as buckets", () => { + const ctx = contextOf( + rowsOf([0.30000000000000004, 0.1]), + { heatmap_y_bucket: { mode: "size", size: 0.1 } }, + false, + ); + applyHeatmapChart(ctx); + expect(ctx.options.yAxis.data).toEqual(["0.1–0.2", "0.2–0.3", "0.3–0.4"]); + expect(zByLabel(ctx)).toEqual({ "0.1–0.2": 2, "0.2–0.3": "-", "0.3–0.4": 1 }); + const log = contextOf(rowsOf([0.001, 1000]), { heatmap_y_bucket: { mode: "log" } }, false); + applyHeatmapChart(log); + expect(log.options.yAxis.data).toEqual([ + "0.001–0.01", + "0.01–0.1", + "0.1–1", + "1–10", + "10–100", + "100–1000", + "1000–10000", + ]); + }); + + it("does not treat custom SQL, an aggregated y or text values as buckets", () => { + const config = { heatmap_y_bucket: { mode: "size", size: 10 } }; + const custom = contextOf(rowsOf([20, 0]), config, true); + applyHeatmapChart(custom); + expect(custom.options.yAxis.data).toEqual([20, 0]); + + const text = contextOf(rowsOf(["20", "0"]), config, false); + applyHeatmapChart(text); + expect(text.options.yAxis.data).toEqual(["20", "0"]); + + const aggregated = contextOf(rowsOf([20, 0]), config, false); + aggregated.panelSchema.queries[0].fields.y = [{ ...plainY, functionName: "max" }]; + applyHeatmapChart(aggregated); + expect(aggregated.options.yAxis.data).toEqual([20, 0]); + }); +}); + +describe("applyHeatmapChart time columns for zoom and cursor sync", () => { + const builder = (resultMetaData: any[]) => + makeMockContext({ + searchQueryData: [ + [ + { x: "2024-01-15T10:03:00", y: "r", z: 3 }, + { x: "2024-01-15T10:00:00", y: "r", z: 1 }, + ], + ], + extras: {}, + resultMetaData, + metadata: { + queries: [ + { + startTime: Date.parse("2024-01-15T10:00:30Z") * 1000, + endTime: Date.parse("2024-01-15T10:04:30Z") * 1000, + }, + ], + }, + panelSchema: { + type: "heatmap", + config: {}, + queries: [ + { + customQuery: false, + fields: { + x: [ + { + alias: "x", + functionName: "histogram", + args: [{ value: { field: "_timestamp" } }], + }, + ], + y: [{ alias: "y" }], + z: [{ alias: "z" }], + }, + }, + ], + }, + }); + const at = (iso: string) => new Date(iso.replace("Z", "")).getTime(); + + it("gives every drawn column its bucket's span, clipped to the query range", () => { + const ctx = builder([{ histogram_interval: 60 }]); + applyHeatmapChart(ctx); + const columns = ctx.extras.heatmapTimeAxis.columns; + expect(columns).toHaveLength(5); + expect([columns[0].start, columns[0].end]).toEqual([ + at("2024-01-15T10:00:30Z"), + at("2024-01-15T10:01:00Z"), + ]); + expect([columns[4].start, columns[4].end]).toEqual([ + at("2024-01-15T10:04:00Z"), + at("2024-01-15T10:04:30Z"), + ]); + }); + + it("draws the hover cursor on the time axis only, without the tooltip cross", () => { + const ctx = builder([{ histogram_interval: 60 }]); + applyHeatmapChart(ctx); + expect(ctx.options.tooltip.axisPointer).toBeUndefined(); + expect(ctx.options.xAxis[0].axisPointer).toMatchObject({ show: true, triggerTooltip: false }); + }); + + it("hides the y pointer label of a categorical heatmap, so only the tooltip names the row", () => { + const ctx = makeMockContext({ extras: {} }); + applyHeatmapChart(ctx); + expect(ctx.options.tooltip.axisPointer.type).toBe("cross"); + expect(ctx.options.yAxis.axisPointer).toEqual({ label: { show: false } }); + }); + + it("has no time columns without a server interval", () => { + const ctx = builder([{}]); + applyHeatmapChart(ctx); + expect(ctx.extras.heatmapTimeAxis).toBeUndefined(); + }); + + it("has no time columns for a categorical x", () => { + const ctx = makeMockContext({ extras: {} }); + applyHeatmapChart(ctx); + expect(ctx.extras.heatmapTimeAxis).toBeUndefined(); + }); +}); diff --git a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts index 25753cdc4c..464f028bbf 100644 --- a/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts +++ b/web/src/utils/dashboard/sql/charts/convertSQLHeatmapChart.ts @@ -21,13 +21,32 @@ import { getFieldLabel } from "../../fieldLabel"; import { chartColor } from "@/utils/chartTheme"; import { type SQLContext } from "../shared/types"; import { + HEATMAP_MAX_CELLS, HEATMAP_SPLIT_AREA, - HEATMAP_VISUAL_MAP_COLORS, + addHeatmapWarning, + cutHeatmapToBudget, + heatmapBudgetWarning, heatmapCellItemStyle, + heatmapColorRange, + heatmapColorScale, heatmapLargeGridDefaults, heatmapValueLabel, + visualMapRangeOf, } from "@/utils/dashboard/heatmapDefaults"; +import { + fillHeatmapYBuckets, + heatmapYBucketInRange, + heatmapYBucketLabel, + heatmapYBucketing, + isHeatmapYBucketLowerBound, + isPlainYField, + sortHeatmapCategories, + type HeatmapYBucketing, +} from "@/utils/dashboard/heatmapYAxis"; +import { applyHeatmapTimePointer, sqlHeatmapTimeAxis } from "@/utils/dashboard/heatmapTimeAxis"; import { escapeHtml } from "@/utils/html"; +import { gt } from "@/types/i18n"; +import { addCommasToNumber } from "@/utils/formatters"; /** * Applies chart-specific options for: heatmap @@ -47,71 +66,82 @@ export function applyHeatmapChart(ctx: SQLContext): void { 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(); + const rows: any[] = searchQueryData[0] ?? []; - searchQueryData[0].forEach((row: any) => { - const yAxisValue = getDataValue(row, key1); - const xAxisValue = getDataValue(row, key0); - const compositeKey = `${yAxisValue}||${xAxisValue}`; + // Tiles of a multi-query panel share one budget, which convertMultiSQLData splits between them. + const budget = panelSchema?.heatmapCellBudget ?? HEATMAP_MAX_CELLS; + const histogramField = histogramTimeField(panelSchema, store); + const { columns: allColumns, timesMs: columnTimesMs } = fillHistogramColumns( + distinctValues(rows, key0), + histogramField ? ctx : null, + budget, + ); + const yBucketing = builderYBucketing(panelSchema, rows, key1); + const allRows = orderYValues(distinctValues(rows, key1), yBucketing, panelSchema?.config); - // Only set if NOT already present (keeps FIRST occurrence) - if (!heatmapLookupMap.has(compositeKey)) { - heatmapLookupMap.set(compositeKey, row); - } - }); + const { lookup, duplicates } = buildCellLookup(rows, key1, key0); + if (duplicates > 0) { + addHeatmapWarning( + ctx.extras, + gt("dashboard.utils.heatmapDuplicateCells", { count: addCommasToNumber(duplicates) }), + ); + } - // 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) || "-" : "-"; + // Checked before the grid exists, so an oversized result never allocates its full matrix. + const cut = cutHeatmapToBudget( + allColumns.length, + allRows.map((yValue) => rowTotal(lookup.get(yValue), yAxisKey0)), + budget, + !!yBucketing, + ); + if (cut) { + addHeatmapWarning(ctx.extras, heatmapBudgetWarning(cut, allColumns.length, allRows.length)); + } + let xAxisZerothPositionUniqueValue: any[] = cut ? allColumns.slice(cut.firstColumn) : allColumns; + const xAxisFirstPositionUniqueValue: any[] = cut + ? cut.rows.map((index) => allRows[index]) + : allRows; + // Drilldowns read the raw row value, not its "(empty)" or range label. + if (ctx.extras) ctx.extras.heatmapRowValues = [xAxisFirstPositionUniqueValue]; + if (columnTimesMs) { + ctx.extras.heatmapTimeAxis = sqlHeatmapTimeAxis( + cut ? columnTimesMs.slice(cut.firstColumn) : columnTimesMs, + Number(ctx.resultMetaData?.[0]?.histogram_interval) * 1000, + queryRangeMs(ctx), + store.state.timezone, + ); + } + + const heatmapCells: any[] = []; + let minValue = Infinity; + let maxValue = -Infinity; + xAxisFirstPositionUniqueValue.forEach((yAxisValue: any, rowIndex: number) => { + const rowCells = lookup.get(yAxisValue); + xAxisZerothPositionUniqueValue.forEach((xAxisValue: any, columnIndex: number) => { + const value = cellValue(rowCells?.get(xAxisValue), yAxisKey0); + heatmapCells.push([columnIndex, rowIndex, value]); + const numeric = value === "-" ? NaN : Number(value); + if (Number.isFinite(numeric)) { + if (numeric < minValue) minValue = numeric; + if (numeric > maxValue) maxValue = numeric; + } }); }); - // Flattened ONCE: the series needs it as `data`, and the label/render guards - // need its length. Rebuilding it for the count would double the work on - // exactly the grids that are already too big. - const heatmapCells: any[] = zValues - .map((it: any, index: any) => - xAxisZerothPositionUniqueValue.map((_i: any, j: number) => [j, index, it[j]]), - ) - .flat(); - + const { color, ...colorRange } = heatmapColorScale( + panelSchema?.config, + heatmapColorRange(panelSchema?.config, visualMapRangeOf(minValue, maxValue)), + [heatmapCells], + ); ((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, - ), + ...colorRange, calculable: true, orient: "horizontal", left: "center", - // Shared heatmap defaults (heatmapDefaults.ts): the same Spectral ramp every - // other heatmap in the app uses, instead of ECharts' stock colours. - inRange: { - color: HEATMAP_VISUAL_MAP_COLORS, - }, + inRange: { color }, }), (options.series = [ { @@ -148,7 +178,6 @@ export function applyHeatmapChart(ctx: SQLContext): void { }), }, ])); - // option.yAxis.data=xAxisFirstPositionUniqueValue; ((options.tooltip = { position: "top", textStyle: { @@ -167,7 +196,7 @@ export function applyHeatmapChart(ctx: SQLContext): void { return ""; // we have value[1] which return yaxis index // it is used to get y axis data - const yLabel = options?.yAxis?.data[params?.value[1]] || params?.seriesName; + const yLabel = options?.yAxis?.data[params?.value[1]] ?? params?.seriesName; const value = formatUnitValue( getUnitValue( @@ -191,36 +220,21 @@ export function applyHeatmapChart(ctx: SQLContext): void { backgroundColor: chartColor("--color-chart-crosshair-bg"), }, })); - // 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)); - }); + if (histogramField) { + xAxisZerothPositionUniqueValue = xAxisZerothPositionUniqueValue.map((it: any) => + 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)); - }); + xAxisZerothPositionUniqueValue = xAxisZerothPositionUniqueValue.map((it: any) => + formatDate(toZonedTime(it + "Z", store.state.timezone)), + ); } } @@ -228,16 +242,160 @@ export function applyHeatmapChart(ctx: SQLContext): void { ((options.xAxis = [ { type: "category", - data: xAxisZerothPositionUniqueValue, + data: xAxisZerothPositionUniqueValue.map(categoryLabel), splitArea: HEATMAP_SPLIT_AREA, }, ]), [ (options.yAxis = { type: "category", - data: xAxisFirstPositionUniqueValue, + data: xAxisFirstPositionUniqueValue.map((value) => + yBucketing ? heatmapYBucketLabel(value, yBucketing.bucket) : categoryLabel(value), + ), splitArea: HEATMAP_SPLIT_AREA, }), ]); options.legend.show = false; + // A y pointer label snaps independently of the hovered cell, so only the tooltip names the row. + if (ctx.extras?.heatmapTimeAxis) applyHeatmapTimePointer(options); + else options.yAxis.axisPointer = { label: { show: false } }; +} + +function categoryLabel(value: any): any { + return value === "" ? gt("dashboard.utils.heatmapEmptyCategory") : value; +} + +/** The builder's y bucketing when it applies to these rows: a bare y column whose every value came back as one of its bucket lower bounds. */ +function builderYBucketing(panelSchema: any, rows: any[], yKey: string): HeatmapYBucketing | null { + const query = panelSchema?.queries?.[0]; + if (query?.customQuery !== false || !isPlainYField(query?.fields?.y?.[0])) return null; + const bucketing = heatmapYBucketing(panelSchema?.config); + if (!bucketing) return null; + for (const row of rows) { + const value = getDataValue(row, yKey); + if (value === null || value === undefined) continue; + if (typeof value !== "number" || !isHeatmapYBucketLowerBound(value, bucketing.bucket)) { + return null; + } + } + return bucketing; +} + +/** Y values in `heatmap_y_sort` order; bucket lower bounds are kept to the y range, gaps filled with empty buckets, and read ascending unless sorted descending. */ +function orderYValues(values: any[], bucketing: HeatmapYBucketing | null, config: any): any[] { + if (!bucketing) return sortHeatmapCategories(values, (value) => value, config?.heatmap_y_sort); + const kept = values.filter((value) => + heatmapYBucketInRange(value, bucketing.bucket, bucketing.range), + ); + return sortHeatmapCategories( + fillHeatmapYBuckets(kept, bucketing.bucket, HEATMAP_MAX_CELLS), + (value) => value, + config?.heatmap_y_sort === "desc" ? "desc" : "asc", + ); +} + +/** Distinct values of `key` in first-seen order; 0 and "" are real categories, only null/undefined are dropped. */ +function distinctValues(rows: any[], key: string): any[] { + const values = new Set(); + for (const row of rows) { + const value = getDataValue(row, key); + if (value !== null && value !== undefined) values.add(value); + } + return [...values]; +} + +/** Rows keyed y then x (first row wins), so values containing any separator cannot collide. */ +function buildCellLookup( + rows: any[], + yKey: string, + xKey: string, +): { lookup: Map>; duplicates: number } { + const lookup = new Map>(); + let duplicates = 0; + for (const row of rows) { + const yValue = getDataValue(row, yKey); + const xValue = getDataValue(row, xKey); + if (yValue === null || yValue === undefined || xValue === null || xValue === undefined) { + continue; + } + let cells = lookup.get(yValue); + if (!cells) { + cells = new Map(); + lookup.set(yValue, cells); + } + if (cells.has(xValue)) duplicates++; + else cells.set(xValue, row); + } + return { lookup, duplicates }; +} + +function cellValue(row: any, zKey: string): any { + if (!row) return "-"; + const value = getDataValue(row, zKey); + return value === null || value === undefined || value === "" ? "-" : value; +} + +/** A row's weight for the cell budget: its summed magnitude, so strongly negative rows rank as strong. */ +function rowTotal(cells: Map | undefined, zKey: string): number { + let total = 0; + cells?.forEach((row) => { + const value = Number(getDataValue(row, zKey)); + if (Number.isFinite(value)) total += Math.abs(value); + }); + return total; +} + +/** The builder x field when it is `histogram()`, else undefined. */ +function histogramTimeField(panelSchema: any, store: any): any { + if (panelSchema?.queries?.[0]?.customQuery != false) return undefined; + return panelSchema.queries[0].fields?.x?.find( + (it: any) => + it.functionName == "histogram" && + it?.args?.[0]?.value?.field == store.state?.zoConfig?.timestamp_column, + ); +} + +function queryRangeMs(ctx: SQLContext): [number, number] { + return [ + Number(ctx?.metadata?.queries?.[0]?.startTime) / 1000, + Number(ctx?.metadata?.queries?.[0]?.endTime) / 1000, + ]; +} + +/** Histogram x values sorted by time, plus an empty column per missing bucket in the query range, with each column's epoch ms; unchanged and without times when any input is unknown. */ +function fillHistogramColumns( + columns: any[], + ctx: SQLContext | null, + maxColumns: number, +): { columns: any[]; timesMs: number[] | null } { + const unchanged = { columns, timesMs: null }; + if (!ctx) return unchanged; + const intervalMs = Number(ctx.resultMetaData?.[0]?.histogram_interval) * 1000; + const [startMs, endMs] = queryRangeMs(ctx); + if (!(intervalMs > 0) || !Number.isFinite(startMs) || !Number.isFinite(endMs)) return unchanged; + if (!columns.length || endMs < startMs) return unchanged; + + const byTime = new Map(); + let newest = -Infinity; + for (const value of columns) { + const ms = Date.parse(String(value).endsWith("Z") ? String(value) : `${value}Z`); + if (!Number.isFinite(ms)) return unchanged; + if (!byTime.has(ms)) byTime.set(ms, value); + if (ms > newest) newest = ms; + } + + // Buckets are labelled by their start, so the first one may begin before the range does. + const anchor = byTime.keys().next().value as number; + let first = anchor - Math.ceil((anchor - startMs) / intervalMs) * intervalMs; + let last = anchor + Math.floor((endMs - anchor) / intervalMs) * intervalMs; + if ((last - first) / intervalMs + 1 > maxColumns) { + // Ending the window at the newest returned bucket keeps data that lies early in a long range on screen. + last = Math.min(last, Math.max(newest, first + (maxColumns - 1) * intervalMs)); + first = last - (maxColumns - 1) * intervalMs; + } + for (let ms = first; ms <= last; ms += intervalMs) { + if (!byTime.has(ms)) byTime.set(ms, new Date(ms).toISOString().slice(0, 19)); + } + const sorted = [...byTime.entries()].sort(([a], [b]) => a - b); + return { columns: sorted.map(([, value]) => value), timesMs: sorted.map(([ms]) => ms) }; } diff --git a/web/src/utils/dashboard/sql/convertSQLChartData.ts b/web/src/utils/dashboard/sql/convertSQLChartData.ts index 54ffe4ef0c..308f2726f2 100644 --- a/web/src/utils/dashboard/sql/convertSQLChartData.ts +++ b/web/src/utils/dashboard/sql/convertSQLChartData.ts @@ -196,8 +196,10 @@ export const convertSQLChartData = async ( if (!["metric", "gauge"].includes(panelSchema.type)) { options.series = options.series.filter((it: any) => it.data?.length); } + // A heatmap's x is categorical, so it zooms and syncs only when its columns carry times. + const heatmapTimeX = panelSchema.type === "heatmap" && !!extras?.heatmapTimeAxis; // allowed to zoom, only if timeseries - options.toolbox.show = options.toolbox.show && isTimeSeriesFlag; + options.toolbox.show = options.toolbox.show && (isTimeSeriesFlag || heatmapTimeX); if ( ["area", "area-stacked", "bar", "h-bar", "line", "scatter", "stacked", "h-stacked"].includes( @@ -265,7 +267,7 @@ export const convertSQLChartData = async ( extras: { ...extras, panelId: panelSchema?.id, - isTimeSeries: isTimeSeriesFlag, + isTimeSeries: isTimeSeriesFlag || heatmapTimeX, }, }; };