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',
{ allowConstantExport: true },
],
'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }],
},
}

View file

@ -13,7 +13,7 @@ import {
PromQLExtension,
newCompleteStrategy,
} from "@prometheus-io/codemirror-promql";
import { FC, useEffect, useState } from "react";
import { FC, useEffect, useMemo, useState } from "react";
import CodeMirror, {
EditorState,
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 {
initialExpr: string;
metricNames: string[];
@ -163,13 +159,15 @@ const ExpressionInput: FC<ExpressionInputProps> = ({
if (formatResult) {
setExpr(formatResult.data);
notifications.show({
color: "green",
title: "Expression formatted",
message: "Expression formatted successfully!",
});
}
}, [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.
useEffect(() => {
// 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 { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
import {
InstantQueryResult,
RangeSamples,
} from "../../api/responseTypes/query";
import { InstantQueryResult } from "../../api/responseTypes/query";
import { useAPIQuery } from "../../api/api";
import classes from "./Graph.module.css";
import { GraphDisplayMode } from "../../state/queryPageSlice";
import { formatSeries } from "../../lib/formatSeries";
import uPlot, { Series } from "uplot";
import UplotReact from "uplot-react";
import {
GraphDisplayMode,
GraphResolution,
getEffectiveResolution,
} from "../../state/queryPageSlice";
import "uplot/dist/uPlot.min.css";
import "./uplot.css";
import { useElementSize } from "@mantine/hooks";
import { formatTimestamp } from "../../lib/formatTime";
import { computePosition, shift, flip, offset, Axis } from "@floating-ui/dom";
import { colorPool } from "./ColorPool";
import UPlotChart, { UPlotChartProps, UPlotChartRange } from "./UPlotChart";
import UPlotChart, { UPlotChartRange } from "./UPlotChart";
export interface GraphProps {
expr: string;
endTime: number | null;
range: number;
resolution: number | null;
resolution: GraphResolution;
showExemplars: boolean;
displayMode: GraphDisplayMode;
retriggerIdx: number;
@ -45,8 +40,7 @@ const Graph: FC<GraphProps> = ({
const effectiveEndTime = (endTime !== null ? endTime : Date.now()) / 1000;
const startTime = effectiveEndTime - range / 1000;
const effectiveResolution =
resolution || Math.max(Math.floor(range / 250000), 1);
const effectiveResolution = getEffectiveResolution(resolution, range) / 1000;
const { data, error, isFetching, isLoading, refetch } =
useAPIQuery<InstantQueryResult>({
@ -62,7 +56,7 @@ const Graph: FC<GraphProps> = ({
});
// 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.
const [displayedChartRange, setDisplayedChartRange] =
useState<UPlotChartRange>({
@ -77,6 +71,8 @@ const Graph: FC<GraphProps> = ({
endTime: effectiveEndTime,
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]);
useEffect(() => {

View file

@ -4,10 +4,10 @@ import {
Center,
Space,
Box,
Input,
SegmentedControl,
Stack,
Select,
TextInput,
} from "@mantine/core";
import {
IconChartAreaFilled,
@ -20,6 +20,8 @@ import { FC, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../state/hooks";
import {
GraphDisplayMode,
GraphResolution,
getEffectiveResolution,
removePanel,
setExpr,
setVisualizer,
@ -29,6 +31,10 @@ import TimeInput from "./TimeInput";
import RangeInput from "./RangeInput";
import ExpressionInput from "./ExpressionInput";
import Graph from "./Graph";
import {
formatPrometheusDuration,
parsePrometheusDuration,
} from "../../lib/formatTime";
export interface PanelProps {
idx: number;
@ -45,8 +51,40 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
const [retriggerIdx, setRetriggerIdx] = useState<number>(0);
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
const resolution = panel.visualizer.resolution;
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 (
<Stack gap="lg">
<ExpressionInput
@ -124,52 +162,99 @@ const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
/>
<Select
title="Resolution"
placeholder="Resolution"
maxDropdownHeight={500}
data={[
{
group: "Automatic resolution",
items: [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
{ label: "Low res.", value: "low" },
{ label: "Medium res.", value: "medium" },
{ label: "High res.", value: "high" },
],
},
{
group: "Fixed resolution",
items: [
{ label: "10s", value: "10" },
{ label: "30s", value: "30" },
{ label: "1m", value: "60" },
{ label: "5m", value: "300" },
{ label: "15m", value: "900" },
{ label: "1h", value: "3600" },
{ label: "10s", value: "10000" },
{ label: "30s", value: "30000" },
{ label: "1m", value: "60000" },
{ label: "5m", value: "300000" },
{ label: "15m", value: "900000" },
{ label: "1h", value: "3600000" },
],
},
{
group: "Custom resolution",
items: [{ label: "Enter value", value: "custom" }],
items: [{ label: "Enter value...", value: "custom" }],
},
]}
w={160}
// value={value ? value.value : null}
value={
resolution.type === "auto"
? resolution.density
: resolution.type === "fixed"
? resolution.value.toString()
: "custom"
}
onChange={(_value, option) => {
dispatch(
setVisualizer({
idx,
visualizer: {
...panel.visualizer,
resolution: option
? option.value
? parseInt(option.value)
: null
: null,
},
})
if (["low", "medium", "high"].includes(option.value)) {
setResolution({
type: "auto",
density: option.value as "low" | "medium" | "high",
});
return;
}
if (option.value === "custom") {
// 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>
<SegmentedControl

View file

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

View file

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

View file

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

View file

@ -7,14 +7,49 @@ export enum GraphDisplayMode {
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
// because we want to preserve and partially share settings while
// switching between display modes.
export interface Visualizer {
activeTab: "table" | "graph" | "explain";
endTime: number | null; // Timestamp in milliseconds.
range: number; // Range in seconds.
resolution: number | null; // Resolution step in seconds.
range: number; // Range in milliseconds.
resolution: GraphResolution;
displayMode: GraphDisplayMode;
showExemplars: boolean;
}
@ -41,7 +76,7 @@ const newDefaultPanel = (): Panel => ({
activeTab: "table",
endTime: null,
range: 3600 * 1000,
resolution: null,
resolution: { type: "auto", density: "medium" },
displayMode: GraphDisplayMode.Lines,
showExemplars: false,
},