diff --git a/web/ui/mantine-ui/.eslintrc.cjs b/web/ui/mantine-ui/.eslintrc.cjs index d6c953795..6ba1275e0 100644 --- a/web/ui/mantine-ui/.eslintrc.cjs +++ b/web/ui/mantine-ui/.eslintrc.cjs @@ -14,5 +14,7 @@ module.exports = { 'warn', { allowConstantExport: true }, ], + 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], + '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], }, } diff --git a/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx b/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx index 4d2eff304..f7514e93f 100644 --- a/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx +++ b/web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx @@ -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 = ({ 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(() => [], []); + // (Re)initialize editor based on settings / setting changes. useEffect(() => { // Build the dynamic part of the config. diff --git a/web/ui/mantine-ui/src/pages/query/Graph.tsx b/web/ui/mantine-ui/src/pages/query/Graph.tsx index 39ec28fc9..532598237 100644 --- a/web/ui/mantine-ui/src/pages/query/Graph.tsx +++ b/web/ui/mantine-ui/src/pages/query/Graph.tsx @@ -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 = ({ 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({ @@ -62,7 +56,7 @@ const Graph: FC = ({ }); // 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({ @@ -77,6 +71,8 @@ const Graph: FC = ({ 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(() => { diff --git a/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx b/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx index ab5303dd5..2730ac717 100644 --- a/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx +++ b/web/ui/mantine-ui/src/pages/query/QueryPanel.tsx @@ -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 = ({ idx, metricNames }) => { const [retriggerIdx, setRetriggerIdx] = useState(0); const panel = useAppSelector((state) => state.queryPage.panels[idx]); + const resolution = panel.visualizer.resolution; const dispatch = useAppDispatch(); + const [customResolutionInput, setCustomResolutionInput] = useState( + 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 ( = ({ idx, metricNames }) => { /> setRangeInput(event.currentTarget.value)} onBlur={() => onChangeRangeInput(rangeInput)} diff --git a/web/ui/mantine-ui/src/pages/query/TimeInput.tsx b/web/ui/mantine-ui/src/pages/query/TimeInput.tsx index 193b2bba0..5d4bc184b 100644 --- a/web/ui/mantine-ui/src/pages/query/TimeInput.tsx +++ b/web/ui/mantine-ui/src/pages/query/TimeInput.tsx @@ -26,6 +26,7 @@ const TimeInput: FC = ({ { @@ -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,44 +323,43 @@ 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 }) => { - // Insert nulls for all missing steps. - const data: (number | null)[] = []; - let valuePos = 0; - let histogramPos = 0; + const values = inputData.map(({ values, histograms }) => { + // Insert nulls for all missing steps. + const data: (number | null)[] = []; + let valuePos = 0; + let histogramPos = 0; - for (let t = startTime; t <= endTime; t += resolution) { - // Allow for floating point inaccuracy. - const currentValue = values && values[valuePos]; - const currentHistogram = histograms && histograms[histogramPos]; - if ( - currentValue && - values.length > valuePos && - currentValue[0] < t + resolution / 100 - ) { - data.push(parseValue(currentValue[1])); - valuePos++; - } else if ( - currentHistogram && - histograms.length > histogramPos && - currentHistogram[0] < t + resolution / 100 - ) { - data.push(parseValue(currentHistogram[1].sum)); - histogramPos++; - } else { - data.push(null); - } + for (let t = startTime; t <= endTime; t += resolution) { + // Allow for floating point inaccuracy. + const currentValue = values && values[valuePos]; + const currentHistogram = histograms && histograms[histogramPos]; + if ( + currentValue && + values.length > valuePos && + currentValue[0] < t + resolution / 100 + ) { + data.push(parseValue(currentValue[1])); + valuePos++; + } else if ( + currentHistogram && + histograms.length > histogramPos && + currentHistogram[0] < t + resolution / 100 + ) { + data.push(parseValue(currentHistogram[1].sum)); + histogramPos++; + } else { + data.push(null); } - return data; - }) - ); + } + return data; + }); + + return [timeData, ...values]; }; const parseValue = (value: string): null | number => { @@ -388,7 +387,6 @@ export interface UPlotChartProps { const UPlotChart: FC = ({ data, range: { startTime, endTime, resolution }, - displayMode, width, onSelectRange, }) => { diff --git a/web/ui/mantine-ui/src/state/queryPageSlice.ts b/web/ui/mantine-ui/src/state/queryPageSlice.ts index 1dbdc6ca5..e5c1812cf 100644 --- a/web/ui/mantine-ui/src/state/queryPageSlice.ts +++ b/web/ui/mantine-ui/src/state/queryPageSlice.ts @@ -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, },