A lot of work around uPlot and resolution picking

Signed-off-by: Julius Volz <julius.volz@gmail.com>
This commit is contained in:
Julius Volz 2024-07-21 23:55:08 +02:00
parent 1c91b82206
commit 8ef66c41a0
8 changed files with 223 additions and 107 deletions

View file

@ -14,5 +14,7 @@ module.exports = {
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
}, },
} }

View file

@ -13,7 +13,7 @@ import {
PromQLExtension, PromQLExtension,
newCompleteStrategy, newCompleteStrategy,
} from "@prometheus-io/codemirror-promql"; } from "@prometheus-io/codemirror-promql";
import { FC, useEffect, useState } from "react"; import { FC, useEffect, useMemo, useState } from "react";
import CodeMirror, { import CodeMirror, {
EditorState, EditorState,
EditorView, EditorView,
@ -107,10 +107,6 @@ export class HistoryCompleteStrategy implements CompleteStrategy {
} }
} }
// This is just a placeholder until query history is implemented, so disable the linter warning.
// eslint-disable-next-line react-hooks/exhaustive-deps
const queryHistory = [] as string[];
interface ExpressionInputProps { interface ExpressionInputProps {
initialExpr: string; initialExpr: string;
metricNames: string[]; metricNames: string[];
@ -163,13 +159,15 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
if (formatResult) { if (formatResult) {
setExpr(formatResult.data); setExpr(formatResult.data);
notifications.show({ notifications.show({
color: "green",
title: "Expression formatted", title: "Expression formatted",
message: "Expression formatted successfully!", message: "Expression formatted successfully!",
}); });
} }
}, [formatResult, formatError]); }, [formatResult, formatError]);
// This is just a placeholder until query history is implemented, so disable the linter warning.
const queryHistory = useMemo<string[]>(() => [], []);
// (Re)initialize editor based on settings / setting changes. // (Re)initialize editor based on settings / setting changes.
useEffect(() => { useEffect(() => {
// Build the dynamic part of the config. // Build the dynamic part of the config.

View file

@ -1,29 +1,24 @@
import { FC, useEffect, useId, useLayoutEffect, useState } from "react"; import { FC, useEffect, useId, useState } from "react";
import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core"; import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core";
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react"; import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
import { import { InstantQueryResult } from "../../api/responseTypes/query";
InstantQueryResult,
RangeSamples,
} from "../../api/responseTypes/query";
import { useAPIQuery } from "../../api/api"; import { useAPIQuery } from "../../api/api";
import classes from "./Graph.module.css"; import classes from "./Graph.module.css";
import { GraphDisplayMode } from "../../state/queryPageSlice"; import {
import { formatSeries } from "../../lib/formatSeries"; GraphDisplayMode,
import uPlot, { Series } from "uplot"; GraphResolution,
import UplotReact from "uplot-react"; getEffectiveResolution,
} from "../../state/queryPageSlice";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import "./uplot.css"; import "./uplot.css";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { formatTimestamp } from "../../lib/formatTime"; import UPlotChart, { UPlotChartRange } from "./UPlotChart";
import { computePosition, shift, flip, offset, Axis } from "@floating-ui/dom";
import { colorPool } from "./ColorPool";
import UPlotChart, { UPlotChartProps, UPlotChartRange } from "./UPlotChart";
export interface GraphProps { export interface GraphProps {
expr: string; expr: string;
endTime: number | null; endTime: number | null;
range: number; range: number;
resolution: number | null; resolution: GraphResolution;
showExemplars: boolean; showExemplars: boolean;
displayMode: GraphDisplayMode; displayMode: GraphDisplayMode;
retriggerIdx: number; retriggerIdx: number;
@ -45,8 +40,7 @@ const Graph: FC<GraphProps> = ({
const effectiveEndTime = (endTime !== null ? endTime : Date.now()) / 1000; const effectiveEndTime = (endTime !== null ? endTime : Date.now()) / 1000;
const startTime = effectiveEndTime - range / 1000; const startTime = effectiveEndTime - range / 1000;
const effectiveResolution = const effectiveResolution = getEffectiveResolution(resolution, range) / 1000;
resolution || Math.max(Math.floor(range / 250000), 1);
const { data, error, isFetching, isLoading, refetch } = const { data, error, isFetching, isLoading, refetch } =
useAPIQuery<InstantQueryResult>({ useAPIQuery<InstantQueryResult>({
@ -62,7 +56,7 @@ const Graph: FC<GraphProps> = ({
}); });
// Keep the displayed chart range separate from the actual query range, so that // Keep the displayed chart range separate from the actual query range, so that
// the chart will keep displaying the old range while a new query for a different range // the chart will keep displaying the old range while a query for a new range
// is still in progress. // is still in progress.
const [displayedChartRange, setDisplayedChartRange] = const [displayedChartRange, setDisplayedChartRange] =
useState<UPlotChartRange>({ useState<UPlotChartRange>({
@ -77,6 +71,8 @@ const Graph: FC<GraphProps> = ({
endTime: effectiveEndTime, endTime: effectiveEndTime,
resolution: effectiveResolution, resolution: effectiveResolution,
}); });
// We actually want to update the displayed range only once the new data is there.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {

View file

@ -4,10 +4,10 @@ import {
Center, Center,
Space, Space,
Box, Box,
Input,
SegmentedControl, SegmentedControl,
Stack, Stack,
Select, Select,
TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconChartAreaFilled, IconChartAreaFilled,
@ -20,6 +20,8 @@ import { FC, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../state/hooks"; import { useAppDispatch, useAppSelector } from "../../state/hooks";
import { import {
GraphDisplayMode, GraphDisplayMode,
GraphResolution,
getEffectiveResolution,
removePanel, removePanel,
setExpr, setExpr,
setVisualizer, setVisualizer,
@ -29,6 +31,10 @@ import TimeInput from "./TimeInput";
import RangeInput from "./RangeInput"; import RangeInput from "./RangeInput";
import ExpressionInput from "./ExpressionInput"; import ExpressionInput from "./ExpressionInput";
import Graph from "./Graph"; import Graph from "./Graph";
import {
formatPrometheusDuration,
parsePrometheusDuration,
} from "../../lib/formatTime";
export interface PanelProps { export interface PanelProps {
idx: number; idx: number;
@ -45,8 +51,40 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
const [retriggerIdx, setRetriggerIdx] = useState<number>(0); const [retriggerIdx, setRetriggerIdx] = useState<number>(0);
const panel = useAppSelector((state) => state.queryPage.panels[idx]); const panel = useAppSelector((state) => state.queryPage.panels[idx]);
const resolution = panel.visualizer.resolution;
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [customResolutionInput, setCustomResolutionInput] = useState<string>(
formatPrometheusDuration(
getEffectiveResolution(resolution, panel.visualizer.range)
)
);
const setResolution = (res: GraphResolution) => {
dispatch(
setVisualizer({
idx,
visualizer: {
...panel.visualizer,
resolution: res,
},
})
);
};
const onChangeCustomResolutionInput = (resText: string): void => {
const newResolution = parsePrometheusDuration(resText);
if (newResolution === null) {
setCustomResolutionInput(
formatPrometheusDuration(
getEffectiveResolution(resolution, panel.visualizer.range)
)
);
} else {
setResolution({ type: "custom", value: newResolution });
}
};
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<ExpressionInput <ExpressionInput
@ -124,52 +162,99 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
/> />
<Select <Select
title="Resolution"
placeholder="Resolution" placeholder="Resolution"
maxDropdownHeight={500} maxDropdownHeight={500}
data={[ data={[
{ {
group: "Automatic resolution", group: "Automatic resolution",
items: [ items: [
{ label: "Low", value: "low" }, { label: "Low res.", value: "low" },
{ label: "Medium", value: "medium" }, { label: "Medium res.", value: "medium" },
{ label: "High", value: "high" }, { label: "High res.", value: "high" },
], ],
}, },
{ {
group: "Fixed resolution", group: "Fixed resolution",
items: [ items: [
{ label: "10s", value: "10" }, { label: "10s", value: "10000" },
{ label: "30s", value: "30" }, { label: "30s", value: "30000" },
{ label: "1m", value: "60" }, { label: "1m", value: "60000" },
{ label: "5m", value: "300" }, { label: "5m", value: "300000" },
{ label: "15m", value: "900" }, { label: "15m", value: "900000" },
{ label: "1h", value: "3600" }, { label: "1h", value: "3600000" },
], ],
}, },
{ {
group: "Custom resolution", group: "Custom resolution",
items: [{ label: "Enter value", value: "custom" }], items: [{ label: "Enter value...", value: "custom" }],
}, },
]} ]}
w={160} w={160}
// value={value ? value.value : null} value={
resolution.type === "auto"
? resolution.density
: resolution.type === "fixed"
? resolution.value.toString()
: "custom"
}
onChange={(_value, option) => { onChange={(_value, option) => {
dispatch( if (["low", "medium", "high"].includes(option.value)) {
setVisualizer({ setResolution({
idx, type: "auto",
visualizer: { density: option.value as "low" | "medium" | "high",
...panel.visualizer, });
resolution: option return;
? option.value }
? parseInt(option.value)
: null if (option.value === "custom") {
: null, // Start the custom resolution at the current effective resolution.
}, const effectiveResolution = getEffectiveResolution(
}) resolution,
); panel.visualizer.range
);
setResolution({
type: "custom",
value: effectiveResolution,
});
setCustomResolutionInput(
formatPrometheusDuration(effectiveResolution)
);
return;
}
const value = parseInt(option.value);
if (!isNaN(value)) {
setResolution({
type: "fixed",
value,
});
} else {
throw new Error("Invalid resolution value");
}
}} }}
clearable
/> />
{resolution.type === "custom" && (
<TextInput
placeholder="Resolution"
value={customResolutionInput}
onChange={(event) =>
setCustomResolutionInput(event.currentTarget.value)
}
onBlur={() =>
onChangeCustomResolutionInput(customResolutionInput)
}
onKeyDown={(event) =>
event.key === "Enter" &&
onChangeCustomResolutionInput(customResolutionInput)
}
aria-label="Range"
style={{
width: `calc(44px + ${customResolutionInput.length + 3}ch)`,
}}
/>
)}
</Group> </Group>
<SegmentedControl <SegmentedControl

View file

@ -79,6 +79,7 @@ const RangeInput: FC<RangeInputProps> = ({ range, onChangeRange }) => {
return ( return (
<Group gap={5}> <Group gap={5}>
<Input <Input
title="Range"
value={rangeInput} value={rangeInput}
onChange={(event) => setRangeInput(event.currentTarget.value)} onChange={(event) => setRangeInput(event.currentTarget.value)}
onBlur={() => onChangeRangeInput(rangeInput)} onBlur={() => onChangeRangeInput(rangeInput)}

View file

@ -26,6 +26,7 @@ const TimeInput: FC<TimeInputProps> = ({
<Group gap={5}> <Group gap={5}>
<DatesProvider settings={{ timezone: useLocalTime ? undefined : "UTC" }}> <DatesProvider settings={{ timezone: useLocalTime ? undefined : "UTC" }}>
<DateTimePicker <DateTimePicker
title="End time"
w={230} w={230}
valueFormat="YYYY-MM-DD HH:mm:ss" valueFormat="YYYY-MM-DD HH:mm:ss"
withSeconds withSeconds

View file

@ -1,11 +1,5 @@
import { FC, useEffect, useId, useState } from "react"; import { FC, useEffect, useState } from "react";
import { Alert, Skeleton, Box, LoadingOverlay } from "@mantine/core"; import { RangeSamples } from "../../api/responseTypes/query";
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
import {
InstantQueryResult,
RangeSamples,
} from "../../api/responseTypes/query";
import { useAPIQuery } from "../../api/api";
import classes from "./Graph.module.css"; import classes from "./Graph.module.css";
import { GraphDisplayMode } from "../../state/queryPageSlice"; import { GraphDisplayMode } from "../../state/queryPageSlice";
import { formatSeries } from "../../lib/formatSeries"; import { formatSeries } from "../../lib/formatSeries";
@ -13,9 +7,8 @@ import uPlot, { Series } from "uplot";
import UplotReact from "uplot-react"; import UplotReact from "uplot-react";
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import "./uplot.css"; import "./uplot.css";
import { useElementSize } from "@mantine/hooks";
import { formatTimestamp } from "../../lib/formatTime"; import { formatTimestamp } from "../../lib/formatTime";
import { computePosition, shift, flip, offset, Axis } from "@floating-ui/dom"; import { computePosition, shift, flip, offset } from "@floating-ui/dom";
import { colorPool } from "./ColorPool"; import { colorPool } from "./ColorPool";
const formatYAxisTickValue = (y: number | null): string => { const formatYAxisTickValue = (y: number | null): string => {
@ -132,7 +125,7 @@ const tooltipPlugin = () => {
}, },
// When a series is selected by hovering close to it, store the // When a series is selected by hovering close to it, store the
// index of the selected series. // index of the selected series.
setSeries: (self: uPlot, seriesIdx: number | null, opts: Series) => { setSeries: (_u: uPlot, seriesIdx: number | null, _opts: Series) => {
selectedSeriesIdx = seriesIdx; selectedSeriesIdx = seriesIdx;
}, },
// When the cursor is moved, update the tooltip with the current // When the cursor is moved, update the tooltip with the current
@ -155,8 +148,12 @@ const tooltipPlugin = () => {
const ts = u.data[0][idx]; const ts = u.data[0][idx];
const value = u.data[selectedSeriesIdx][idx]; const value = u.data[selectedSeriesIdx][idx];
const series = u.series[selectedSeriesIdx]; const series = u.series[selectedSeriesIdx];
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
const labels = series.labels; const labels = series.labels;
const color = series.stroke(); if (typeof series.stroke !== "function") {
throw new Error("series.stroke is not a function");
}
const color = series.stroke(u, selectedSeriesIdx);
const x = left + boundingLeft; const x = left + boundingLeft;
const y = top + boundingTop; const y = top + boundingTop;
@ -205,29 +202,31 @@ const tooltipPlugin = () => {
// A helper function to automatically create enough space for the Y axis // A helper function to automatically create enough space for the Y axis
// ticket labels depending on their length. // ticket labels depending on their length.
const autoPadLeft = ( const autoPadLeft = (
self: uPlot, u: uPlot,
values: string[], values: string[],
axisIdx: number, axisIdx: number,
cycleNum: number cycleNum: number
) => { ) => {
const axis = self.axes[axisIdx]; const axis = u.axes[axisIdx];
// bail out, force convergence // bail out, force convergence
if (cycleNum > 1) { if (cycleNum > 1) {
// @ts-expect-error - got this from a uPlot demo example, not sure if it's correct.
return axis._size; return axis._size;
} }
let axisSize = axis.ticks.size + axis.gap; let axisSize = axis.ticks!.size! + axis.gap!;
// find longest value // Find longest tick text.
const longestVal = (values ?? []).reduce( const longestVal = (values ?? []).reduce(
(acc, val) => (val.length > acc.length ? val : acc), (acc, val) => (val.length > acc.length ? val : acc),
"" ""
); );
if (longestVal != "") { if (longestVal != "") {
self.ctx.font = axis.font[0]; console.log("axis.font", axis.font![0]);
axisSize += self.ctx.measureText(longestVal).width / devicePixelRatio; u.ctx.font = axis.font![0];
axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio;
} }
return Math.ceil(axisSize); return Math.ceil(axisSize);
@ -236,7 +235,7 @@ const autoPadLeft = (
const getOptions = ( const getOptions = (
width: number, width: number,
result: RangeSamples[], result: RangeSamples[],
onSelectRange: (start: number, end: number) => void onSelectRange: (_start: number, _end: number) => void
): uPlot.Options => ({ ): uPlot.Options => ({
width: width - 30, width: width - 30,
height: 550, height: 550,
@ -258,7 +257,7 @@ const getOptions = (
live: false, live: false,
markers: { markers: {
fill: ( fill: (
self: uPlot, _u: uPlot,
seriesIdx: number seriesIdx: number
): CSSStyleDeclaration["borderColor"] => { ): CSSStyleDeclaration["borderColor"] => {
return colorPool[seriesIdx % colorPool.length]; return colorPool[seriesIdx % colorPool.length];
@ -285,7 +284,7 @@ const getOptions = (
}, },
// Y axis (sample value). // Y axis (sample value).
{ {
values: (u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue), values: (_u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue),
border: { border: {
show: true, show: true,
stroke: "#333", stroke: "#333",
@ -298,6 +297,7 @@ const getOptions = (
}, },
], ],
series: [ series: [
{},
...result.map((r, idx) => ({ ...result.map((r, idx) => ({
label: formatSeries(r.metric), label: formatSeries(r.metric),
width: 2, width: 2,
@ -323,44 +323,43 @@ export const normalizeData = (
endTime: number, endTime: number,
resolution: number resolution: number
): uPlot.AlignedData => { ): uPlot.AlignedData => {
const timeData: (number | null)[][] = []; const timeData: number[] = [];
timeData[0] = [];
for (let t = startTime; t <= endTime; t += resolution) { for (let t = startTime; t <= endTime; t += resolution) {
timeData[0].push(t); timeData.push(t);
} }
return timeData.concat( const values = inputData.map(({ values, histograms }) => {
inputData.map(({ values, histograms }) => { // Insert nulls for all missing steps.
// Insert nulls for all missing steps. const data: (number | null)[] = [];
const data: (number | null)[] = []; let valuePos = 0;
let valuePos = 0; let histogramPos = 0;
let histogramPos = 0;
for (let t = startTime; t <= endTime; t += resolution) { for (let t = startTime; t <= endTime; t += resolution) {
// Allow for floating point inaccuracy. // Allow for floating point inaccuracy.
const currentValue = values && values[valuePos]; const currentValue = values && values[valuePos];
const currentHistogram = histograms && histograms[histogramPos]; const currentHistogram = histograms && histograms[histogramPos];
if ( if (
currentValue && currentValue &&
values.length > valuePos && values.length > valuePos &&
currentValue[0] < t + resolution / 100 currentValue[0] < t + resolution / 100
) { ) {
data.push(parseValue(currentValue[1])); data.push(parseValue(currentValue[1]));
valuePos++; valuePos++;
} else if ( } else if (
currentHistogram && currentHistogram &&
histograms.length > histogramPos && histograms.length > histogramPos &&
currentHistogram[0] < t + resolution / 100 currentHistogram[0] < t + resolution / 100
) { ) {
data.push(parseValue(currentHistogram[1].sum)); data.push(parseValue(currentHistogram[1].sum));
histogramPos++; histogramPos++;
} else { } else {
data.push(null); data.push(null);
}
} }
return data; }
}) return data;
); });
return [timeData, ...values];
}; };
const parseValue = (value: string): null | number => { const parseValue = (value: string): null | number => {
@ -388,7 +387,6 @@ export interface UPlotChartProps {
const UPlotChart: FC<UPlotChartProps> = ({ const UPlotChart: FC<UPlotChartProps> = ({
data, data,
range: { startTime, endTime, resolution }, range: { startTime, endTime, resolution },
displayMode,
width, width,
onSelectRange, onSelectRange,
}) => { }) => {

View file

@ -7,14 +7,49 @@ export enum GraphDisplayMode {
Heatmap = "heatmap", Heatmap = "heatmap",
} }
export type GraphResolution =
| {
type: "auto";
density: "low" | "medium" | "high";
}
| {
type: "fixed";
value: number; // Resolution step in milliseconds.
}
| {
type: "custom";
value: number; // Resolution step in milliseconds.
};
export const getEffectiveResolution = (
resolution: GraphResolution,
range: number
) => {
switch (resolution.type) {
case "auto": {
const factor =
resolution.density === "high"
? 750
: resolution.density === "medium"
? 250
: 100;
return Math.max(Math.floor(range / factor), 1);
}
case "fixed":
return resolution.value; // TODO: Scope this to a list?
case "custom":
return resolution.value;
}
};
// NOTE: This is not represented as a discriminated union type // NOTE: This is not represented as a discriminated union type
// because we want to preserve and partially share settings while // because we want to preserve and partially share settings while
// switching between display modes. // switching between display modes.
export interface Visualizer { export interface Visualizer {
activeTab: "table" | "graph" | "explain"; activeTab: "table" | "graph" | "explain";
endTime: number | null; // Timestamp in milliseconds. endTime: number | null; // Timestamp in milliseconds.
range: number; // Range in seconds. range: number; // Range in milliseconds.
resolution: number | null; // Resolution step in seconds. resolution: GraphResolution;
displayMode: GraphDisplayMode; displayMode: GraphDisplayMode;
showExemplars: boolean; showExemplars: boolean;
} }
@ -41,7 +76,7 @@ const newDefaultPanel = (): Panel => ({
activeTab: "table", activeTab: "table",
endTime: null, endTime: null,
range: 3600 * 1000, range: 3600 * 1000,
resolution: null, resolution: { type: "auto", density: "medium" },
displayMode: GraphDisplayMode.Lines, displayMode: GraphDisplayMode.Lines,
showExemplars: false, showExemplars: false,
}, },