mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-01-19 23:09:18 +01:00
vmui: add custom start range (#1989)
* feat: add custom start range * app/vmselect/vmui: `make vmui-update` Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
parent
ce333f28d8
commit
4b40acd964
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "./static/css/main.a33903a8.css",
|
"main.css": "./static/css/main.a33903a8.css",
|
||||||
"main.js": "./static/js/main.23f635e5.js",
|
"main.js": "./static/js/main.2587cf95.js",
|
||||||
"static/js/27.85f0e2b0.chunk.js": "./static/js/27.85f0e2b0.chunk.js",
|
"static/js/27.85f0e2b0.chunk.js": "./static/js/27.85f0e2b0.chunk.js",
|
||||||
"index.html": "./index.html"
|
"index.html": "./index.html"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.a33903a8.css",
|
"static/css/main.a33903a8.css",
|
||||||
"static/js/main.23f635e5.js"
|
"static/js/main.2587cf95.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -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="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.23f635e5.js"></script><link href="./static/css/main.a33903a8.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="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script defer="defer" src="./static/js/main.2587cf95.js"></script><link href="./static/css/main.a33903a8.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
2
app/vmselect/vmui/static/js/main.2587cf95.js
Normal file
2
app/vmselect/vmui/static/js/main.2587cf95.js
Normal file
File diff suppressed because one or more lines are too long
@ -20,7 +20,7 @@ export interface QueryConfiguratorProps {
|
|||||||
|
|
||||||
const QueryConfigurator: FC<QueryConfiguratorProps> = ({error}) => {
|
const QueryConfigurator: FC<QueryConfiguratorProps> = ({error}) => {
|
||||||
|
|
||||||
const {serverUrl, query, queryHistory, time: {duration}, queryControls: {autocomplete}} = useAppState();
|
const {serverUrl, query, queryHistory, queryControls: {autocomplete}} = useAppState();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const queryContainer = useRef<HTMLDivElement>(null);
|
const queryContainer = useRef<HTMLDivElement>(null);
|
||||||
@ -123,7 +123,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({error}) => {
|
|||||||
</Box>}
|
</Box>}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs>
|
<Grid item xs>
|
||||||
<TimeSelector setDuration={onSetDuration} duration={duration}/>
|
<TimeSelector setDuration={onSetDuration}/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} pt={1}>
|
<Grid item xs={12} pt={1}>
|
||||||
<AdditionalSettings/>
|
<AdditionalSettings/>
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
import React, {FC, useEffect, useState} from "react";
|
||||||
|
import {Box, Popover, TextField, Typography} from "@mui/material";
|
||||||
|
import {checkDurationLimit} from "../../../../utils/time";
|
||||||
|
import {TimeDurationPopover} from "./TimeDurationPopover";
|
||||||
|
import {InlineBtn} from "../../../common/InlineBtn";
|
||||||
|
import {useAppState} from "../../../../state/common/StateContext";
|
||||||
|
|
||||||
|
interface TimeDurationSelector {
|
||||||
|
setDuration: (str: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimeDurationSelector: FC<TimeDurationSelector> = ({setDuration}) => {
|
||||||
|
const {time: {duration}} = useAppState();
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
|
||||||
|
const [durationString, setDurationString] = useState<string>(duration);
|
||||||
|
const [durationStringFocused, setFocused] = useState(false);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
|
const handleDurationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setDurationString(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverOpen = (event: React.MouseEvent<Element, MouseEvent>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
target.blur();
|
||||||
|
setDurationString(target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDurationString(duration);
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!durationStringFocused) {
|
||||||
|
const value = checkDurationLimit(durationString);
|
||||||
|
setDurationString(value);
|
||||||
|
setDuration(value);
|
||||||
|
}
|
||||||
|
}, [durationString, durationStringFocused]);
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Box>
|
||||||
|
<TextField label="Duration" value={durationString} onChange={handleDurationChange}
|
||||||
|
variant="standard"
|
||||||
|
fullWidth={true}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box mt={2}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
<span aria-owns={open ? "mouse-over-popover" : undefined}
|
||||||
|
aria-haspopup="true"
|
||||||
|
style={{cursor: "pointer"}}
|
||||||
|
onMouseEnter={handlePopoverOpen}
|
||||||
|
onMouseLeave={handlePopoverClose}>
|
||||||
|
Possible options:
|
||||||
|
</span>
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "left",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "left",
|
||||||
|
}}
|
||||||
|
style={{pointerEvents: "none"}} // important
|
||||||
|
onClose={handlePopoverClose}
|
||||||
|
disableRestoreFocus
|
||||||
|
>
|
||||||
|
<TimeDurationPopover/>
|
||||||
|
</Popover>
|
||||||
|
<InlineBtn handler={() => setDurationString("5m")} text="5m"/>,
|
||||||
|
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,
|
||||||
|
<InlineBtn handler={() => setDurationString("1h 30m")} text="1h 30m"/>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimeDurationSelector;
|
@ -1,141 +1,98 @@
|
|||||||
import React, {FC, useEffect, useState} from "react";
|
import React, {FC, useEffect, useState} from "react";
|
||||||
import {Box, Popover, TextField, Typography} from "@mui/material";
|
import {Box, TextField, Typography} from "@mui/material";
|
||||||
import DateTimePicker from "@mui/lab/DateTimePicker";
|
import DateTimePicker from "@mui/lab/DateTimePicker";
|
||||||
import {TimeDurationPopover} from "./TimeDurationPopover";
|
|
||||||
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
|
||||||
import {checkDurationLimit, dateFromSeconds, formatDateForNativeInput} from "../../../../utils/time";
|
import {dateFromSeconds, formatDateForNativeInput} from "../../../../utils/time";
|
||||||
import {InlineBtn} from "../../../common/InlineBtn";
|
import {InlineBtn} from "../../../common/InlineBtn";
|
||||||
import makeStyles from "@mui/styles/makeStyles";
|
import makeStyles from "@mui/styles/makeStyles";
|
||||||
|
import TimeDurationSelector from "./TimeDurationSelector";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
interface TimeSelectorProps {
|
interface TimeSelectorProps {
|
||||||
setDuration: (str: string) => void;
|
setDuration: (str: string) => void;
|
||||||
duration: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
container: {
|
container: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "auto auto",
|
gridTemplateColumns: "200px 1fr",
|
||||||
|
gridGap: "20px",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
padding: "18px 14px",
|
padding: "20px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
borderColor: "#b9b9b9",
|
borderColor: "#b9b9b9",
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
borderWidth: "1px"
|
borderWidth: "1px"
|
||||||
}
|
},
|
||||||
|
timeControls: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr",
|
||||||
|
gridTemplateRows: "auto 1fr",
|
||||||
|
gridGap: "16px 0",
|
||||||
|
},
|
||||||
|
datePickers: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, 200px)",
|
||||||
|
gridGap: "16px 0",
|
||||||
|
},
|
||||||
|
datePickerItem: {
|
||||||
|
minWidth: "200px",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TimeSelector: FC<TimeSelectorProps> = ({setDuration}) => {
|
export const TimeSelector: FC<TimeSelectorProps> = ({setDuration}) => {
|
||||||
|
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
|
||||||
const [durationStringFocused, setFocused] = useState(false);
|
|
||||||
const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
|
|
||||||
const [until, setUntil] = useState<string>();
|
const [until, setUntil] = useState<string>();
|
||||||
|
const [from, setFrom] = useState<string>();
|
||||||
|
|
||||||
const {time: {period: {end}, duration}} = useAppState();
|
const {time: {period: {end, start}}} = useAppState();
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [durationString, setDurationString] = useState<string>(duration);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDurationString(duration);
|
|
||||||
}, [duration]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||||
}, [end]);
|
}, [end]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!durationStringFocused) {
|
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||||
const value = checkDurationLimit(durationString);
|
}, [start]);
|
||||||
setDurationString(value);
|
|
||||||
setDuration(value);
|
|
||||||
}
|
|
||||||
}, [durationString, durationStringFocused]);
|
|
||||||
|
|
||||||
const handleDurationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setDurationString(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopoverOpen = (event: React.MouseEvent<Element, MouseEvent>) => {
|
|
||||||
setAnchorEl(event.currentTarget);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopoverClose = () => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (event.key !== "Enter") return;
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
target.blur();
|
|
||||||
setDurationString(target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const open = Boolean(anchorEl);
|
|
||||||
|
|
||||||
return <Box className={classes.container}>
|
return <Box className={classes.container}>
|
||||||
{/*setup duration*/}
|
{/*setup duration*/}
|
||||||
<Box px={1}>
|
<Box>
|
||||||
<Box>
|
<TimeDurationSelector setDuration={setDuration}/>
|
||||||
<TextField label="Duration" value={durationString} onChange={handleDurationChange}
|
|
||||||
variant="standard"
|
|
||||||
fullWidth={true}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onBlur={() => {setFocused(false);}}
|
|
||||||
onFocus={() => {setFocused(true);}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box mt={2}>
|
|
||||||
<Typography variant="body2">
|
|
||||||
<span aria-owns={open ? "mouse-over-popover" : undefined}
|
|
||||||
aria-haspopup="true"
|
|
||||||
style={{cursor: "pointer"}}
|
|
||||||
onMouseEnter={handlePopoverOpen}
|
|
||||||
onMouseLeave={handlePopoverClose}>
|
|
||||||
Possible options:
|
|
||||||
</span>
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
anchorOrigin={{
|
|
||||||
vertical: "bottom",
|
|
||||||
horizontal: "left",
|
|
||||||
}}
|
|
||||||
transformOrigin={{
|
|
||||||
vertical: "top",
|
|
||||||
horizontal: "left",
|
|
||||||
}}
|
|
||||||
style={{pointerEvents: "none"}} // important
|
|
||||||
onClose={handlePopoverClose}
|
|
||||||
disableRestoreFocus
|
|
||||||
>
|
|
||||||
<TimeDurationPopover/>
|
|
||||||
</Popover>
|
|
||||||
<InlineBtn handler={() => setDurationString("5m")} text="5m"/>,
|
|
||||||
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,
|
|
||||||
<InlineBtn handler={() => setDurationString("1h 30m")} text="1h 30m"/>
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
{/*setup end time*/}
|
{/*setup end time*/}
|
||||||
<Box px={1}>
|
<Box className={classes.timeControls}>
|
||||||
<Box>
|
<Box className={classes.datePickers}>
|
||||||
<DateTimePicker
|
<Box className={classes.datePickerItem}>
|
||||||
label="Until"
|
<DateTimePicker
|
||||||
ampm={false}
|
label="From"
|
||||||
value={until}
|
ampm={false}
|
||||||
onChange={date => dispatch({type: "SET_UNTIL", payload: date as unknown as Date})}
|
value={from}
|
||||||
onError={console.log}
|
onChange={date => dispatch({type: "SET_FROM", payload: date as unknown as Date})}
|
||||||
inputFormat="DD/MM/YYYY HH:mm:ss"
|
onError={console.log}
|
||||||
mask="__/__/____ __:__:__"
|
inputFormat="DD/MM/YYYY HH:mm:ss"
|
||||||
renderInput={(params) => <TextField {...params} variant="standard"/>}
|
mask="__/__/____ __:__:__"
|
||||||
/>
|
renderInput={(params) => <TextField {...params} variant="standard"/>}
|
||||||
|
maxDate={dayjs(until)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box className={classes.datePickerItem}>
|
||||||
|
<DateTimePicker
|
||||||
|
label="Until"
|
||||||
|
ampm={false}
|
||||||
|
value={until}
|
||||||
|
onChange={date => dispatch({type: "SET_UNTIL", payload: date as unknown as Date})}
|
||||||
|
onError={console.log}
|
||||||
|
inputFormat="DD/MM/YYYY HH:mm:ss"
|
||||||
|
mask="__/__/____ __:__:__"
|
||||||
|
renderInput={(params) => <TextField {...params} variant="standard"/>}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box>
|
||||||
<Box mt={2}>
|
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
Will be changed to current time for auto-refresh mode.
|
Will be changed to current time for auto-refresh mode.
|
||||||
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
|
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
/* eslint max-lines: 0 */
|
/* eslint max-lines: 0 */
|
||||||
import {DisplayType} from "../../components/Home/Configurator/DisplayTypeSwitch";
|
import {DisplayType} from "../../components/Home/Configurator/DisplayTypeSwitch";
|
||||||
import {TimeParams, TimePeriod} from "../../types";
|
import {TimeParams, TimePeriod} from "../../types";
|
||||||
import {dateFromSeconds, formatDateToLocal, getDateNowUTC, getDurationFromPeriod, getTimeperiodForDuration} from "../../utils/time";
|
import {
|
||||||
|
dateFromSeconds,
|
||||||
|
formatDateToLocal,
|
||||||
|
getDateNowUTC,
|
||||||
|
getDurationFromPeriod,
|
||||||
|
getTimeperiodForDuration,
|
||||||
|
getDurationFromMilliseconds
|
||||||
|
} from "../../utils/time";
|
||||||
import {getFromStorage} from "../../utils/storage";
|
import {getFromStorage} from "../../utils/storage";
|
||||||
import {getDefaultServer} from "../../utils/default-server-url";
|
import {getDefaultServer} from "../../utils/default-server-url";
|
||||||
import {getQueryArray, getQueryStringValue} from "../../utils/query-string";
|
import {getQueryArray, getQueryStringValue} from "../../utils/query-string";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export interface TimeState {
|
export interface TimeState {
|
||||||
duration: string;
|
duration: string;
|
||||||
@ -37,6 +45,7 @@ export type Action =
|
|||||||
| { type: "SET_QUERY_HISTORY", payload: QueryHistory[] }
|
| { type: "SET_QUERY_HISTORY", payload: QueryHistory[] }
|
||||||
| { type: "SET_DURATION", payload: string }
|
| { type: "SET_DURATION", payload: string }
|
||||||
| { type: "SET_UNTIL", payload: Date }
|
| { type: "SET_UNTIL", payload: Date }
|
||||||
|
| { type: "SET_FROM", payload: Date }
|
||||||
| { type: "SET_PERIOD", payload: TimePeriod }
|
| { type: "SET_PERIOD", payload: TimePeriod }
|
||||||
| { type: "RUN_QUERY"}
|
| { type: "RUN_QUERY"}
|
||||||
| { type: "RUN_QUERY_TO_NOW"}
|
| { type: "RUN_QUERY_TO_NOW"}
|
||||||
@ -109,6 +118,21 @@ export function reducer(state: AppState, action: Action): AppState {
|
|||||||
period: getTimeperiodForDuration(state.time.duration, action.payload)
|
period: getTimeperiodForDuration(state.time.duration, action.payload)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
case "SET_FROM":
|
||||||
|
// eslint-disable-next-line no-case-declarations
|
||||||
|
const durationFrom = getDurationFromMilliseconds(state.time.period.end*1000 - action.payload.valueOf());
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
queryControls: {
|
||||||
|
...state.queryControls,
|
||||||
|
autoRefresh: false // since we're considering this to action to be fired from period selection on chart
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
...state.time,
|
||||||
|
duration: durationFrom,
|
||||||
|
period: getTimeperiodForDuration(durationFrom, dayjs(state.time.period.end*1000).toDate())
|
||||||
|
}
|
||||||
|
};
|
||||||
case "SET_PERIOD":
|
case "SET_PERIOD":
|
||||||
// eslint-disable-next-line no-case-declarations
|
// eslint-disable-next-line no-case-declarations
|
||||||
const duration = getDurationFromPeriod(action.payload);
|
const duration = getDurationFromPeriod(action.payload);
|
||||||
|
@ -75,7 +75,7 @@ export const formatDateForNativeInput = (date: Date): string => dayjs(date).form
|
|||||||
|
|
||||||
export const getDateNowUTC = (): Date => new Date(dayjs().utc().format(dateIsoFormat));
|
export const getDateNowUTC = (): Date => new Date(dayjs().utc().format(dateIsoFormat));
|
||||||
|
|
||||||
const getDurationFromMilliseconds = (ms: number): string => {
|
export const getDurationFromMilliseconds = (ms: number): string => {
|
||||||
const milliseconds = Math.floor(ms % 1000);
|
const milliseconds = Math.floor(ms % 1000);
|
||||||
const seconds = Math.floor((ms / 1000) % 60);
|
const seconds = Math.floor((ms / 1000) % 60);
|
||||||
const minutes = Math.floor((ms / 1000 / 60) % 60);
|
const minutes = Math.floor((ms / 1000 / 60) % 60);
|
||||||
|
Loading…
Reference in New Issue
Block a user