mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 16:10:43 +00:00
- Replaced ChartRenderer with PanelSchemaRenderer in MetricCardChart.vue for consistent rendering. - Updated MetricCardChart.spec.ts to assert the panel schema and injected data correctly. - Introduced applyMeasuredYAxisLeftInset utility to adjust y-axis label spacing based on content width. - Enhanced convertPromQLData to call applyMeasuredYAxisLeftInset before returning options. - Added tests for applyMeasuredYAxisLeftInset to ensure correct behavior with various configurations. - Implemented compact preview adjustments in heatmap charts to optimize rendering in metrics explorer cards.
555 lines
18 KiB
TypeScript
555 lines
18 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import {
|
|
PromQLChartConverter,
|
|
ProcessedPromQLData,
|
|
TOOLTIP_SCROLL_STYLE,
|
|
} from "./shared/types";
|
|
import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue";
|
|
import { chartColor } from "@/utils/chartTheme";
|
|
import {
|
|
deaccumulateHistogramSeries,
|
|
HistogramSeriesInput,
|
|
} from "./shared/histogramBuckets";
|
|
import {
|
|
HEATMAP_SPLIT_AREA,
|
|
HEATMAP_VISUAL_MAP_COLORS,
|
|
heatmapCellItemStyle,
|
|
heatmapLargeGridDefaults,
|
|
heatmapValueLabel,
|
|
} from "../heatmapDefaults";
|
|
|
|
/**
|
|
* 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";
|
|
|
|
/**
|
|
* The X axis for a heatmap: every timestamp any query reported, ascending.
|
|
*
|
|
* A union rather than `processedData[0].timestamps`: if the first query returned
|
|
* no data the axis would be empty (blank panel), and if two queries land on
|
|
* 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]> {
|
|
const byTs = new Map<string, [any, any]>();
|
|
|
|
for (const queryData of processedData ?? []) {
|
|
for (const entry of queryData?.timestamps ?? []) {
|
|
// Keep the first formatting seen for a timestamp; two queries that report
|
|
// the same instant format it identically anyway.
|
|
const key = String(entry[0]);
|
|
if (!byTs.has(key)) byTs.set(key, 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 } {
|
|
return {
|
|
min: Number.isFinite(minValue) ? Math.min(0, minValue) : 0,
|
|
max: Number.isFinite(maxValue) ? maxValue : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format timestamps to extract the time portion (consistent with bar charts)
|
|
*/
|
|
function buildXAxisData(timestamps: Array<[any, any]>): string[] {
|
|
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 timeString;
|
|
}) || []
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract the `le` label for a series.
|
|
* Prefers the parsed `metric.le` label; when the metric labels are absent
|
|
* (some callers only carry a rendered legend name), fall back to pulling
|
|
* `le="..."` out of the series name, and finally to the whole name if it
|
|
* looks like a bare bound.
|
|
*/
|
|
export function extractLeLabel(seriesData: any): string {
|
|
const fromMetric = seriesData?.metric?.le;
|
|
if (fromMetric !== undefined && fromMetric !== null) {
|
|
return String(fromMetric);
|
|
}
|
|
|
|
const name = seriesData?.name;
|
|
if (typeof name !== "string") return "";
|
|
|
|
const labelMatch = name.match(/\ble\s*=\s*"([^"]*)"/);
|
|
if (labelMatch) return labelMatch[1];
|
|
|
|
return name.trim();
|
|
}
|
|
|
|
/**
|
|
* Render a bucket bound as a y-axis category label.
|
|
* `+Inf` renders as the literal "+Inf"; finite bounds are formatted with the
|
|
* bucket unit (the unit of the OBSERVATION, e.g. seconds) — distinct from
|
|
* `config.unit`, which describes the cell intensity (count/s).
|
|
*/
|
|
function formatBucketLabel(le: string, leValue: number, config: any): string {
|
|
if (!Number.isFinite(leValue)) {
|
|
return leValue < 0 ? "-Inf" : "+Inf";
|
|
}
|
|
|
|
try {
|
|
const formatted = formatUnitValue(
|
|
getUnitValue(
|
|
leValue,
|
|
config?.bucket_unit,
|
|
config?.bucket_unit_custom,
|
|
config?.decimals,
|
|
),
|
|
);
|
|
return formatted || le;
|
|
} catch (error) {
|
|
return le;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Card-sized heatmap. The metrics explorer's preview cards are a few cm tall,
|
|
* where the default colour bar and full-size axis labels swamp the plot (the
|
|
* cells collapse to a thin line and only one bucket label survives). Shrinks the
|
|
* visual map, thins the bucket labels — always keeping the top `+Inf` row — and
|
|
* drops the font sizes. Mutates and returns `options`. Only run when the card
|
|
* sets `config.compact_preview`.
|
|
*/
|
|
function applyCompactPreview(options: any): any {
|
|
if (options.visualMap) {
|
|
options.visualMap = {
|
|
...options.visualMap,
|
|
show: true,
|
|
orient: "horizontal",
|
|
left: "center",
|
|
bottom: 0,
|
|
itemWidth: 10,
|
|
itemHeight: 90,
|
|
precision: 2,
|
|
textStyle: { fontSize: 9 },
|
|
};
|
|
// Pull the plot to the top edge (no default top inset on a short card) and
|
|
// leave room for the bar below the x-axis labels.
|
|
options.grid = { ...(options.grid ?? {}), top: 8, bottom: 26 };
|
|
}
|
|
|
|
const yAxis = Array.isArray(options.yAxis) ? options.yAxis[0] : options.yAxis;
|
|
const rowCount = yAxis?.data?.length ?? 0;
|
|
if (yAxis && rowCount) {
|
|
// 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
|
|
// so `+Inf` is always labelled and the spacing stays even.
|
|
const step = Math.max(1, Math.ceil(rowCount / 8));
|
|
yAxis.axisLabel = {
|
|
...(yAxis.axisLabel ?? {}),
|
|
fontSize: 9,
|
|
width: 46,
|
|
overflow: "truncate",
|
|
hideOverlap: false,
|
|
interval: (index: number) => (rowCount - 1 - index) % step === 0,
|
|
};
|
|
}
|
|
|
|
const xAxis = Array.isArray(options.xAxis) ? options.xAxis[0] : options.xAxis;
|
|
if (xAxis) {
|
|
xAxis.axisLabel = {
|
|
...(xAxis.axisLabel ?? {}),
|
|
fontSize: 9,
|
|
hideOverlap: true,
|
|
};
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* Converter for heatmap charts
|
|
* Displays time-series data as a 2D heatmap with color intensity
|
|
*/
|
|
export class HeatmapConverter implements PromQLChartConverter {
|
|
supportedTypes = ["heatmap"];
|
|
|
|
convert(
|
|
processedData: ProcessedPromQLData[],
|
|
panelSchema: any,
|
|
store: any,
|
|
extras: any,
|
|
) {
|
|
const config = panelSchema.config || {};
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Heatmap requires 3D data: [x, y, value]
|
|
// X-axis: timestamps
|
|
// Y-axis: series names
|
|
// Value: metric values
|
|
|
|
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);
|
|
|
|
// Format timestamps to extract time portion (consistent with bar charts)
|
|
const xAxisData = buildXAxisData(timeAxis);
|
|
|
|
const options: any = {
|
|
series: [
|
|
{
|
|
type: "heatmap",
|
|
data,
|
|
// Per-cell labels only while the grid is small enough to read them,
|
|
// and chunked rendering when it is not — a `sum by (le) (rate(...))`
|
|
// over a full-size panel is thousands of cells, and a text element +
|
|
// a unit formatter per cell is what hangs the tab. See heatmapDefaults.
|
|
...heatmapLargeGridDefaults(data.length),
|
|
label: heatmapValueLabel(data.length, (params: any) => {
|
|
try {
|
|
return (
|
|
formatUnitValue(
|
|
getUnitValue(
|
|
params?.value?.[2],
|
|
config?.unit,
|
|
config?.unit_custom,
|
|
config?.decimals,
|
|
),
|
|
) || params?.value?.[2]
|
|
);
|
|
} catch (error) {
|
|
return params?.value?.[2]?.toString() ?? "";
|
|
}
|
|
}),
|
|
// Shared heatmap defaults, so a heatmap looks the same wherever it
|
|
// is drawn (see heatmapDefaults.ts).
|
|
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: seriesNames,
|
|
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 <body> 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 seriesName =
|
|
seriesNames[params?.value[1]] || params?.seriesName;
|
|
const value =
|
|
formatUnitValue(
|
|
getUnitValue(
|
|
params?.value?.[2],
|
|
config?.unit,
|
|
config?.unit_custom,
|
|
config?.decimals,
|
|
),
|
|
) || params?.value?.[2];
|
|
return `${seriesName} <br/> ${params?.marker} ${params?.name} : ${value}`;
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
},
|
|
},
|
|
grid: {
|
|
left: "3%",
|
|
right: "4%",
|
|
bottom: 60,
|
|
containLabel: true,
|
|
},
|
|
};
|
|
|
|
return config?.compact_preview ? applyCompactPreview(options) : options;
|
|
}
|
|
|
|
/**
|
|
* Prometheus classic-histogram heatmap.
|
|
*
|
|
* Assumes each series is one cumulative bucket of a classic histogram
|
|
* (typically `sum by(le) (rate(x_bucket[W]))`). The cumulative buckets are
|
|
* de-accumulated into per-bucket values, and the y-axis becomes the sorted
|
|
* `le` bounds rather than raw series names.
|
|
*/
|
|
private convertHistogram(
|
|
processedData: ProcessedPromQLData[],
|
|
config: any,
|
|
store: any,
|
|
extras: any,
|
|
) {
|
|
// 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);
|
|
});
|
|
|
|
// 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<string>();
|
|
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}`;
|
|
}
|
|
|
|
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;
|
|
});
|
|
});
|
|
|
|
// De-accumulated counts are clamped at zero, so this resolves to [0, max];
|
|
// routed through the shared helper so the empty-data case is handled in one place.
|
|
const visualMapRange = 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 <body> 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 ${bucketLabel} <br/> ${params?.marker} ${params?.name} : ${value}`;
|
|
} catch (error) {
|
|
return "";
|
|
}
|
|
},
|
|
},
|
|
grid: {
|
|
left: "3%",
|
|
right: "4%",
|
|
bottom: 60,
|
|
containLabel: true,
|
|
},
|
|
};
|
|
|
|
return config?.compact_preview ? applyCompactPreview(options) : options;
|
|
}
|
|
}
|