feat (ui): Add Native Histogram rendering to new UI (#14431)

* feat: scaffold new ui native histogram

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* feat: add native histogram rendering

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* feat: add tooltip

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* fix:revert package-lock changes

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* chore: fix tab spacing

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* fix: apply suggestions and fixes

Signed-off-by: Manik Rana <manikrana54@gmail.com>

* chore: lint

Signed-off-by: Manik Rana <manikrana54@gmail.com>

---------

Signed-off-by: Manik Rana <manikrana54@gmail.com>
This commit is contained in:
Manik Rana 2024-07-10 02:21:37 +05:30 committed by GitHub
parent 0eea8645fa
commit 373f09796d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 668 additions and 4 deletions

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "prometheus",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -8,3 +8,16 @@
text-align: right;
font-variant-numeric: tabular-nums;
}
.histogramSummaryWrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
}
.histogramSummary {
display: flex;
align-items: center;
gap: 1rem;
}

View file

@ -1,5 +1,13 @@
import { FC, useEffect, useId } from "react";
import { Table, Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core";
import { FC, ReactNode, useEffect, useId, useState } from "react";
import {
Table,
Alert,
Skeleton,
Box,
LoadingOverlay,
SegmentedControl,
ScrollArea,
} from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
import {
InstantQueryResult,
@ -13,6 +21,9 @@ import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import { useAppSelector } from "../../state/hooks";
import { formatTimestamp } from "../../lib/formatTime";
import HistogramChart from "./HistogramChart";
import { Histogram } from "../../types/types";
import { bucketRangeString } from "./HistogramHelpers";
dayjs.extend(timezone);
const maxFormattableSeries = 1000;
@ -34,6 +45,8 @@ export interface DataTableProps {
}
const DataTable: FC<DataTableProps> = ({ expr, evalTime, retriggerIdx }) => {
const [scale, setScale] = useState<string>("exponential");
const { data, error, isFetching, isLoading, refetch } =
useAPIQuery<InstantQueryResult>({
key: useId(),
@ -101,7 +114,7 @@ const DataTable: FC<DataTableProps> = ({ expr, evalTime, retriggerIdx }) => {
}}
styles={{ loader: { width: "100%", height: "100%" } }}
/>
<Table highlightOnHover fz="xs">
<Table fz="xs">
<Table.Tbody>
{resultType === "vector" ? (
limitSeries<InstantSample>(result).map((s, idx) => (
@ -111,7 +124,35 @@ const DataTable: FC<DataTableProps> = ({ expr, evalTime, retriggerIdx }) => {
</Table.Td>
<Table.Td className={classes.numberCell}>
{s.value && s.value[1]}
{s.histogram && "TODO HISTOGRAM DISPLAY"}
{s.histogram && (
<>
<HistogramChart
histogram={s.histogram[1]}
index={idx}
scale={scale}
/>
<div className={classes.histogramSummaryWrapper}>
<div className={classes.histogramSummary}>
<span>
<strong>Total count:</strong> {s.histogram[1].count}
</span>
<span>
<strong>Sum:</strong> {s.histogram[1].sum}
</span>
</div>
<div className={classes.histogramSummary}>
<span>x-axis scale:</span>
<SegmentedControl
size={"xs"}
value={scale}
onChange={setScale}
data={["exponential", "linear"]}
/>
</div>
</div>
{histogramTable(s.histogram[1])}
</>
)}
</Table.Td>
</Table.Tr>
))
@ -161,4 +202,44 @@ const DataTable: FC<DataTableProps> = ({ expr, evalTime, retriggerIdx }) => {
);
};
const histogramTable = (h: Histogram): ReactNode => (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ textAlign: "center" }} colSpan={2}>
Bucket counts
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<Table.Tr
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<Table.Th>Range</Table.Th>
<Table.Th>Count</Table.Th>
</Table.Tr>
<ScrollArea w={"100%"} h={250}>
{h.buckets?.map((b, i) => (
<Table.Tr key={i}>
<Table.Td style={{ textAlign: "left" }}>
{bucketRangeString(b)}
</Table.Td>
<Table.Td>{b[3]}</Table.Td>
</Table.Tr>
))}
</ScrollArea>
</Table.Tbody>
</Table>
);
export default DataTable;

View file

@ -0,0 +1,106 @@
.histogramYWrapper {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
box-sizing: border-box;
margin: 15px 0;
width: 100%;
}
.histogramYLabels {
height: 200px;
display: flex;
flex-direction: column;
}
.histogramYLabel {
margin-right: 8px;
height: 25%;
text-align: right;
}
.histogramXWrapper {
flex: 1 1 auto;
display: flex;
flex-direction: column;
margin-right: 8px;
}
.histogramXLabels {
display: flex;
}
.histogramXLabel {
position: relative;
margin-top: 5px;
width: 100%;
text-align: right;
}
.histogramContainer {
margin-top: 9px;
position: relative;
height: 200px;
}
.histogramAxes {
position: absolute;
width: 100%;
height: 100%;
border-bottom: 1px solid var(--mantine-color-gray-7);
border-left: 1px solid var(--mantine-color-gray-7);
pointer-events: none;
}
.histogramYGrid {
position: absolute;
border-bottom: 1px dashed var(--mantine-color-gray-6);
width: 100%;
}
.histogramYTick {
position: absolute;
border-bottom: 1px solid var(--mantine-color-gray-7);
left: -5px;
height: 0px;
width: 5px;
}
.histogramXGrid {
position: absolute;
border-left: 1px dashed var(--mantine-color-gray-6);
height: 100%;
width: 0;
}
.histogramXTick {
position: absolute;
border-left: 1px solid var(--mantine-color-gray-7);
height: 5px;
width: 0;
bottom: -5px;
}
.histogramBucketSlot {
position: absolute;
bottom: 0;
top: 0;
}
.histogramBucket {
position: absolute;
width: 100%;
bottom: 0;
background-color: #2db453;
border: 1px solid #77de94;
pointer-events: none;
}
.histogramBucketSlot:hover {
background-color: var(--mantine-color-gray-4);
}
.histogramBucketSlot:hover .histogramBucket {
background-color: #88e1a1;
border: 1px solid #77de94;
}

View file

@ -0,0 +1,309 @@
import React, { FC } from "react";
import { Histogram } from "../../types/types";
import {
calculateDefaultExpBucketWidth,
findMinPositive,
findMaxNegative,
findZeroAxisLeft,
showZeroAxis,
findZeroBucket,
bucketRangeString,
} from "./HistogramHelpers";
import classes from "./HistogramChart.module.css";
import { Tooltip } from "@mantine/core";
interface HistogramChartProps {
histogram: Histogram;
index: number;
scale: string;
}
const HistogramChart: FC<HistogramChartProps> = ({
index,
histogram,
scale,
}) => {
const { buckets } = histogram;
if (!buckets || buckets.length === 0) {
return <div>No data</div>;
}
const formatter = Intl.NumberFormat("en", { notation: "compact" });
// For linear scales, the count of a histogram bucket is represented by its area rather than its height. This means it considers
// both the count and the range (width) of the bucket. For this, we can set the height of the bucket proportional
// to its frequency density (fd). The fd is the count of the bucket divided by the width of the bucket.
const fds = [];
for (const bucket of buckets) {
const left = parseFloat(bucket[1]);
const right = parseFloat(bucket[2]);
const count = parseFloat(bucket[3]);
const width = right - left;
// This happens when a user want observations of precisely zero to be included in the zero bucket
if (width === 0) {
fds.push(0);
continue;
}
fds.push(count / width);
}
const fdMax = Math.max(...fds);
const first = buckets[0];
const last = buckets[buckets.length - 1];
const rangeMax = parseFloat(last[2]);
const rangeMin = parseFloat(first[1]);
const countMax = Math.max(...buckets.map((b) => parseFloat(b[3])));
const defaultExpBucketWidth = calculateDefaultExpBucketWidth(last, buckets);
const maxPositive = rangeMax > 0 ? rangeMax : 0;
const minPositive = findMinPositive(buckets);
const maxNegative = findMaxNegative(buckets);
const minNegative = parseFloat(first[1]) < 0 ? parseFloat(first[1]) : 0;
// Calculate the borders of positive and negative buckets in the exponential scale from left to right
const startNegative =
minNegative !== 0 ? -Math.log(Math.abs(minNegative)) : 0;
const endNegative = maxNegative !== 0 ? -Math.log(Math.abs(maxNegative)) : 0;
const startPositive = minPositive !== 0 ? Math.log(minPositive) : 0;
const endPositive = maxPositive !== 0 ? Math.log(maxPositive) : 0;
// Calculate the width of negative, positive, and all exponential bucket ranges on the x-axis
const xWidthNegative = endNegative - startNegative;
const xWidthPositive = endPositive - startPositive;
const xWidthTotal = xWidthNegative + defaultExpBucketWidth + xWidthPositive;
const zeroBucketIdx = findZeroBucket(buckets);
const zeroAxisLeft = findZeroAxisLeft(
scale,
rangeMin,
rangeMax,
minPositive,
maxNegative,
zeroBucketIdx,
xWidthNegative,
xWidthTotal,
defaultExpBucketWidth,
);
const zeroAxis = showZeroAxis(zeroAxisLeft);
return (
<div className={classes.histogramYWrapper}>
<div className={classes.histogramYLabels}>
{[1, 0.75, 0.5, 0.25].map((i) => (
<div key={i} className={classes.histogramYLabel}>
{scale === "linear" ? "" : formatter.format(countMax * i)}
</div>
))}
<div key={0} className={classes.histogramYLabel} style={{ height: 0 }}>
0
</div>
</div>
<div className={classes.histogramXWrapper}>
<div className={classes.histogramContainer}>
{[0, 0.25, 0.5, 0.75, 1].map((i) => (
<React.Fragment key={i}>
<div
className={classes.histogramYGrid}
style={{ bottom: i * 100 + "%" }}
></div>
<div
className={classes.histogramYTick}
style={{ bottom: i * 100 + "%" }}
></div>
<div
className={classes.histogramXGrid}
style={{ left: i * 100 + "%" }}
></div>
</React.Fragment>
))}
<div className={classes.histogramXTick} style={{ left: "0%" }}></div>
<div
className={classes.histogramXTick}
style={{ left: zeroAxisLeft }}
></div>
<div
className={classes.histogramXGrid}
style={{ left: zeroAxisLeft }}
></div>
<div
className={classes.histogramXTick}
style={{ left: "100%" }}
></div>
<RenderHistogramBars
buckets={buckets}
scale={scale}
rangeMin={rangeMin}
rangeMax={rangeMax}
index={index}
fds={fds}
fdMax={fdMax}
countMax={countMax}
defaultExpBucketWidth={defaultExpBucketWidth}
minPositive={minPositive}
maxNegative={maxNegative}
startPositive={startPositive}
startNegative={startNegative}
xWidthNegative={xWidthNegative}
xWidthTotal={xWidthTotal}
/>
<div className={classes.histogramAxes}></div>
</div>
<div className={classes.histogramXLabels}>
<div className={classes.histogramXLabel}>
<React.Fragment>
<div style={{ position: "absolute", left: 0 }}>
{formatter.format(rangeMin)}
</div>
{rangeMin < 0 && zeroAxis && (
<div style={{ position: "absolute", left: zeroAxisLeft }}>
0
</div>
)}
<div style={{ position: "absolute", right: 0 }}>
{formatter.format(rangeMax)}
</div>
</React.Fragment>
</div>
</div>
</div>
</div>
);
};
interface RenderHistogramProps {
buckets: [number, string, string, string][];
scale: string;
rangeMin: number;
rangeMax: number;
index: number;
fds: number[];
fdMax: number;
countMax: number;
defaultExpBucketWidth: number;
minPositive: number;
maxNegative: number;
startPositive: number;
startNegative: number;
xWidthNegative: number;
xWidthTotal: number;
}
const RenderHistogramBars: FC<RenderHistogramProps> = ({
buckets,
scale,
rangeMin,
rangeMax,
index,
fds,
fdMax,
countMax,
defaultExpBucketWidth,
minPositive,
maxNegative,
startPositive,
startNegative,
xWidthNegative,
xWidthTotal,
}) => {
return (
<React.Fragment>
{buckets.map((b, bIdx) => {
const left = parseFloat(b[1]);
const right = parseFloat(b[2]);
const count = parseFloat(b[3]);
const bucketIdx = `bucket-${index}-${bIdx}-${Math.ceil(parseFloat(b[3]) * 100)}`;
const logWidth = Math.abs(
Math.log(Math.abs(right)) - Math.log(Math.abs(left)),
);
const expBucketWidth =
logWidth === 0 ? defaultExpBucketWidth : logWidth;
let bucketWidth = "";
let bucketLeft = "";
let bucketHeight = "";
switch (scale) {
case "linear": {
bucketWidth = ((right - left) / (rangeMax - rangeMin)) * 100 + "%";
bucketLeft =
((left - rangeMin) / (rangeMax - rangeMin)) * 100 + "%";
if (left === 0 && right === 0) {
bucketLeft = "0%"; // do not render zero-width zero bucket
bucketWidth = "0%";
}
bucketHeight = (fds[bIdx] / fdMax) * 100 + "%";
break;
}
case "exponential": {
let adjust = 0; // if buckets are all positive/negative, we need to remove the width of the zero bucket
if (minPositive === 0 || maxNegative === 0) {
adjust = defaultExpBucketWidth;
}
bucketWidth = (expBucketWidth / (xWidthTotal - adjust)) * 100 + "%";
if (left < 0) {
// negative buckets boundary
bucketLeft =
(-(Math.log(Math.abs(left)) + startNegative) /
(xWidthTotal - adjust)) *
100 +
"%";
} else {
// positive buckets boundary
bucketLeft =
((Math.log(left) -
startPositive +
defaultExpBucketWidth +
xWidthNegative -
adjust) /
(xWidthTotal - adjust)) *
100 +
"%";
}
if (left < 0 && right > 0) {
// if the bucket crosses the zero axis
bucketLeft = (xWidthNegative / xWidthTotal) * 100 + "%";
}
if (left === 0 && right === 0) {
// do not render zero width zero bucket
bucketLeft = "0%";
bucketWidth = "0%";
}
bucketHeight = (count / countMax) * 100 + "%";
break;
}
default:
throw new Error("Invalid scale");
}
return (
<Tooltip label={`range: ${bucketRangeString(b)}`} key={bIdx}>
<div
id={bucketIdx}
className={classes.histogramBucketSlot}
style={{
left: bucketLeft,
width: bucketWidth,
}}
>
<div
id={bucketIdx}
className={classes.histogramBucket}
style={{
height: bucketHeight,
}}
></div>
</div>
</Tooltip>
);
})}
</React.Fragment>
);
};
export default HistogramChart;

View file

@ -0,0 +1,144 @@
// Calculates a default width of exponential histogram bucket ranges. If the last bucket is [0, 0],
// the width is calculated using the second to last bucket. returns error if the last bucket is [-0, 0],
export function calculateDefaultExpBucketWidth(
last: [number, string, string, string],
buckets: [number, string, string, string][]
): number {
if (parseFloat(last[2]) === 0 || parseFloat(last[1]) === 0) {
if (buckets.length > 1) {
return Math.abs(
Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][2]))) -
Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][1])))
);
} else {
throw new Error(
"Only one bucket in histogram ([-0, 0]). Cannot calculate defaultExpBucketWidth."
);
}
} else {
return Math.abs(
Math.log(Math.abs(parseFloat(last[2]))) -
Math.log(Math.abs(parseFloat(last[1])))
);
}
}
// Finds the lowest positive value from the bucket ranges
// Returns 0 if no positive values are found or if there are no buckets.
export function findMinPositive(buckets: [number, string, string, string][]) {
if (!buckets || buckets.length === 0) {
return 0; // no buckets
}
for (let i = 0; i < buckets.length; i++) {
const right = parseFloat(buckets[i][2]);
const left = parseFloat(buckets[i][1]);
if (left > 0) {
return left;
}
if (left < 0 && right > 0) {
return right;
}
if (i === buckets.length - 1) {
if (right > 0) {
return right;
}
}
}
return 0; // all buckets are negative
}
// Finds the lowest negative value from the bucket ranges
// Returns 0 if no negative values are found or if there are no buckets.
export function findMaxNegative(buckets: [number, string, string, string][]) {
if (!buckets || buckets.length === 0) {
return 0; // no buckets
}
for (let i = 0; i < buckets.length; i++) {
const right = parseFloat(buckets[i][2]);
const left = parseFloat(buckets[i][1]);
const prevRight = i > 0 ? parseFloat(buckets[i - 1][2]) : 0;
if (right >= 0) {
if (i === 0) {
if (left < 0) {
return left; // return the first negative bucket
}
return 0; // all buckets are positive
}
return prevRight; // return the last negative bucket
}
}
console.log("findmaxneg returning: ", buckets[buckets.length - 1][2]);
return parseFloat(buckets[buckets.length - 1][2]); // all buckets are negative
}
// Calculates the left position of the zero axis as a percentage string.
export function findZeroAxisLeft(
scale: string,
rangeMin: number,
rangeMax: number,
minPositive: number,
maxNegative: number,
zeroBucketIdx: number,
widthNegative: number,
widthTotal: number,
expBucketWidth: number
): string {
if (scale === "linear") {
return ((0 - rangeMin) / (rangeMax - rangeMin)) * 100 + "%";
} else {
if (maxNegative === 0) {
return "0%";
}
if (minPositive === 0) {
return "100%";
}
if (zeroBucketIdx === -1) {
// if there is no zero bucket, we must zero axis between buckets around zero
return (widthNegative / widthTotal) * 100 + "%";
}
if ((widthNegative + 0.5 * expBucketWidth) / widthTotal > 0) {
return ((widthNegative + 0.5 * expBucketWidth) / widthTotal) * 100 + "%";
} else {
return "0%";
}
}
}
// Determines if the zero axis should be shown such that the zero label does not overlap with the range labels.
// The zero axis is shown if it is between 5% and 95% of the graph.
export function showZeroAxis(zeroAxisLeft: string) {
const axisNumber = parseFloat(zeroAxisLeft.slice(0, -1));
if (5 < axisNumber && axisNumber < 95) {
return true;
}
return false;
}
// Finds the index of the bucket whose range includes zero
export function findZeroBucket(
buckets: [number, string, string, string][]
): number {
for (let i = 0; i < buckets.length; i++) {
const left = parseFloat(buckets[i][1]);
const right = parseFloat(buckets[i][2]);
if (left <= 0 && right >= 0) {
return i;
}
}
return -1;
}
const leftDelim = (br: number): string => (br === 3 || br === 1 ? "[" : "(");
const rightDelim = (br: number): string => (br === 3 || br === 0 ? "]" : ")");
export const bucketRangeString = ([
boundaryRule,
leftBoundary,
rightBoundary,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_,
]: [number, string, string, string]): string => {
return `${leftDelim(boundaryRule)}${leftBoundary} -> ${rightBoundary}${rightDelim(boundaryRule)}`;
};

View file

@ -0,0 +1,5 @@
export interface Histogram {
count: string;
sum: string;
buckets?: [number, string, string, string][];
}