mirror of
https://github.com/hibiken/asynqmon.git
synced 2025-08-23 22:28:43 +08:00
Add support for Prometheus integration
This commit is contained in:
3
ui/build/static/js/2.8854b145.chunk.js
Normal file
3
ui/build/static/js/2.8854b145.chunk.js
Normal file
File diff suppressed because one or more lines are too long
253
ui/build/static/js/2.8854b145.chunk.js.LICENSE.txt
Normal file
253
ui/build/static/js/2.8854b145.chunk.js.LICENSE.txt
Normal file
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
object-assign
|
||||
(c) Sindre Sorhus
|
||||
@license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
Copyright (c) 2017 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*/
|
||||
|
||||
/*! Conditions:: INITIAL */
|
||||
|
||||
/*! Production:: $accept : expression $end */
|
||||
|
||||
/*! Production:: css_value : ANGLE */
|
||||
|
||||
/*! Production:: css_value : CHS */
|
||||
|
||||
/*! Production:: css_value : EMS */
|
||||
|
||||
/*! Production:: css_value : EXS */
|
||||
|
||||
/*! Production:: css_value : FREQ */
|
||||
|
||||
/*! Production:: css_value : LENGTH */
|
||||
|
||||
/*! Production:: css_value : PERCENTAGE */
|
||||
|
||||
/*! Production:: css_value : REMS */
|
||||
|
||||
/*! Production:: css_value : RES */
|
||||
|
||||
/*! Production:: css_value : SUB css_value */
|
||||
|
||||
/*! Production:: css_value : TIME */
|
||||
|
||||
/*! Production:: css_value : VHS */
|
||||
|
||||
/*! Production:: css_value : VMAXS */
|
||||
|
||||
/*! Production:: css_value : VMINS */
|
||||
|
||||
/*! Production:: css_value : VWS */
|
||||
|
||||
/*! Production:: css_variable : CSS_VAR LPAREN CSS_CPROP COMMA math_expression RPAREN */
|
||||
|
||||
/*! Production:: css_variable : CSS_VAR LPAREN CSS_CPROP RPAREN */
|
||||
|
||||
/*! Production:: expression : math_expression EOF */
|
||||
|
||||
/*! Production:: math_expression : LPAREN math_expression RPAREN */
|
||||
|
||||
/*! Production:: math_expression : NESTED_CALC LPAREN math_expression RPAREN */
|
||||
|
||||
/*! Production:: math_expression : SUB PREFIX SUB NESTED_CALC LPAREN math_expression RPAREN */
|
||||
|
||||
/*! Production:: math_expression : css_value */
|
||||
|
||||
/*! Production:: math_expression : css_variable */
|
||||
|
||||
/*! Production:: math_expression : math_expression ADD math_expression */
|
||||
|
||||
/*! Production:: math_expression : math_expression DIV math_expression */
|
||||
|
||||
/*! Production:: math_expression : math_expression MUL math_expression */
|
||||
|
||||
/*! Production:: math_expression : math_expression SUB math_expression */
|
||||
|
||||
/*! Production:: math_expression : value */
|
||||
|
||||
/*! Production:: value : NUMBER */
|
||||
|
||||
/*! Production:: value : SUB NUMBER */
|
||||
|
||||
/*! Rule:: $ */
|
||||
|
||||
/*! Rule:: (--[0-9a-z-A-Z-]*) */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)% */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)Hz\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ch\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)cm\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)deg\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dpcm\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dpi\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)dppx\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)em\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ex\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)grad\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)in\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)kHz\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)mm\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)ms\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)pc\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)pt\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)px\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)rad\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)rem\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)s\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)turn\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vh\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vmax\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vmin\b */
|
||||
|
||||
/*! Rule:: ([0-9]+(\.[0-9]*)?|\.[0-9]+)vw\b */
|
||||
|
||||
/*! Rule:: ([a-z]+) */
|
||||
|
||||
/*! Rule:: (calc) */
|
||||
|
||||
/*! Rule:: (var) */
|
||||
|
||||
/*! Rule:: , */
|
||||
|
||||
/*! Rule:: - */
|
||||
|
||||
/*! Rule:: \( */
|
||||
|
||||
/*! Rule:: \) */
|
||||
|
||||
/*! Rule:: \* */
|
||||
|
||||
/*! Rule:: \+ */
|
||||
|
||||
/*! Rule:: \/ */
|
||||
|
||||
/*! Rule:: \s+ */
|
||||
|
||||
/*! decimal.js-light v2.5.1 https://github.com/MikeMcl/decimal.js-light/LICENCE */
|
||||
|
||||
/**
|
||||
* A better abstraction over CSS.
|
||||
*
|
||||
* @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
|
||||
* @website https://github.com/cssinjs/jss
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v0.19.1
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.10.2
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.14.0
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.14.0
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.14.0
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**!
|
||||
* @fileOverview Kickass library to create and place poppers near their reference elements.
|
||||
* @version 1.16.1-lts
|
||||
* @license
|
||||
* Copyright (c) 2016 Federico Zivolo and contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
1
ui/build/static/js/2.8854b145.chunk.js.map
Normal file
1
ui/build/static/js/2.8854b145.chunk.js.map
Normal file
File diff suppressed because one or more lines are too long
2
ui/build/static/js/main.aac2a828.chunk.js
Normal file
2
ui/build/static/js/main.aac2a828.chunk.js
Normal file
File diff suppressed because one or more lines are too long
1
ui/build/static/js/main.aac2a828.chunk.js.map
Normal file
1
ui/build/static/js/main.aac2a828.chunk.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -51,6 +51,7 @@
|
||||
/>
|
||||
<script>
|
||||
window.ROOT_PATH = "%PUBLIC_URL%";
|
||||
window.PROMETHEUS_SERVER_ADDRESS = "/[[.PrometheusAddr]]";
|
||||
</script>
|
||||
<title>Asynq - Monitoring</title>
|
||||
</head>
|
||||
|
@@ -22,6 +22,7 @@ import LayersIcon from "@material-ui/icons/Layers";
|
||||
import SettingsIcon from "@material-ui/icons/Settings";
|
||||
import ScheduleIcon from "@material-ui/icons/Schedule";
|
||||
import FeedbackIcon from "@material-ui/icons/Feedback";
|
||||
import TimelineIcon from "@material-ui/icons/Timeline";
|
||||
import DoubleArrowIcon from "@material-ui/icons/DoubleArrow";
|
||||
import CloseIcon from "@material-ui/icons/Close";
|
||||
import { AppState } from "./store";
|
||||
@@ -37,6 +38,7 @@ import TaskDetailsView from "./views/TaskDetailsView";
|
||||
import SettingsView from "./views/SettingsView";
|
||||
import ServersView from "./views/ServersView";
|
||||
import RedisInfoView from "./views/RedisInfoView";
|
||||
import MetricsView from "./views/MetricsView";
|
||||
import PageNotFoundView from "./views/PageNotFoundView";
|
||||
|
||||
const drawerWidth = 220;
|
||||
@@ -244,6 +246,13 @@ function App(props: ConnectedProps<typeof connector>) {
|
||||
primary="Redis"
|
||||
icon={<LayersIcon />}
|
||||
/>
|
||||
{window.PROMETHEUS_SERVER_ADDRESS && (
|
||||
<ListItemLink
|
||||
to={paths.METRICS}
|
||||
primary="Metrics"
|
||||
icon={<TimelineIcon />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</List>
|
||||
<List>
|
||||
@@ -291,6 +300,9 @@ function App(props: ConnectedProps<typeof connector>) {
|
||||
<Route exact path={paths.HOME}>
|
||||
<DashboardView />
|
||||
</Route>
|
||||
<Route exact path={paths.METRICS}>
|
||||
<MetricsView />
|
||||
</Route>
|
||||
<Route path="*">
|
||||
<PageNotFoundView />
|
||||
</Route>
|
||||
|
48
ui/src/actions/metricsActions.ts
Normal file
48
ui/src/actions/metricsActions.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Dispatch } from "redux";
|
||||
import { getMetrics, MetricsResponse } from "../api";
|
||||
import { toErrorString, toErrorStringWithHttpStatus } from "../utils";
|
||||
|
||||
// List of metrics related action types.
|
||||
export const GET_METRICS_BEGIN = "GET_METRICS_BEGIN";
|
||||
export const GET_METRICS_SUCCESS = "GET_METRICS_SUCCESS";
|
||||
export const GET_METRICS_ERROR = "GET_METRICS_ERROR";
|
||||
|
||||
interface GetMetricsBeginAction {
|
||||
type: typeof GET_METRICS_BEGIN;
|
||||
}
|
||||
|
||||
interface GetMetricsSuccessAction {
|
||||
type: typeof GET_METRICS_SUCCESS;
|
||||
payload: MetricsResponse;
|
||||
}
|
||||
|
||||
interface GetMetricsErrorAction {
|
||||
type: typeof GET_METRICS_ERROR;
|
||||
error: string;
|
||||
}
|
||||
|
||||
// Union of all metrics related actions.
|
||||
export type MetricsActionTypes =
|
||||
| GetMetricsBeginAction
|
||||
| GetMetricsSuccessAction
|
||||
| GetMetricsErrorAction;
|
||||
|
||||
export function getMetricsAsync(
|
||||
endTime: number,
|
||||
duration: number,
|
||||
queues: string[]
|
||||
) {
|
||||
return async (dispatch: Dispatch<MetricsActionTypes>) => {
|
||||
dispatch({ type: GET_METRICS_BEGIN });
|
||||
try {
|
||||
const response = await getMetrics(endTime, duration, queues);
|
||||
dispatch({ type: GET_METRICS_SUCCESS, payload: response });
|
||||
} catch (error) {
|
||||
console.error(`getMetricsAsync: ${toErrorStringWithHttpStatus(error)}`);
|
||||
dispatch({
|
||||
type: GET_METRICS_ERROR,
|
||||
error: toErrorString(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
@@ -76,6 +76,45 @@ export interface QueueLocation {
|
||||
nodes: string[]; // node addresses
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
queue_size: PrometheusMetricsResponse;
|
||||
queue_latency_seconds: PrometheusMetricsResponse;
|
||||
queue_memory_usage_approx_bytes: PrometheusMetricsResponse;
|
||||
tasks_processed_per_second: PrometheusMetricsResponse;
|
||||
tasks_failed_per_second: PrometheusMetricsResponse;
|
||||
error_rate: PrometheusMetricsResponse;
|
||||
pending_tasks_by_queue: PrometheusMetricsResponse;
|
||||
retry_tasks_by_queue: PrometheusMetricsResponse;
|
||||
archived_tasks_by_queue: PrometheusMetricsResponse;
|
||||
}
|
||||
|
||||
export interface PrometheusMetricsResponse {
|
||||
status: "success" | "error";
|
||||
data?: MetricsResult; // present if status === "success"
|
||||
error?: string; // present if status === "error"
|
||||
errorType?: string; // present if status === "error"
|
||||
}
|
||||
|
||||
export interface MetricsResult {
|
||||
resultType: string;
|
||||
result: Metrics[];
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
metric: MetricsInfo;
|
||||
values: [number, string][]; // [unixtime, value]
|
||||
}
|
||||
|
||||
export interface MetricsInfo {
|
||||
__name__: string;
|
||||
instance: string;
|
||||
job: string;
|
||||
|
||||
// labels (may or may not be present depending on metrics)
|
||||
queue?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
// Return value from redis INFO command.
|
||||
// See https://redis.io/commands/info#return-value.
|
||||
export interface RedisInfo {
|
||||
@@ -854,3 +893,28 @@ export async function getRedisInfo(): Promise<RedisInfoResponse> {
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
interface MetricsEndpointParams {
|
||||
endtime: number;
|
||||
duration: number;
|
||||
queues?: string; // comma-separated list of queues
|
||||
}
|
||||
|
||||
export async function getMetrics(
|
||||
endTime: number,
|
||||
duration: number,
|
||||
queues: string[]
|
||||
): Promise<MetricsResponse> {
|
||||
let params: MetricsEndpointParams = {
|
||||
endtime: endTime,
|
||||
duration: duration,
|
||||
};
|
||||
if (queues && queues.length > 0) {
|
||||
params.queues = queues.join(",");
|
||||
}
|
||||
const resp = await axios({
|
||||
method: "get",
|
||||
url: `${BASE_URL}/metrics?${queryString.stringify(params)}`,
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
@@ -30,8 +30,12 @@ export default function DailyStatsChart(props: Props) {
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" minTickGap={10} />
|
||||
<YAxis />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
minTickGap={10}
|
||||
stroke={theme.palette.text.secondary}
|
||||
/>
|
||||
<YAxis stroke={theme.palette.text.secondary} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
|
736
ui/src/components/MetricsFetchControls.tsx
Normal file
736
ui/src/components/MetricsFetchControls.tsx
Normal file
@@ -0,0 +1,736 @@
|
||||
import React from "react";
|
||||
import { connect, ConnectedProps } from "react-redux";
|
||||
import { makeStyles, Theme } from "@material-ui/core/styles";
|
||||
import Button, { ButtonProps } from "@material-ui/core/Button";
|
||||
import ButtonGroup from "@material-ui/core/ButtonGroup";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import Popover from "@material-ui/core/Popover";
|
||||
import Radio from "@material-ui/core/Radio";
|
||||
import RadioGroup from "@material-ui/core/RadioGroup";
|
||||
import Checkbox from "@material-ui/core/Checkbox";
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import FormGroup from "@material-ui/core/FormGroup";
|
||||
import FormLabel from "@material-ui/core/FormLabel";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import ArrowLeftIcon from "@material-ui/icons/ArrowLeft";
|
||||
import ArrowRightIcon from "@material-ui/icons/ArrowRight";
|
||||
import FilterListIcon from "@material-ui/icons/FilterList";
|
||||
import dayjs from "dayjs";
|
||||
import { currentUnixtime, parseDuration } from "../utils";
|
||||
import { AppState } from "../store";
|
||||
import { isDarkTheme } from "../theme";
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return { pollInterval: state.settings.pollInterval };
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps);
|
||||
type ReduxProps = ConnectedProps<typeof connector>;
|
||||
|
||||
interface Props extends ReduxProps {
|
||||
// Specifies the endtime in Unix time seconds.
|
||||
endTimeSec: number;
|
||||
onEndTimeChange: (t: number, isEndTimeFixed: boolean) => void;
|
||||
|
||||
// Specifies the duration in seconds.
|
||||
durationSec: number;
|
||||
onDurationChange: (d: number, isEndTimeFixed: boolean) => void;
|
||||
|
||||
// All available queues.
|
||||
queues: string[];
|
||||
// Selected queues.
|
||||
selectedQueues: string[];
|
||||
addQueue: (qname: string) => void;
|
||||
removeQueue: (qname: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
endTimeOption: EndTimeOption;
|
||||
durationOption: DurationOption;
|
||||
customEndTime: string; // text shown in input field
|
||||
customDuration: string; // text shown in input field
|
||||
customEndTimeError: string;
|
||||
customDurationError: string;
|
||||
}
|
||||
|
||||
type EndTimeOption = "real_time" | "freeze_at_now" | "custom";
|
||||
type DurationOption = "1h" | "6h" | "1d" | "8d" | "30d" | "custom";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
endTimeCaption: {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
shiftButtons: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
buttonGroupRoot: {
|
||||
height: 29,
|
||||
position: "relative",
|
||||
top: 1,
|
||||
},
|
||||
endTimeShiftControls: {
|
||||
padding: theme.spacing(1),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderBottomColor: theme.palette.divider,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomStyle: "solid",
|
||||
},
|
||||
leftShiftButtons: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
rightShiftButtons: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
controlsContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
controlSelectorBox: {
|
||||
display: "flex",
|
||||
minWidth: 490,
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
controlEndTimeSelector: {
|
||||
width: "50%",
|
||||
},
|
||||
controlDurationSelector: {
|
||||
width: "50%",
|
||||
},
|
||||
radioButtonRoot: {
|
||||
paddingTop: theme.spacing(0.5),
|
||||
paddingBottom: theme.spacing(0.5),
|
||||
paddingLeft: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
formControlLabel: {
|
||||
fontSize: 14,
|
||||
},
|
||||
buttonLabel: {
|
||||
textTransform: "none",
|
||||
fontSize: 12,
|
||||
},
|
||||
formControlRoot: {
|
||||
width: "100%",
|
||||
margin: 0,
|
||||
},
|
||||
formLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
customInputField: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
filterButton: {
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
queueFilters: {
|
||||
padding: theme.spacing(2),
|
||||
maxHeight: 400,
|
||||
},
|
||||
checkbox: {
|
||||
padding: 6,
|
||||
},
|
||||
}));
|
||||
|
||||
// minute, hour, day in seconds
|
||||
const minute = 60;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
|
||||
function getInitialState(endTimeSec: number, durationSec: number): State {
|
||||
let endTimeOption: EndTimeOption = "real_time";
|
||||
let customEndTime = "";
|
||||
let durationOption: DurationOption = "1h";
|
||||
let customDuration = "";
|
||||
|
||||
const now = currentUnixtime();
|
||||
// Account for 1s difference, may just happen to elapse 1s
|
||||
// between the parent component's render and this component's render.
|
||||
if (now <= endTimeSec && endTimeSec <= now + 1) {
|
||||
endTimeOption = "real_time";
|
||||
} else {
|
||||
endTimeOption = "custom";
|
||||
customEndTime = new Date(endTimeSec * 1000).toISOString();
|
||||
}
|
||||
|
||||
switch (durationSec) {
|
||||
case 1 * hour:
|
||||
durationOption = "1h";
|
||||
break;
|
||||
case 6 * hour:
|
||||
durationOption = "6h";
|
||||
break;
|
||||
case 1 * day:
|
||||
durationOption = "1d";
|
||||
break;
|
||||
case 8 * day:
|
||||
durationOption = "8d";
|
||||
break;
|
||||
case 30 * day:
|
||||
durationOption = "30d";
|
||||
break;
|
||||
default:
|
||||
durationOption = "custom";
|
||||
customDuration = durationSec + "s";
|
||||
}
|
||||
|
||||
return {
|
||||
endTimeOption,
|
||||
customEndTime,
|
||||
customEndTimeError: "",
|
||||
durationOption,
|
||||
customDuration,
|
||||
customDurationError: "",
|
||||
};
|
||||
}
|
||||
|
||||
function MetricsFetchControls(props: Props) {
|
||||
const classes = useStyles();
|
||||
|
||||
const [state, setState] = React.useState<State>(
|
||||
getInitialState(props.endTimeSec, props.durationSec)
|
||||
);
|
||||
const [timePopoverAnchorElem, setTimePopoverAnchorElem] =
|
||||
React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const [queuePopoverAnchorElem, setQueuePopoverAnchorElem] =
|
||||
React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleEndTimeOptionChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const selectedOpt = (event.target as HTMLInputElement)
|
||||
.value as EndTimeOption;
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
endTimeOption: selectedOpt,
|
||||
customEndTime: "",
|
||||
customEndTimeError: "",
|
||||
}));
|
||||
switch (selectedOpt) {
|
||||
case "real_time":
|
||||
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
|
||||
break;
|
||||
case "freeze_at_now":
|
||||
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ true);
|
||||
break;
|
||||
case "custom":
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
|
||||
const handleDurationOptionChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const selectedOpt = (event.target as HTMLInputElement)
|
||||
.value as DurationOption;
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
durationOption: selectedOpt,
|
||||
customDuration: "",
|
||||
customDurationError: "",
|
||||
}));
|
||||
const isEndTimeFixed = state.endTimeOption !== "real_time";
|
||||
switch (selectedOpt) {
|
||||
case "1h":
|
||||
props.onDurationChange(1 * hour, isEndTimeFixed);
|
||||
break;
|
||||
case "6h":
|
||||
props.onDurationChange(6 * hour, isEndTimeFixed);
|
||||
break;
|
||||
case "1d":
|
||||
props.onDurationChange(1 * day, isEndTimeFixed);
|
||||
break;
|
||||
case "8d":
|
||||
props.onDurationChange(8 * day, isEndTimeFixed);
|
||||
break;
|
||||
case "30d":
|
||||
props.onDurationChange(30 * day, isEndTimeFixed);
|
||||
break;
|
||||
case "custom":
|
||||
// No-op
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomDurationChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
customDuration: event.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCustomEndTimeChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
customEndTime: event.target.value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCustomDurationKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (event.key === "Enter") {
|
||||
try {
|
||||
const d = parseDuration(state.customDuration);
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
durationOption: "custom",
|
||||
customDurationError: "",
|
||||
}));
|
||||
props.onDurationChange(d, state.endTimeOption !== "real_time");
|
||||
} catch (error) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
customDurationError: "Duration invalid",
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomEndTimeKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (event.key === "Enter") {
|
||||
const timeUsecOrNaN = Date.parse(state.customEndTime);
|
||||
if (isNaN(timeUsecOrNaN)) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
customEndTimeError: "End time invalid",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
endTimeOption: "custom",
|
||||
customEndTimeError: "",
|
||||
}));
|
||||
props.onEndTimeChange(
|
||||
Math.floor(timeUsecOrNaN / 1000),
|
||||
/* isEndTimeFixed= */ true
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTimePopover = (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
setTimePopoverAnchorElem(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseTimePopover = () => {
|
||||
setTimePopoverAnchorElem(null);
|
||||
};
|
||||
|
||||
const handleOpenQueuePopover = (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
setQueuePopoverAnchorElem(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleCloseQueuePopover = () => {
|
||||
setQueuePopoverAnchorElem(null);
|
||||
};
|
||||
|
||||
const isTimePopoverOpen = Boolean(timePopoverAnchorElem);
|
||||
const isQueuePopoverOpen = Boolean(queuePopoverAnchorElem);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.endTimeOption === "real_time") {
|
||||
const id = setInterval(() => {
|
||||
props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
|
||||
}, props.pollInterval * 1000);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
});
|
||||
|
||||
const shiftBy = (deltaSec: number) => {
|
||||
return () => {
|
||||
const now = currentUnixtime();
|
||||
const endTime = props.endTimeSec + deltaSec;
|
||||
if (now <= endTime) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
customEndTime: "",
|
||||
endTimeOption: "real_time",
|
||||
}));
|
||||
props.onEndTimeChange(now, /*isEndTimeFixed=*/ false);
|
||||
return;
|
||||
}
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
endTimeOption: "custom",
|
||||
customEndTime: new Date(endTime * 1000).toISOString(),
|
||||
}));
|
||||
props.onEndTimeChange(endTime, /*isEndTimeFixed=*/ true);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="textPrimary"
|
||||
className={classes.endTimeCaption}
|
||||
>
|
||||
{formatTime(props.endTimeSec)}
|
||||
</Typography>
|
||||
<div>
|
||||
<Button
|
||||
aria-describedby={isTimePopoverOpen ? "time-popover" : undefined}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={handleOpenTimePopover}
|
||||
size="small"
|
||||
classes={{
|
||||
label: classes.buttonLabel,
|
||||
}}
|
||||
>
|
||||
{state.endTimeOption === "real_time" ? "Realtime" : "Historical"}:{" "}
|
||||
{state.durationOption === "custom"
|
||||
? state.customDuration
|
||||
: state.durationOption}
|
||||
</Button>
|
||||
<Popover
|
||||
id={isTimePopoverOpen ? "time-popover" : undefined}
|
||||
open={isTimePopoverOpen}
|
||||
anchorEl={timePopoverAnchorElem}
|
||||
onClose={handleCloseTimePopover}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<div className={classes.endTimeShiftControls}>
|
||||
<div className={classes.leftShiftButtons}>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text="2h"
|
||||
onClick={shiftBy(-2 * hour)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text="1h"
|
||||
onClick={shiftBy(-1 * hour)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text="30m"
|
||||
onClick={shiftBy(-30 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text="15m"
|
||||
onClick={shiftBy(-15 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text="5m"
|
||||
onClick={shiftBy(-5 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.rightShiftButtons}>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text="5m"
|
||||
onClick={shiftBy(5 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text="15m"
|
||||
onClick={shiftBy(15 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text="30m"
|
||||
onClick={shiftBy(30 * minute)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text="1h"
|
||||
onClick={shiftBy(1 * hour)}
|
||||
dense={true}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text="2h"
|
||||
onClick={shiftBy(2 * hour)}
|
||||
dense={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.controlSelectorBox}>
|
||||
<div className={classes.controlEndTimeSelector}>
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
margin="dense"
|
||||
classes={{ root: classes.formControlRoot }}
|
||||
>
|
||||
<FormLabel className={classes.formLabel} component="legend">
|
||||
End Time
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
aria-label="end_time"
|
||||
name="end_time"
|
||||
value={state.endTimeOption}
|
||||
onChange={handleEndTimeOptionChange}
|
||||
>
|
||||
<RadioInput value="real_time" label="Real Time" />
|
||||
<RadioInput value="freeze_at_now" label="Freeze at now" />
|
||||
<RadioInput value="custom" label="Custom End Time" />
|
||||
</RadioGroup>
|
||||
<div className={classes.customInputField}>
|
||||
<TextField
|
||||
id="custom-endtime"
|
||||
label="yyyy-mm-dd hh:mm:ssz"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onChange={handleCustomEndTimeChange}
|
||||
value={state.customEndTime}
|
||||
onKeyDown={handleCustomEndTimeKeyDown}
|
||||
error={state.customEndTimeError !== ""}
|
||||
helperText={state.customEndTimeError}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={classes.controlDurationSelector}>
|
||||
<FormControl
|
||||
component="fieldset"
|
||||
margin="dense"
|
||||
classes={{ root: classes.formControlRoot }}
|
||||
>
|
||||
<FormLabel className={classes.formLabel} component="legend">
|
||||
Duration
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
aria-label="duration"
|
||||
name="duration"
|
||||
value={state.durationOption}
|
||||
onChange={handleDurationOptionChange}
|
||||
>
|
||||
<RadioInput value="1h" label="1h" />
|
||||
<RadioInput value="6h" label="6h" />
|
||||
<RadioInput value="1d" label="1 day" />
|
||||
<RadioInput value="8d" label="8 days" />
|
||||
<RadioInput value="30d" label="30 days" />
|
||||
<RadioInput value="custom" label="Custom Duration" />
|
||||
</RadioGroup>
|
||||
<div className={classes.customInputField}>
|
||||
<TextField
|
||||
id="custom-duration"
|
||||
label="duration"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onChange={handleCustomDurationChange}
|
||||
value={state.customDuration}
|
||||
onKeyDown={handleCustomDurationKeyDown}
|
||||
error={state.customDurationError !== ""}
|
||||
helperText={state.customDurationError}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className={classes.shiftButtons}>
|
||||
<ButtonGroup
|
||||
classes={{ root: classes.buttonGroupRoot }}
|
||||
size="small"
|
||||
color="primary"
|
||||
aria-label="shift buttons"
|
||||
>
|
||||
<ShiftButton
|
||||
direction="left"
|
||||
text={
|
||||
state.durationOption === "custom" ? "1h" : state.durationOption
|
||||
}
|
||||
color="primary"
|
||||
onClick={
|
||||
state.durationOption === "custom"
|
||||
? shiftBy(-1 * hour)
|
||||
: shiftBy(-props.durationSec)
|
||||
}
|
||||
/>
|
||||
<ShiftButton
|
||||
direction="right"
|
||||
text={
|
||||
state.durationOption === "custom" ? "1h" : state.durationOption
|
||||
}
|
||||
color="primary"
|
||||
onClick={
|
||||
state.durationOption === "custom"
|
||||
? shiftBy(1 * hour)
|
||||
: shiftBy(props.durationSec)
|
||||
}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className={classes.filterButton}>
|
||||
<IconButton
|
||||
aria-label="filter"
|
||||
size="small"
|
||||
onClick={handleOpenQueuePopover}
|
||||
>
|
||||
<FilterListIcon />
|
||||
</IconButton>
|
||||
<Popover
|
||||
id={isQueuePopoverOpen ? "queue-popover" : undefined}
|
||||
open={isQueuePopoverOpen}
|
||||
anchorEl={queuePopoverAnchorElem}
|
||||
onClose={handleCloseQueuePopover}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<FormControl className={classes.queueFilters}>
|
||||
<FormLabel className={classes.formLabel} component="legend">
|
||||
Queues
|
||||
</FormLabel>
|
||||
<FormGroup>
|
||||
{props.queues.map((qname) => (
|
||||
<FormControlLabel
|
||||
key={qname}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={props.selectedQueues.includes(qname)}
|
||||
onChange={() => {
|
||||
if (props.selectedQueues.includes(qname)) {
|
||||
props.removeQueue(qname);
|
||||
} else {
|
||||
props.addQueue(qname);
|
||||
}
|
||||
}}
|
||||
name={qname}
|
||||
className={classes.checkbox}
|
||||
/>
|
||||
}
|
||||
label={qname}
|
||||
classes={{ label: classes.formControlLabel }}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/****************** Helper functions/components *******************/
|
||||
|
||||
function formatTime(unixtime: number): string {
|
||||
const tz = new Date(unixtime * 1000)
|
||||
.toLocaleTimeString("en-us", { timeZoneName: "short" })
|
||||
.split(" ")[2];
|
||||
return dayjs.unix(unixtime).format("ddd, DD MMM YYYY HH:mm:ss ") + tz;
|
||||
}
|
||||
|
||||
interface RadioInputProps {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function RadioInput(props: RadioInputProps) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<FormControlLabel
|
||||
classes={{ label: classes.formControlLabel }}
|
||||
value={props.value}
|
||||
control={
|
||||
<Radio size="small" classes={{ root: classes.radioButtonRoot }} />
|
||||
}
|
||||
label={props.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShiftButtonProps extends ButtonProps {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
direction: "left" | "right";
|
||||
dense?: boolean;
|
||||
}
|
||||
|
||||
const useShiftButtonStyles = makeStyles((theme: Theme) => ({
|
||||
root: {
|
||||
minWidth: 40,
|
||||
fontWeight: (props: ShiftButtonProps) => (props.dense ? 400 : 500),
|
||||
},
|
||||
label: { fontSize: 12, textTransform: "none" },
|
||||
iconRoot: {
|
||||
marginRight: (props: ShiftButtonProps) =>
|
||||
props.direction === "left" ? (props.dense ? -8 : -4) : 0,
|
||||
marginLeft: (props: ShiftButtonProps) =>
|
||||
props.direction === "right" ? (props.dense ? -8 : -4) : 0,
|
||||
color: (props: ShiftButtonProps) =>
|
||||
props.color
|
||||
? props.color
|
||||
: theme.palette.grey[isDarkTheme(theme) ? 200 : 700],
|
||||
},
|
||||
}));
|
||||
|
||||
function ShiftButton(props: ShiftButtonProps) {
|
||||
const classes = useShiftButtonStyles(props);
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
classes={{
|
||||
root: classes.root,
|
||||
label: classes.label,
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{props.direction === "left" && (
|
||||
<ArrowLeftIcon classes={{ root: classes.iconRoot }} />
|
||||
)}
|
||||
{props.text}
|
||||
{props.direction === "right" && (
|
||||
<ArrowRightIcon classes={{ root: classes.iconRoot }} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
ShiftButton.defaultProps = {
|
||||
dense: false,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(MetricsFetchControls);
|
@@ -27,8 +27,8 @@ function ProcessedTasksChart(props: Props) {
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={props.data} maxBarSize={120}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="queue" />
|
||||
<YAxis />
|
||||
<XAxis dataKey="queue" stroke={theme.palette.text.secondary} />
|
||||
<YAxis stroke={theme.palette.text.secondary} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
|
108
ui/src/components/QueueMetricsChart.tsx
Normal file
108
ui/src/components/QueueMetricsChart.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useTheme } from "@material-ui/core/styles";
|
||||
import React from "react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Metrics } from "../api";
|
||||
|
||||
interface Props {
|
||||
data: Metrics[];
|
||||
|
||||
// both startTime and endTime are in unix time (seconds)
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
|
||||
// (optional): Tick formatter function for YAxis
|
||||
yAxisTickFormatter?: (val: number) => string;
|
||||
}
|
||||
|
||||
// interface that rechart understands.
|
||||
interface ChartData {
|
||||
timestamp: number;
|
||||
[qname: string]: number;
|
||||
}
|
||||
|
||||
function toChartData(metrics: Metrics[]): ChartData[] {
|
||||
if (metrics.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let byTimestamp: { [key: number]: ChartData } = {};
|
||||
for (let x of metrics) {
|
||||
for (let [ts, val] of x.values) {
|
||||
if (!byTimestamp[ts]) {
|
||||
byTimestamp[ts] = { timestamp: ts };
|
||||
}
|
||||
const qname = x.metric.queue;
|
||||
if (qname) {
|
||||
byTimestamp[ts][qname] = parseFloat(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.values(byTimestamp);
|
||||
}
|
||||
|
||||
const lineColors = [
|
||||
"#2085ec",
|
||||
"#72b4eb",
|
||||
"#0a417a",
|
||||
"#8464a0",
|
||||
"#cea9bc",
|
||||
"#323232",
|
||||
];
|
||||
|
||||
function QueueMetricsChart(props: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
const data = toChartData(props.data);
|
||||
const keys = props.data.map((x) => x.metric.queue);
|
||||
return (
|
||||
<ResponsiveContainer height={260}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
minTickGap={10}
|
||||
dataKey="timestamp"
|
||||
domain={[props.startTime, props.endTime]}
|
||||
tickFormatter={(timestamp: number) =>
|
||||
new Date(timestamp * 1000).toLocaleTimeString()
|
||||
}
|
||||
type="number"
|
||||
scale="time"
|
||||
stroke={theme.palette.text.secondary}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={props.yAxisTickFormatter}
|
||||
stroke={theme.palette.text.secondary}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleTimeString();
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
{keys.map((key, idx) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={lineColors[idx % lineColors.length]}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
QueueMetricsChart.defaultProps = {
|
||||
yAxisTickFormatter: (val: number) => val.toString(),
|
||||
};
|
||||
|
||||
export default QueueMetricsChart;
|
@@ -10,6 +10,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useTheme } from "@material-ui/core/styles";
|
||||
import { queueDetailsPath } from "../paths";
|
||||
|
||||
interface Props {
|
||||
@@ -27,6 +28,7 @@ interface TaskBreakdown {
|
||||
}
|
||||
|
||||
function QueueSizeChart(props: Props) {
|
||||
const theme = useTheme();
|
||||
const handleClick = (params: { activeLabel?: string } | null) => {
|
||||
const allQueues = props.data.map((b) => b.queue);
|
||||
if (
|
||||
@@ -47,8 +49,8 @@ function QueueSizeChart(props: Props) {
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="queue" />
|
||||
<YAxis />
|
||||
<XAxis dataKey="queue" stroke={theme.palette.text.secondary} />
|
||||
<YAxis stroke={theme.palette.text.secondary} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="active" stackId="a" fill="#1967d2" />
|
||||
|
6
ui/src/global.d.ts
vendored
6
ui/src/global.d.ts
vendored
@@ -2,4 +2,8 @@ interface Window {
|
||||
// Root URL path for asynqmon app.
|
||||
// ROOT_PATH should not have the tailing slash.
|
||||
ROOT_PATH: string;
|
||||
}
|
||||
|
||||
// Prometheus server address to query time series data.
|
||||
// This field is set to empty string by default. Use this field only if it's set.
|
||||
PROMETHEUS_SERVER_ADDRESS: string;
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
// usePolling repeatedly calls doFn with a fix time delay specified
|
||||
// by interval (in millisecond).
|
||||
@@ -9,3 +10,9 @@ export function usePolling(doFn: () => void, interval: number) {
|
||||
return () => clearInterval(id);
|
||||
}, [interval, doFn]);
|
||||
}
|
||||
|
||||
// useQuery gets the URL search params from the current URL.
|
||||
export function useQuery(): URLSearchParams {
|
||||
const { search } = useLocation();
|
||||
return useMemo(() => new URLSearchParams(search), [search]);
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ export const paths = {
|
||||
QUEUE_DETAILS: `${window.ROOT_PATH}/queues/:qname`,
|
||||
REDIS: `${window.ROOT_PATH}/redis`,
|
||||
TASK_DETAILS: `${window.ROOT_PATH}/queues/:qname/tasks/:taskId`,
|
||||
METRICS: `${window.ROOT_PATH}/metrics`,
|
||||
};
|
||||
|
||||
/**************************************************************
|
||||
@@ -35,4 +36,4 @@ export interface QueueDetailsRouteParams {
|
||||
export interface TaskDetailsRouteParams {
|
||||
qname: string;
|
||||
taskId: string;
|
||||
}
|
||||
}
|
||||
|
49
ui/src/reducers/metricsReducer.ts
Normal file
49
ui/src/reducers/metricsReducer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
GET_METRICS_BEGIN,
|
||||
GET_METRICS_ERROR,
|
||||
GET_METRICS_SUCCESS,
|
||||
MetricsActionTypes,
|
||||
} from "../actions/metricsActions";
|
||||
import { MetricsResponse } from "../api";
|
||||
|
||||
interface MetricsState {
|
||||
loading: boolean;
|
||||
error: string;
|
||||
data: MetricsResponse | null;
|
||||
}
|
||||
|
||||
const initialState: MetricsState = {
|
||||
loading: false,
|
||||
error: "",
|
||||
data: null,
|
||||
};
|
||||
|
||||
export default function metricsReducer(
|
||||
state = initialState,
|
||||
action: MetricsActionTypes
|
||||
): MetricsState {
|
||||
switch (action.type) {
|
||||
case GET_METRICS_BEGIN:
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
};
|
||||
|
||||
case GET_METRICS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.error,
|
||||
};
|
||||
|
||||
case GET_METRICS_SUCCESS:
|
||||
return {
|
||||
loading: false,
|
||||
error: "",
|
||||
data: action.payload,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@@ -7,6 +7,7 @@ import schedulerEntriesReducer from "./reducers/schedulerEntriesReducer";
|
||||
import snackbarReducer from "./reducers/snackbarReducer";
|
||||
import queueStatsReducer from "./reducers/queueStatsReducer";
|
||||
import redisInfoReducer from "./reducers/redisInfoReducer";
|
||||
import metricsReducer from "./reducers/metricsReducer";
|
||||
import { loadState } from "./localStorage";
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
@@ -18,6 +19,7 @@ const rootReducer = combineReducers({
|
||||
snackbar: snackbarReducer,
|
||||
queueStats: queueStatsReducer,
|
||||
redis: redisInfoReducer,
|
||||
metrics: metricsReducer,
|
||||
});
|
||||
|
||||
const preloadedState = loadState();
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { createMuiTheme, Theme } from "@material-ui/core/styles";
|
||||
import { createTheme, Theme } from "@material-ui/core/styles";
|
||||
import { ThemePreference } from "./reducers/settingsReducer";
|
||||
import useMediaQuery from "@material-ui/core/useMediaQuery";
|
||||
|
||||
@@ -9,7 +9,7 @@ export function useTheme(themePreference: ThemePreference): Theme {
|
||||
} else if (themePreference === ThemePreference.Never) {
|
||||
prefersDarkMode = false;
|
||||
}
|
||||
return createMuiTheme({
|
||||
return createTheme({
|
||||
// Got color palette from https://htmlcolors.com/palette/31/stripe
|
||||
palette: {
|
||||
primary: {
|
||||
|
@@ -74,8 +74,8 @@ export function timeAgo(timestamp: string): string {
|
||||
}
|
||||
|
||||
export function timeAgoUnix(unixtime: number): string {
|
||||
if (unixtime === 0) {
|
||||
return ""
|
||||
if (unixtime === 0) {
|
||||
return "";
|
||||
}
|
||||
const duration = durationBetween(Date.now(), unixtime * 1000);
|
||||
return stringifyDuration(duration) + " ago";
|
||||
@@ -103,14 +103,12 @@ export function percentage(numerator: number, denominator: number): string {
|
||||
return `${perc} %`;
|
||||
}
|
||||
|
||||
|
||||
export function isJsonPayload(p: string) {
|
||||
try {
|
||||
JSON.parse(p);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,6 +116,31 @@ export function prettifyPayload(p: string) {
|
||||
if (isJsonPayload(p)) {
|
||||
return JSON.stringify(JSON.parse(p), null, 2);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
// Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC.
|
||||
export function currentUnixtime(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
const durationRegex = /([0-9]*(\.[0-9]*)?)[s|m|h]/;
|
||||
// Parses the given string and returns the number of seconds if the input is valid.
|
||||
// Otherwise, it throws an error
|
||||
// Supported time units are "s", "m", "h"
|
||||
export function parseDuration(s: string): number {
|
||||
if (!durationRegex.test(s)) {
|
||||
throw new Error("invalid duration");
|
||||
}
|
||||
const val = parseFloat(s.slice(0, -1));
|
||||
switch (s.slice(-1)) {
|
||||
case "s":
|
||||
return val;
|
||||
case "m":
|
||||
return val * 60;
|
||||
case "h":
|
||||
return val * 60 * 60;
|
||||
default:
|
||||
throw new Error("invalid duration unit");
|
||||
}
|
||||
}
|
||||
|
329
ui/src/views/MetricsView.tsx
Normal file
329
ui/src/views/MetricsView.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React from "react";
|
||||
import { connect, ConnectedProps } from "react-redux";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import queryString from "query-string";
|
||||
import { makeStyles } from "@material-ui/core/styles";
|
||||
import Container from "@material-ui/core/Container";
|
||||
import Grid from "@material-ui/core/Grid";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import WarningIcon from "@material-ui/icons/Warning";
|
||||
import InfoIcon from "@material-ui/icons/Info";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { getMetricsAsync } from "../actions/metricsActions";
|
||||
import { listQueuesAsync } from "../actions/queuesActions";
|
||||
import { AppState } from "../store";
|
||||
import QueueMetricsChart from "../components/QueueMetricsChart";
|
||||
import Tooltip from "../components/Tooltip";
|
||||
import { currentUnixtime } from "../utils";
|
||||
import MetricsFetchControls from "../components/MetricsFetchControls";
|
||||
import { useQuery } from "../hooks";
|
||||
import { PrometheusMetricsResponse } from "../api";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
container: {
|
||||
marginTop: 30,
|
||||
paddingTop: theme.spacing(4),
|
||||
paddingBottom: theme.spacing(4),
|
||||
},
|
||||
controlsContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
position: "fixed",
|
||||
background: theme.palette.background.paper,
|
||||
zIndex: theme.zIndex.appBar,
|
||||
right: 0,
|
||||
top: 64, // app-bar height
|
||||
width: "100%",
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
chartInfo: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
infoIcon: {
|
||||
marginLeft: theme.spacing(1),
|
||||
color: theme.palette.grey[500],
|
||||
cursor: "pointer",
|
||||
},
|
||||
errorMessage: {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
warningIcon: {
|
||||
color: "#ff6700",
|
||||
marginRight: 6,
|
||||
},
|
||||
}));
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
loading: state.metrics.loading,
|
||||
error: state.metrics.error,
|
||||
data: state.metrics.data,
|
||||
pollInterval: state.settings.pollInterval,
|
||||
queues: state.queues.data.map((q) => q.name),
|
||||
};
|
||||
}
|
||||
|
||||
const connector = connect(mapStateToProps, {
|
||||
getMetricsAsync,
|
||||
listQueuesAsync,
|
||||
});
|
||||
type Props = ConnectedProps<typeof connector>;
|
||||
|
||||
const ENDTIME_URL_PARAM_KEY = "end";
|
||||
const DURATION_URL_PARAM_KEY = "duration";
|
||||
|
||||
function MetricsView(props: Props) {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const query = useQuery();
|
||||
|
||||
const endTimeStr = query.get(ENDTIME_URL_PARAM_KEY);
|
||||
const endTime = endTimeStr ? parseFloat(endTimeStr) : currentUnixtime(); // default to now
|
||||
|
||||
const durationStr = query.get(DURATION_URL_PARAM_KEY);
|
||||
const duration = durationStr ? parseFloat(durationStr) : 60 * 60; // default to 1h
|
||||
|
||||
const { pollInterval, getMetricsAsync, listQueuesAsync, data } = props;
|
||||
|
||||
const [endTimeSec, setEndTimeSec] = React.useState(endTime);
|
||||
const [durationSec, setDurationSec] = React.useState(duration);
|
||||
const [selectedQueues, setSelectedQueues] = React.useState<string[]>([]);
|
||||
|
||||
const handleEndTimeChange = (endTime: number, isEndTimeFixed: boolean) => {
|
||||
const urlQuery = isEndTimeFixed
|
||||
? {
|
||||
[ENDTIME_URL_PARAM_KEY]: endTime,
|
||||
[DURATION_URL_PARAM_KEY]: durationSec,
|
||||
}
|
||||
: {
|
||||
[DURATION_URL_PARAM_KEY]: durationSec,
|
||||
};
|
||||
history.push({
|
||||
...history.location,
|
||||
search: queryString.stringify(urlQuery),
|
||||
});
|
||||
setEndTimeSec(endTime);
|
||||
};
|
||||
|
||||
const handleDurationChange = (duration: number, isEndTimeFixed: boolean) => {
|
||||
const urlQuery = isEndTimeFixed
|
||||
? {
|
||||
[ENDTIME_URL_PARAM_KEY]: endTimeSec,
|
||||
[DURATION_URL_PARAM_KEY]: duration,
|
||||
}
|
||||
: {
|
||||
[DURATION_URL_PARAM_KEY]: duration,
|
||||
};
|
||||
history.push({
|
||||
...history.location,
|
||||
search: queryString.stringify(urlQuery),
|
||||
});
|
||||
setDurationSec(duration);
|
||||
};
|
||||
|
||||
const handleAddQueue = (qname: string) => {
|
||||
if (selectedQueues.includes(qname)) {
|
||||
return;
|
||||
}
|
||||
setSelectedQueues(selectedQueues.concat(qname));
|
||||
};
|
||||
|
||||
const handleRemoveQueue = (qname: string) => {
|
||||
if (selectedQueues.length === 1) {
|
||||
return; // ensure that selected queues doesn't go down to zero once user selected
|
||||
}
|
||||
if (selectedQueues.length === 0) {
|
||||
// when user first select filter (remove once of the queues),
|
||||
// we need to lazily initialize the selectedQueues with the rest (all queues but the selected one).
|
||||
setSelectedQueues(props.queues.filter((q) => q !== qname));
|
||||
return;
|
||||
}
|
||||
setSelectedQueues(selectedQueues.filter((q) => q !== qname));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
listQueuesAsync();
|
||||
}, [listQueuesAsync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
getMetricsAsync(endTimeSec, durationSec, selectedQueues);
|
||||
}, [pollInterval, getMetricsAsync, durationSec, endTimeSec, selectedQueues]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" className={classes.container}>
|
||||
<div className={classes.controlsContainer}>
|
||||
<MetricsFetchControls
|
||||
endTimeSec={endTimeSec}
|
||||
onEndTimeChange={handleEndTimeChange}
|
||||
durationSec={durationSec}
|
||||
onDurationChange={handleDurationChange}
|
||||
queues={props.queues}
|
||||
selectedQueues={
|
||||
// If none are selected (e.g. initial state), no filters should apply.
|
||||
selectedQueues.length === 0 ? props.queues : selectedQueues
|
||||
}
|
||||
addQueue={handleAddQueue}
|
||||
removeQueue={handleRemoveQueue}
|
||||
/>
|
||||
</div>
|
||||
<Grid container spacing={3}>
|
||||
{data?.tasks_processed_per_second && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Tasks Processed"
|
||||
description="Number of tasks processed (both succeeded and failed) per second."
|
||||
metrics={data.tasks_processed_per_second}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.tasks_failed_per_second && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Tasks Failed"
|
||||
description="Number of tasks failed per second."
|
||||
metrics={data.tasks_failed_per_second}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.error_rate && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Error Rate"
|
||||
description="Rate of task failures"
|
||||
metrics={data.error_rate}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.queue_size && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Queue Size"
|
||||
description="Total number of tasks in a given queue."
|
||||
metrics={data.queue_size}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.queue_latency_seconds && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Queue Latency"
|
||||
description="Latency of queue, measured by the oldest pending task in the queue."
|
||||
metrics={data.queue_latency_seconds}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
yAxisTickFormatter={(val: number) => val + "s"}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.queue_size && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Queue Memory Usage (approx)"
|
||||
description="Memory usage by queue. Approximate value by sampling a few tasks in a queue."
|
||||
metrics={data.queue_memory_usage_approx_bytes}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
yAxisTickFormatter={(val: number) => {
|
||||
try {
|
||||
return prettyBytes(val);
|
||||
} catch (error) {
|
||||
return val + "B";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.pending_tasks_by_queue && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Pending Tasks"
|
||||
description="Number of pending tasks in a given queue."
|
||||
metrics={data.pending_tasks_by_queue}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.retry_tasks_by_queue && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Retry Tasks"
|
||||
description="Number of retry tasks in a given queue."
|
||||
metrics={data.retry_tasks_by_queue}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{data?.archived_tasks_by_queue && (
|
||||
<Grid item xs={12}>
|
||||
<ChartRow
|
||||
title="Archived Tasks"
|
||||
description="Number of archived tasks in a given queue."
|
||||
metrics={data.archived_tasks_by_queue}
|
||||
endTime={endTimeSec}
|
||||
startTime={endTimeSec - durationSec}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default connector(MetricsView);
|
||||
|
||||
/******** Helper components ********/
|
||||
|
||||
interface ChartRowProps {
|
||||
title: string;
|
||||
description: string;
|
||||
metrics: PrometheusMetricsResponse;
|
||||
endTime: number;
|
||||
startTime: number;
|
||||
yAxisTickFormatter?: (val: number) => string;
|
||||
}
|
||||
|
||||
function ChartRow(props: ChartRowProps) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
<div className={classes.chartInfo}>
|
||||
<Typography color="textPrimary">{props.title}</Typography>
|
||||
<Tooltip title={<div>{props.description}</div>}>
|
||||
<InfoIcon fontSize="small" className={classes.infoIcon} />
|
||||
</Tooltip>
|
||||
{props.metrics.status === "error" && (
|
||||
<div className={classes.errorMessage}>
|
||||
<WarningIcon fontSize="small" className={classes.warningIcon} />
|
||||
<Typography color="textSecondary">
|
||||
Failed to get metrics data: {props.metrics.error}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<QueueMetricsChart
|
||||
data={
|
||||
props.metrics.status === "error"
|
||||
? []
|
||||
: props.metrics.data?.result || []
|
||||
}
|
||||
endTime={props.endTime}
|
||||
startTime={props.startTime}
|
||||
yAxisTickFormatter={props.yAxisTickFormatter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -6,10 +6,11 @@ import Grid from "@material-ui/core/Grid";
|
||||
import TasksTable from "../components/TasksTable";
|
||||
import QueueInfoBanner from "../components/QueueInfoBanner";
|
||||
import QueueBreadCrumb from "../components/QueueBreadcrumb";
|
||||
import { useParams, useLocation } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { listQueuesAsync } from "../actions/queuesActions";
|
||||
import { AppState } from "../store";
|
||||
import { QueueDetailsRouteParams } from "../paths";
|
||||
import { useQuery } from "../hooks";
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
@@ -34,10 +35,6 @@ const useStyles = makeStyles((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function useQuery(): URLSearchParams {
|
||||
return new URLSearchParams(useLocation().search);
|
||||
}
|
||||
|
||||
const validStatus = [
|
||||
"active",
|
||||
"pending",
|
||||
|
Reference in New Issue
Block a user