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": { "files": {
"main.css": "./static/css/main.74a50bcc.css", "main.css": "./static/css/main.9a291a47.css",
"main.js": "./static/js/main.2a5e72d0.js", "main.js": "./static/js/main.e0294822.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js", "static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"index.html": "./index.html" "index.html": "./index.html"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.74a50bcc.css", "static/css/main.9a291a47.css",
"static/js/main.2a5e72d0.js" "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" }); queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
}; };
const onChangeStep = (value: number) => { const onChangeStep = (value: string) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value }); graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
}; };

View file

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

View file

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

View file

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

View file

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

View file

@ -6,22 +6,49 @@
gap: $padding-medium; gap: $padding-medium;
&-header { &-header {
display: grid; display: flex;
gap: $padding-small; flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-small $padding-medium;
&-top { &__job {
display: grid; min-width: 200px;
grid-template-columns: minmax(200px, 300px) minmax(200px, 500px) auto; max-width: 300px;
align-items: center; width: 100%;
gap: $padding-medium;
&__switch-graphs {
display: flex;
align-items: center;
justify-content: flex-end;
}
} }
&__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 { &__clear-icon {
display: flex; display: flex;
align-items: center; align-items: center;

View file

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

View file

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

View file

@ -11,7 +11,7 @@ export type DisplayType = "table" | "chart" | "code";
export interface TimeParams { export interface TimeParams {
start: number; // timestamp in seconds start: number; // timestamp in seconds
end: number; // timestamp in seconds end: number; // timestamp in seconds
step?: number; // seconds step?: string; // seconds
date: string; // end input date date: string; // end input date
} }
@ -44,7 +44,8 @@ export enum ErrorTypes {
validQuery = "Please enter a valid Query and execute it", validQuery = "Please enter a valid Query and execute it",
traceNotFound = "Not found the tracing information", traceNotFound = "Not found the tracing information",
emptyTitle = "Please enter title", emptyTitle = "Please enter title",
positiveNumber = "Please enter positive number" positiveNumber = "Please enter positive number",
validStep = "Please enter a valid step"
} }
export interface PanelSettings { 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 // @ts-ignore
export const supportedTimezones = Intl.supportedValuesOf("timeZone") as string[]; 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 = [ 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: "years", short: "y", possible: "year" },
{ long: "weeks", short: "w", possible: "week" },
{ long: "days", short: "d", possible: "day" },
{ long: "hours", short: "h", possible: "hour" }, { long: "hours", short: "h", possible: "hour" },
{ long: "minutes", short: "m", possible: "min" }, { long: "minutes", short: "m", possible: "min" },
{ long: "seconds", short: "s", possible: "sec" }, { 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; export const roundToMilliseconds = (num: number): number => Math.round(num*1000)/1000;
const roundStep = (step: number) => { const roundStep = (step: number) => {
let result = roundToMilliseconds(step);
const integerStep = Math.round(step); const integerStep = Math.round(step);
if (step >= 100) { if (step >= 100) {
return integerStep - (integerStep%10); // integer multiple of 10 result = integerStep - (integerStep%10); // integer multiple of 10
} }
if (step < 100 && step >= 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) { if (step < 10 && step >= 1) {
return integerStep; // integer result = integerStep; // integer
} }
if (step < 1 && step > 0.01) { 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 => { 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 => { export const getSecondsFromDuration = (dur: string) => {
const n = (date || dayjs().toDate()).valueOf() / 1000; const shortSupportedDur = supportedDurations.map(d => d.short).join("|");
const regexp = new RegExp(`\\d+[${shortSupportedDur}]+`, "g");
const durItems = dur.trim().split(" "); const durItems = dur.match(regexp) || [];
const durObject = durItems.reduce((prev, curr) => { 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 rawStep = delta / MAX_ITEMS_PER_CHART;
const step = roundStep(rawStep) || 0.001; const step = roundStep(rawStep);
return { return {
start: n - delta, start: n - delta,

View file

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