vmui: fix step field (#3561)

* feat: use a unit next to the step value

* app/vmselect/vmui: `make vmui-update`

Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
Yury Molodov 2022-12-29 01:00:51 +01:00 committed by Aliaksandr Valialkin
parent 7e49818c6d
commit 6f21435d2d
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
17 changed files with 181 additions and 93 deletions

View file

@ -1,12 +1,12 @@
{
"files": {
"main.css": "./static/css/main.74a50bcc.css",
"main.js": "./static/js/main.2a5e72d0.js",
"main.css": "./static/css/main.9a291a47.css",
"main.js": "./static/js/main.e0294822.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.74a50bcc.css",
"static/js/main.2a5e72d0.js"
"static/css/main.9a291a47.css",
"static/js/main.e0294822.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.2a5e72d0.js"></script><link href="./static/css/main.74a50bcc.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.e0294822.js"></script><link href="./static/css/main.9a291a47.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -38,7 +38,7 @@ const AdditionalSettings: FC = () => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
};
const onChangeStep = (value: number) => {
const onChangeStep = (value: string) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
};

View file

@ -1,14 +1,15 @@
import React, { FC, useCallback, useEffect, useState } from "preact/compat";
import debounce from "lodash.debounce";
import React, { FC, useEffect, useState } from "preact/compat";
import { RestartIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import Button from "../../Main/Button/Button";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { ErrorTypes } from "../../../types";
import { supportedDurations } from "../../../utils/time";
interface StepConfiguratorProps {
defaultStep?: number,
value?: number,
setStep: (step: number) => void,
defaultStep?: string,
value?: string,
setStep: (step: string) => void,
}
const StepConfigurator: FC<StepConfiguratorProps> = ({ value, defaultStep, setStep }) => {
@ -16,40 +17,46 @@ const StepConfigurator: FC<StepConfiguratorProps> = ({ value, defaultStep, setSt
const [customStep, setCustomStep] = useState(value || defaultStep);
const [error, setError] = useState("");
const handleApply = (step: number) => setStep(step || 1);
const debouncedHandleApply = useCallback(debounce(handleApply, 500), []);
const onChangeStep = (val: string) => {
const value = +val;
if (!value) return;
handleSetStep(value);
const handleApply = (value?: string) => {
const step = value || customStep || defaultStep || "1s";
const durations = step.match(/[a-zA-Z]+/g) || [];
setStep(!durations.length ? `${step}s` : step);
};
const handleSetStep = (value: number) => {
if (value > 0) {
setCustomStep(value);
debouncedHandleApply(value);
const handleChangeStep = (value: string) => {
const numbers = value.match(/[-+]?([0-9]*\.[0-9]+|[0-9]+)/g) || [];
const durations = value.match(/[a-zA-Z]+/g) || [];
const isValidNumbers = numbers.length && numbers.every(num => parseFloat(num) > 0);
const isValidDuration = durations.every(d => supportedDurations.find(dur => dur.short === d));
const isValidStep = isValidNumbers && isValidDuration;
setCustomStep(value);
if (isValidStep) {
setError("");
} else {
setError("step is out of allowed range");
setError(ErrorTypes.validStep);
}
};
const handleReset = () => {
handleSetStep(defaultStep || 1);
const value = defaultStep || "1s";
handleChangeStep(value);
handleApply(value);
};
useEffect(() => {
if (value) handleSetStep(value);
if (value) handleChangeStep(value);
}, [value]);
return (
<TextField
label="Step value of seconds"
type="number"
label="Step value"
value={customStep}
error={error}
onChange={onChangeStep}
onChange={handleChangeStep}
onEnter={handleApply}
onBlur={handleApply}
endIcon={(
<Tooltip title="Reset step to default">
<Button

View file

@ -16,7 +16,7 @@ import "./style.scss";
export interface GraphViewProps {
data?: MetricResult[];
period: TimeParams;
customStep: number;
customStep: string;
query: string[];
alias?: string[],
yaxis: YaxisState;
@ -56,7 +56,7 @@ const GraphView: FC<GraphViewProps> = ({
fullWidth = true
}) => {
const { timezone } = useTimeState();
const currentStep = useMemo(() => customStep || period.step || 1, [period.step, customStep]);
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
const [dataChart, setDataChart] = useState<uPlotData>([[]]);
const [series, setSeries] = useState<uPlotSeries[]>([]);

View file

@ -15,7 +15,7 @@ interface FetchQueryParams {
predefinedQuery?: string[]
visible: boolean
display?: DisplayType,
customStep: number,
customStep: string,
hideQuery?: number[]
showAllSeries?: boolean
}

View file

@ -11,10 +11,19 @@ import ExploreMetricItem from "./ExploreMetricItem/ExploreMetricItem";
import TextField from "../../components/Main/TextField/TextField";
import { CloseIcon, SearchIcon } from "../../components/Main/Icons";
import Switch from "../../components/Main/Switch/Switch";
import StepConfigurator from "../../components/Configurators/StepConfigurator/StepConfigurator";
import { useTimeState } from "../../state/time/TimeStateContext";
import { useGraphDispatch, useGraphState } from "../../state/graph/GraphStateContext";
import usePrevious from "../../hooks/usePrevious";
const ExploreMetrics: FC = () => {
useSetQueryParams();
const { period: { step }, duration } = useTimeState();
const { customStep } = useGraphState();
const graphDispatch = useGraphDispatch();
const prevDuration = usePrevious(duration);
const [job, setJob] = useState("");
const [instance, setInstance] = useState("");
const [searchMetric, setSearchMetric] = useState("");
@ -64,14 +73,27 @@ const ExploreMetrics: FC = () => {
});
};
const handleChangeStep = (value: string) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
};
useEffect(() => {
setInstance("");
}, [job]);
useEffect(() => {
if (!customStep && step) handleChangeStep(step);
}, [step]);
useEffect(() => {
if (duration === prevDuration || !prevDuration) return;
if (customStep) handleChangeStep(step || "1s");
}, [duration, prevDuration]);
return (
<div className="vm-explore-metrics">
<div className="vm-explore-metrics-header vm-block">
<div className="vm-explore-metrics-header-top">
<div className="vm-explore-metrics-header__job">
<Select
value={job}
list={jobs}
@ -80,6 +102,8 @@ const ExploreMetrics: FC = () => {
onChange={setJob}
searchable
/>
</div>
<div className="vm-explore-metrics-header__instance">
<Select
value={instance}
list={instances}
@ -90,29 +114,38 @@ const ExploreMetrics: FC = () => {
clearable
searchable
/>
<div className="vm-explore-metrics-header-top__switch-graphs">
<Switch
label={"Show only opened metrics"}
value={onlyGraphs}
onChange={setOnlyGraphs}
/>
</div>
</div>
<TextField
autofocus
label="Metric search"
value={searchMetric}
onChange={setSearchMetric}
startIcon={<SearchIcon/>}
endIcon={(
<div
className="vm-explore-metrics-header__clear-icon"
onClick={handleClearSearch}
>
<CloseIcon/>
</div>
)}
/>
<div className="vm-explore-metrics-header__step">
<StepConfigurator
defaultStep={step}
setStep={handleChangeStep}
value={customStep}
/>
</div>
<div className="vm-explore-metrics-header__switch-graphs">
<Switch
label={"Show only opened metrics"}
value={onlyGraphs}
onChange={setOnlyGraphs}
/>
</div>
<div className="vm-explore-metrics-header__search">
<TextField
autofocus
label="Metric search"
value={searchMetric}
onChange={setSearchMetric}
startIcon={<SearchIcon/>}
endIcon={(
<div
className="vm-explore-metrics-header__clear-icon"
onClick={handleClearSearch}
>
<CloseIcon/>
</div>
)}
/>
</div>
</div>
{isLoading && <Spinner />}

View file

@ -6,22 +6,49 @@
gap: $padding-medium;
&-header {
display: grid;
gap: $padding-small;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-small $padding-medium;
&-top {
display: grid;
grid-template-columns: minmax(200px, 300px) minmax(200px, 500px) auto;
align-items: center;
gap: $padding-medium;
&__switch-graphs {
display: flex;
align-items: center;
justify-content: flex-end;
}
&__job {
min-width: 200px;
max-width: 300px;
width: 100%;
}
&__instance {
min-width: 200px;
max-width: 500px;
width: 100%;
}
&__step {
}
&__switch-graphs {
}
&__search {
flex-grow: 1;
width: 100%;
}
//&-top {
// display: grid;
// grid-template-columns: minmax(200px, 300px) minmax(200px, 500px) auto;
// align-items: center;
// gap: $padding-medium;
//
// &__switch-graphs {
// display: flex;
// align-items: center;
// justify-content: flex-end;
// gap: $padding-medium;
// }
//}
&__clear-icon {
display: flex;
align-items: center;

View file

@ -12,6 +12,7 @@ import { InfoIcon } from "../../../components/Main/Icons";
import "./style.scss";
import Alert from "../../../components/Main/Alert/Alert";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import usePrevious from "../../../hooks/usePrevious";
export interface PredefinedPanelsProps extends PanelSettings {
filename: string;
@ -27,12 +28,13 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
alias
}) => {
const { period } = useTimeState();
const { period, duration } = useTimeState();
const dispatch = useTimeDispatch();
const prevDuration = usePrevious(duration);
const containerRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
const [customStep, setCustomStep] = useState<number>(period.step || 1);
const [customStep, setCustomStep] = useState(period.step || "1s");
const [yaxis, setYaxis] = useState<YaxisState>({
limits: {
enable: false,
@ -75,6 +77,11 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
};
}, []);
useEffect(() => {
if (duration === prevDuration || !prevDuration) return;
if (customStep) setCustomStep(period.step || "1s");
}, [duration, prevDuration]);
if (!validExpr) return (
<Alert variant="error">
<code>&quot;expr&quot;</code> not found. Check the configuration file <b>{filename}</b>.
@ -119,6 +126,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
<div className="vm-predefined-panel-header__step">
<StepConfigurator
defaultStep={period.step}
value={customStep}
setStep={setCustomStep}
/>
</div>

View file

@ -12,17 +12,17 @@ export interface YaxisState {
}
export interface GraphState {
customStep: number
customStep: string
yaxis: YaxisState
}
export type GraphAction =
| { type: "TOGGLE_ENABLE_YAXIS_LIMITS" }
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
| { type: "SET_CUSTOM_STEP", payload: number}
| { type: "SET_CUSTOM_STEP", payload: string}
export const initialGraphState: GraphState = {
customStep: parseFloat(getQueryStringValue("g0.step_input", "0") as string),
customStep: getQueryStringValue("g0.step_input", "") as string,
yaxis: {
limits: { enable: false, range: { "1": [0, 0] } }
}

View file

@ -11,7 +11,7 @@ export type DisplayType = "table" | "chart" | "code";
export interface TimeParams {
start: number; // timestamp in seconds
end: number; // timestamp in seconds
step?: number; // seconds
step?: string; // seconds
date: string; // end input date
}
@ -44,7 +44,8 @@ export enum ErrorTypes {
validQuery = "Please enter a valid Query and execute it",
traceNotFound = "Not found the tracing information",
emptyTitle = "Please enter title",
positiveNumber = "Please enter positive number"
positiveNumber = "Please enter positive number",
validStep = "Please enter a valid step"
}
export interface PanelSettings {

View file

@ -11,11 +11,12 @@ export const limitsDurations = { min: 1, max: 1.578e+11 }; // min: 1 ms, max: 5
// @ts-ignore
export const supportedTimezones = Intl.supportedValuesOf("timeZone") as string[];
// The list of supported units could be the following -
// https://prometheus.io/docs/prometheus/latest/querying/basics/#time-durations
export const supportedDurations = [
{ long: "days", short: "d", possible: "day" },
{ long: "weeks", short: "w", possible: "week" },
{ long: "months", short: "M", possible: "mon" },
{ long: "years", short: "y", possible: "year" },
{ long: "weeks", short: "w", possible: "week" },
{ long: "days", short: "d", possible: "day" },
{ long: "hours", short: "h", possible: "hour" },
{ long: "minutes", short: "m", possible: "min" },
{ long: "seconds", short: "s", possible: "sec" },
@ -27,20 +28,24 @@ const shortDurations = supportedDurations.map(d => d.short);
export const roundToMilliseconds = (num: number): number => Math.round(num*1000)/1000;
const roundStep = (step: number) => {
let result = roundToMilliseconds(step);
const integerStep = Math.round(step);
if (step >= 100) {
return integerStep - (integerStep%10); // integer multiple of 10
result = integerStep - (integerStep%10); // integer multiple of 10
}
if (step < 100 && step >= 10) {
return integerStep - (integerStep%5); // integer multiple of 5
result = integerStep - (integerStep%5); // integer multiple of 5
}
if (step < 10 && step >= 1) {
return integerStep; // integer
result = integerStep; // integer
}
if (step < 1 && step > 0.01) {
return Math.round(step * 40) / 40; // float to thousandths multiple of 5
result = Math.round(step * 40) / 40; // float to thousandths multiple of 5
}
return roundToMilliseconds(step);
const humanize = getDurationFromMilliseconds(dayjs.duration(result || 0.001, "seconds").asMilliseconds());
return humanize.replace(/\s/g, "");
};
export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort, string>> | undefined => {
@ -53,10 +58,10 @@ export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort,
}
};
export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams => {
const n = (date || dayjs().toDate()).valueOf() / 1000;
const durItems = dur.trim().split(" ");
export const getSecondsFromDuration = (dur: string) => {
const shortSupportedDur = supportedDurations.map(d => d.short).join("|");
const regexp = new RegExp(`\\d+[${shortSupportedDur}]+`, "g");
const durItems = dur.match(regexp) || [];
const durObject = durItems.reduce((prev, curr) => {
@ -73,9 +78,15 @@ export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams =
}
}, {});
const delta = dayjs.duration(durObject).asSeconds();
return dayjs.duration(durObject).asSeconds();
};
export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams => {
const n = (date || dayjs().toDate()).valueOf() / 1000;
const delta = getSecondsFromDuration(dur);
const rawStep = delta / MAX_ITEMS_PER_CHART;
const step = roundStep(rawStep) || 0.001;
const step = roundStep(rawStep);
return {
start: n - delta,

View file

@ -1,6 +1,6 @@
import uPlot, { Axis, Series } from "uplot";
import { getMaxFromArray, getMinFromArray } from "../math";
import { roundToMilliseconds } from "../time";
import { getSecondsFromDuration, roundToMilliseconds } from "../time";
import { AxisRange } from "../../state/graph/reducer";
import { formatTicks, sizeAxis } from "./helpers";
import { TimeParams } from "../../types";
@ -30,7 +30,8 @@ export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(n
return axis;
});
export const getTimeSeries = (times: number[], step: number, period: TimeParams): number[] => {
export const getTimeSeries = (times: number[], stepDuration: string, period: TimeParams): number[] => {
const step = getSecondsFromDuration(stepDuration) || 1;
const allTimes = Array.from(new Set(times)).sort((a, b) => a - b);
let t = period.start;
const tEnd = roundToMilliseconds(period.end + step);