diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9940a5186 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "prometheus", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/ui/mantine-ui/src/pages/query/DataTable.module.css b/web/ui/mantine-ui/src/pages/query/DataTable.module.css index 255173d9b..d8ff4a511 100644 --- a/web/ui/mantine-ui/src/pages/query/DataTable.module.css +++ b/web/ui/mantine-ui/src/pages/query/DataTable.module.css @@ -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; +} diff --git a/web/ui/mantine-ui/src/pages/query/DataTable.tsx b/web/ui/mantine-ui/src/pages/query/DataTable.tsx index 7eda6c759..36e1038d6 100644 --- a/web/ui/mantine-ui/src/pages/query/DataTable.tsx +++ b/web/ui/mantine-ui/src/pages/query/DataTable.tsx @@ -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 = ({ expr, evalTime, retriggerIdx }) => { + const [scale, setScale] = useState("exponential"); + const { data, error, isFetching, isLoading, refetch } = useAPIQuery({ key: useId(), @@ -101,7 +114,7 @@ const DataTable: FC = ({ expr, evalTime, retriggerIdx }) => { }} styles={{ loader: { width: "100%", height: "100%" } }} /> - +
{resultType === "vector" ? ( limitSeries(result).map((s, idx) => ( @@ -111,7 +124,35 @@ const DataTable: FC = ({ expr, evalTime, retriggerIdx }) => { {s.value && s.value[1]} - {s.histogram && "TODO HISTOGRAM DISPLAY"} + {s.histogram && ( + <> + +
+
+ + Total count: {s.histogram[1].count} + + + Sum: {s.histogram[1].sum} + +
+
+ x-axis scale: + +
+
+ {histogramTable(s.histogram[1])} + + )}
)) @@ -161,4 +202,44 @@ const DataTable: FC = ({ expr, evalTime, retriggerIdx }) => { ); }; +const histogramTable = (h: Histogram): ReactNode => ( +
+ + + + Bucket counts + + + + + + Range + Count + + + {h.buckets?.map((b, i) => ( + + + {bucketRangeString(b)} + + {b[3]} + + ))} + + +
+); + export default DataTable; diff --git a/web/ui/mantine-ui/src/pages/query/HistogramChart.module.css b/web/ui/mantine-ui/src/pages/query/HistogramChart.module.css new file mode 100644 index 000000000..61b9546af --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/HistogramChart.module.css @@ -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; +} diff --git a/web/ui/mantine-ui/src/pages/query/HistogramChart.tsx b/web/ui/mantine-ui/src/pages/query/HistogramChart.tsx new file mode 100644 index 000000000..a574d1df4 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/HistogramChart.tsx @@ -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 = ({ + index, + histogram, + scale, +}) => { + const { buckets } = histogram; + if (!buckets || buckets.length === 0) { + return
No data
; + } + 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 ( +
+
+ {[1, 0.75, 0.5, 0.25].map((i) => ( +
+ {scale === "linear" ? "" : formatter.format(countMax * i)} +
+ ))} +
+ 0 +
+
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map((i) => ( + +
+
+
+
+ ))} +
+
+
+
+ + + +
+
+
+
+ +
+ {formatter.format(rangeMin)} +
+ {rangeMin < 0 && zeroAxis && ( +
+ 0 +
+ )} +
+ {formatter.format(rangeMax)} +
+
+
+
+
+
+ ); +}; + +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 = ({ + buckets, + scale, + rangeMin, + rangeMax, + index, + fds, + fdMax, + countMax, + defaultExpBucketWidth, + minPositive, + maxNegative, + startPositive, + startNegative, + xWidthNegative, + xWidthTotal, +}) => { + return ( + + {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 ( + +
+
+
+
+ ); + })} +
+ ); +}; + +export default HistogramChart; diff --git a/web/ui/mantine-ui/src/pages/query/HistogramHelpers.ts b/web/ui/mantine-ui/src/pages/query/HistogramHelpers.ts new file mode 100644 index 000000000..0bb76ba42 --- /dev/null +++ b/web/ui/mantine-ui/src/pages/query/HistogramHelpers.ts @@ -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)}`; +}; diff --git a/web/ui/mantine-ui/src/types/types.ts b/web/ui/mantine-ui/src/types/types.ts new file mode 100644 index 000000000..ef67eca05 --- /dev/null +++ b/web/ui/mantine-ui/src/types/types.ts @@ -0,0 +1,5 @@ +export interface Histogram { + count: string; + sum: string; + buckets?: [number, string, string, string][]; +}